// ==UserScript==
// @name m3u8Player
// @namespace https://github.com/lol3721987/m3u8Player
// @version 1.2.5
// @license MIT
// @description 支持多源浏览、分类筛选和搜索的M3U8视频播放器,基于HLS.js
// @author zjb & Gemini
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @run-at document-end
// @noframes
// @connect cj.lziapi.com
// @connect json.heimuer.xyz
// @connect cj.rycjapi.com
// @connect bfzyapi.com
// @connect tyyszy.com
// @connect ffzy5.tv
// @connect 360zy.com
// @connect www.iqiyizyapi.com
// @connect wolongzyw.com
// @connect jszyapi.com
// @connect dbzy.tv
// @connect mozhuazy.com
// @connect www.mdzyapi.com
// @connect api.zuidapi.com
// @connect m3u8.apiyhzy.com
// @connect api.apibdzy.com
// @connect api.wujinapi.me
// @connect ikunzyapi.com
// @connect dadiapi.com
// @connect slapibf.com
// @connect aosikazy.com
// @connect apiyutu.com
// @connect thzy1.me
// @connect apilsbzy1.com
// @connect cj.yayazy.net
// @connect hhzyapi.com
// @connect www.jkunzyapi.com
// @connect naixxzy.com
// @connect 155api.com
// @connect apilj.com
// @connect caiji.semaozy.net
// @connect siwazyw.tv
// @connect api.bwzyz.com
// @connect api.souavzy.vip
// @connect www.xxibaozyw.com
// @connect hsckzy.vip
// @connect xingba111.com
// @connect iqiyizyapi.com
// @connect apidanaizi.com
// @connect xzybb1.com
// @connect api.ddapi.cc
// @connect api.sexnguon.com
// @connect www.jingpinx.com
// @connect shayuapi.com
// @connect www.hongniuzy2.com
// @connect www.huyaapi.com
// @connect caiji.maotaizy.cc
// @connect api.zuiseapi.com
// @connect wukongzyz.com
// @connect jyzyapi.com
// @connect api.xiaojizy.live
// @connect api.ukuapi.com
// @connect suoniapi.com
// @connect 91md.me
// @connect collect.wolongzyw.com
// @connect sdzyapi.com
// @connect www.bt4.cc
// @connect api.guangsuapi.com
// @connect www.hongniuzy2.com
// @connect www.36717.info
// @connect api.ffzyapi.com
// @connect www.caoliuzyw.com
// @connect cj.maczy.me
// @connect 360zyzz.com
// @connect www.sufeizy.com
// @connect *
// ==/UserScript==
(function() {
'use strict';
// ===== 配置模块 =====
const ConfigModule = {
API_ENDPOINT: '/api.php/provide/vod',
API_SITES_CONFIG: [
['卧龙资源', 'https://collect.wolongzy.cc', true, '/api.php/provide/vod/'],
['淘片资源', 'https://www.taopianzy.com', true, '/cjapi/mc/vod/json.html'],
['LZI资源', 'https://cj.lziapi.com', true],
['黑木耳', 'https://json.heimuer.xyz', true],
['如意资源', 'https://cj.rycjapi.com', true],
['暴风资源', 'https://bfzyapi.com', true],
['天涯资源', 'https://tyyszy.com', true],
['非凡影视', 'http://ffzy5.tv', true],
['360资源', 'https://360zy.com', true],
['iqiyi资源', 'https://www.iqiyizyapi.com', true],
['极速资源', 'https://jszyapi.com', true],
['豆瓣资源', 'https://dbzy.tv', true],
['魔爪资源', 'https://mozhuazy.com', true],
['魔都资源', 'https://www.mdzyapi.com', true],
['最大资源', 'https://api.zuidapi.com', true],
['樱花资源', 'https://m3u8.apiyhzy.com', true],
['百度云资源', 'https://api.apibdzy.com', true],
['无尽资源', 'https://api.wujinapi.me', true],
['iKun资源', 'https://ikunzyapi.com', true],
['CK资源', 'https://www.ckzy1.com', false],
['大地', 'https://dadiapi.com', true, '/api.php'],
['森林', 'https://slapibf.com', true, '/api.php/provide/vod/'],
['奥斯卡', 'https://aosikazy.com', true, '/api.php/provide/vod/'],
['爱困', 'https://ikunzyapi.com', true, '/api.php/provide/vod'],
['玉兔', 'https://apiyutu.com', true, '/api.php/provide/vod/at/xml'],
['桃花', 'https://thzy1.me', true, '/api.php/provide/vod/'],
['lsp', 'https://apilsbzy1.com', true, '/api.php/provide/vod/at/xml'],
['丫丫', 'https://cj.yayazy.net', true, '/api.php/provide/vod/from/yym3u8/at/xml'],
['豪华', 'https://hhzyapi.com', true, '/api.php/provide/vod'],
['jkun', 'https://www.jkunzyapi.com', true, '/api.php/provide/vod/'],
['奶香香', 'https://naixxzy.com', true, '/api.php/provide/vod/at/xml/'],
['155', 'https://155api.com', true, '/api.php/provide/vod/at/xml/'],
['辣椒', 'https://apilj.com', true, '/api.php/provide/vod/at/xml/'],
['色猫', 'https://caiji.semaozy.net', true, '/inc/api.php'],
['丝袜', 'https://siwazyw.tv', true, '/api.php/provide/vod/at/xml/'],
['百万', 'https://api.bwzyz.com', true, '/api.php/provide/vod/at/json/'],
['搜av', 'https://api.souavzy.vip', true, '/api.php/provide/vod/'],
['x细胞', 'https://www.xxibaozyw.com', true, '/api.php/provide/vod/'],
['黄色仓库', 'https://hsckzy.vip', true, '/api.php/provide/vod/at/json/'],
['性吧', 'https://xingba111.com', true, '/api.php/provide/vod/at/xml'],
['大奶子', 'https://apidanaizi.com', true, '/api.php/provide/vod/at/xml/'],
['杏资源', 'https://xzybb1.com', true, '/api.php/provide/vod/at/xml'],
['滴滴', 'https://api.ddapi.cc', true, '/api.php/provide/vod/'],
['色南国', 'https://api.sexnguon.com', true, '/api.php/provide/vod/'],
['精品', 'http://www.jingpinx.com', true, '/api.php/provide/vod/'],
['鲨鱼', 'https://shayuapi.com', true, '/api.php/provide/vod/'],
['红牛', 'https://www.hongniuzy2.com', true, '/api.php/provide/vod/'],
['虎牙', 'https://www.huyaapi.com', true, '/api.php/provide/vod/at/json/'],
['茅台', 'https://caiji.maotaizy.cc', true, '/api.php/provide/vod/'],
['醉色', 'https://api.zuiseapi.com', true, '/api.php/provide/vod/'],
['悟空', 'https://wukongzyz.com', true, '/api.php/provide/vod/'],
['金鹰', 'https://jyzyapi.com', true, '/provide/vod/'],
['小鸡', 'https://api.xiaojizy.live', true, '/provide/vod'],
['uku', 'https://api.ukuapi.com', true, '/api.php/provide/vod/'],
['索尼', 'https://suoniapi.com', true, '/api.php/provide/vod/'],
['91制片厂', 'https://91md.me', true, '/api.php/provide/vod/'],
['卧龙', 'https://collect.wolongzyw.com', true, '/api.php/provide/vod/'],
['闪电', 'https://sdzyapi.com', true, '/api.php/provide/vod/'],
['bt4', 'http://www.bt4.cc', true, '/api.php/provide/vod/'],
['光速', 'https://api.guangsuapi.com', true, '/api.php/provide/vod/'],
['36717', 'http://www.36717.info', true, '/api.php/provide/vod/'],
['飞飞', 'http://api.ffzyapi.com', true, '/api.php/provide/vod/'],
['草榴', 'https://www.caoliuzyw.com', true, '/api.php/provide/vod/'],
['mac', 'https://cj.maczy.me', true, '/api.php/provide/vod/'],
['360', 'https://360zyzz.com', true, '/api.php/provide/vod/'],
['苏菲', 'https://www.sufeizy.com', true, '/api.php/provide/vod/'],
],
get API_SITES() {
return this.API_SITES_CONFIG.reduce((acc, [name, host, enabled, customEndpoint]) => {
const endpoint = customEndpoint || this.API_ENDPOINT;
let key;
try {
const urlObj = new URL(host);
const hostnameParts = urlObj.hostname.split('.');
key = hostnameParts.length >= 2 ? hostnameParts[hostnameParts.length - 2] : hostnameParts[0];
} catch (e) {
key = `site${Object.keys(acc).length}`;
}
let uniqueKey = key;
let counter = 0;
while (acc[uniqueKey]) {
uniqueKey = `${key}${counter++}`;
}
acc[uniqueKey] = { api: `${host}${endpoint}`, name, enabled };
return acc;
}, {});
},
CONFIG: {
PAGE_SIZE: 12,
SEARCH_TIMEOUT: 8000,
MAX_HISTORY_ITEMS: 50,
STORAGE_KEYS: {
LAST_SEARCH: 'iePlayer_lastSearch',
SELECTED_SOURCES: 'iePlayer_selectedSources',
IS_AGGREGATED: 'iePlayer_isAggregated',
PLAY_HISTORY: 'iePlayer_playHistory'
}
},
HLS_JS_CDNS: [
'https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js',
'https://cdn.bootcdn.net/ajax/libs/hls.js/1.4.12/hls.min.js'
],
getEnabledSources() {
return Object.entries(this.API_SITES).filter(([, source]) => source.enabled);
},
getSource(sourceKey) {
return this.API_SITES[sourceKey] || null;
},
};
// ===== 存储模块 =====
const StorageModule = {
get(key, def) { return GM_getValue(key, def); },
set(key, val) { GM_setValue(key, val); },
getLastSearch() { return this.get(ConfigModule.CONFIG.STORAGE_KEYS.LAST_SEARCH, ''); },
setLastSearch(keyword) { this.set(ConfigModule.CONFIG.STORAGE_KEYS.LAST_SEARCH, keyword); },
getSelectedSources() { return this.get(ConfigModule.CONFIG.STORAGE_KEYS.SELECTED_SOURCES, ['lziapi']); },
setSelectedSources(sources) { this.set(ConfigModule.CONFIG.STORAGE_KEYS.SELECTED_SOURCES, sources); },
getIsAggregated() { return this.get(ConfigModule.CONFIG.STORAGE_KEYS.IS_AGGREGATED, false); },
setIsAggregated(isAggregated) { this.set(ConfigModule.CONFIG.STORAGE_KEYS.IS_AGGREGATED, isAggregated); },
getPlayHistory() { return this.get(ConfigModule.CONFIG.STORAGE_KEYS.PLAY_HISTORY, []); },
setPlayHistory(history) { this.set(ConfigModule.CONFIG.STORAGE_KEYS.PLAY_HISTORY, history); },
addPlayHistory(videoData) {
let history = this.getPlayHistory();
const { url, title, source, episode } = videoData;
const existingIndex = history.findIndex(item => item.url === url);
if (existingIndex > -1) {
history.splice(existingIndex, 1);
}
history.unshift({ id: `hist_${Date.now()}`, url, title, source, episode, time: Date.now() });
if (history.length > ConfigModule.CONFIG.MAX_HISTORY_ITEMS) {
history.pop();
}
this.setPlayHistory(history);
},
removePlayHistory(id) {
let history = this.getPlayHistory();
this.setPlayHistory(history.filter(item => item.id !== id));
},
clearPlayHistory() { this.setPlayHistory([]); },
};
// ===== 状态管理模块 =====
const StateModule = {
state: {
searchPanel: null,
currentPlayer: null,
currentPage: 1,
totalPages: 1,
homepageCurrentPage: 1,
homepageTotalPages: 1,
homepageCategories: {},
homepageSelectedCategory: null,
selectedSources: ['lziapi'],
isAggregatedSearch: false,
isSearching: false,
searchController: null,
playHistory: []
},
get(key) { return this.state[key]; },
set(key, val) { this.state[key] = val; },
init() {
this.set('isAggregatedSearch', StorageModule.getIsAggregated());
this.set('selectedSources', StorageModule.getSelectedSources());
this.set('playHistory', StorageModule.getPlayHistory());
}
};
// ===== API模块 =====
const APIModule = {
_request(url, params) {
return new Promise((resolve, reject) => {
const fullUrl = `${url}?${params.toString()}`;
GM_xmlhttpRequest({
method: 'GET',
url: fullUrl,
timeout: ConfigModule.CONFIG.SEARCH_TIMEOUT,
onload: (res) => {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(new Error('JSON parsing failed'));
}
},
onerror: () => reject(new Error('Request failed')),
ontimeout: () => reject(new Error('Request timed out')),
});
});
},
async fetchCategories(sourceKey) {
const source = ConfigModule.getSource(sourceKey);
if (!source) return [];
try {
const data = await this._request(source.api, new URLSearchParams());
return data.class || [];
} catch (error) {
console.error(`Failed to fetch categories from ${source.name}:`, error);
return [];
}
},
async fetchSourceHomepage(sourceKey, page, categoryId) {
const source = ConfigModule.getSource(sourceKey);
if (!source) return null;
const params = new URLSearchParams({ pg: page, limit: ConfigModule.CONFIG.PAGE_SIZE });
if (categoryId) {
params.set('t', categoryId);
}
try {
const data = await this._request(source.api, params);
if (data.list) {
data.list.forEach(item => {
item.source_name = source.name;
item.source_key = sourceKey;
});
}
return data;
} catch (error) {
return null;
}
},
search(sourceKey, keyword, page, abortSignal) {
return new Promise((resolve, reject) => {
const source = ConfigModule.getSource(sourceKey);
if (!source) return reject(new Error('Invalid source'));
const params = new URLSearchParams({ ac: 'list', wd: keyword, pg: page });
const req = GM_xmlhttpRequest({
method: 'GET',
url: `${source.api}?${params.toString()}`,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.list) data.list.forEach(item => {
item.source_name = source.name;
item.source_key = sourceKey;
});
resolve(data);
} catch (e) {
reject(e);
}
},
onerror: reject,
});
if (abortSignal) abortSignal.onabort = () => req.abort();
});
},
getVideoDetail(videoId, sourceKey) {
return this._request(ConfigModule.getSource(sourceKey).api, new URLSearchParams({ ac: 'detail', ids: videoId }));
},
};
// ===== 工具函数模块 =====
const UtilsModule = {
copyToClipboard(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
};
// ===== UI模块 =====
const UIModule = {
_extractAndFormatImageUrl(picString, sourceKey) {
if (!picString) return '';
const firstPart = picString.split('$$$')[0];
const urlMatch = firstPart.match(/https?:\/\/[^\s"]+\.(?:jpg|jpeg|png|gif|webp)|^\/[^\s"]+\.(?:jpg|jpeg|png|gif|webp)/i);
let picUrl = urlMatch ? urlMatch[0] : '';
if (!picUrl) return '';
if (picUrl.startsWith('http')) return picUrl;
try {
const source = ConfigModule.getSource(sourceKey);
const origin = new URL(source.api).origin;
return `${origin}${picUrl.startsWith('/') ? picUrl : '/' + picUrl}`;
} catch (e) {
return '';
}
},
createSearchPanel() {
const panel = document.createElement('div');
panel.className = 'iePlayer-search-panel';
panel.innerHTML = `
<div class="iePlayer-panel-header"><h3 class="iePlayer-panel-title">🎬 M3U8播放器</h3><button class="iePlayer-close-btn">×</button></div>
<div class="iePlayer-tabs">
<button class="iePlayer-tab-btn active" data-tab="search">视频搜索</button>
<button class="iePlayer-tab-btn" data-tab="homepage">资源首页</button>
<button class="iePlayer-tab-btn" data-tab="direct-play">链接播放</button>
<button class="iePlayer-tab-btn" data-tab="history">播放历史</button>
</div>
<div class="iePlayer-panel-body">
<div class="iePlayer-tab-content active" id="iePlayer-tab-search">
<div class="iePlayer-section"><div class="iePlayer-source-header">📺 选择视频源</div><div class="iePlayer-source-options" id="iePlayer-source-options"></div></div>
<div class="iePlayer-section"><div class="iePlayer-search-form"><div class="iePlayer-form-header">🔍 视频搜索</div><input type="text" class="iePlayer-search-input" placeholder="输入视频名称..."><button class="iePlayer-search-btn">搜索</button></div></div>
<div class="iePlayer-loading" style="display:none;">搜索中...</div>
<div class="iePlayer-results"></div>
<div class="iePlayer-pagination" style="display:none;"><button id="iePlayer-prev-page" class="iePlayer-page-btn">上一页</button><span class="iePlayer-page-info"></span><button id="iePlayer-next-page" class="iePlayer-page-btn">下一页</button></div>
</div>
<div class="iePlayer-tab-content" id="iePlayer-tab-homepage">
<div class="iePlayer-homepage-categories"></div>
<div class="iePlayer-homepage-loading" style="display:none;">加载中...</div>
<div class="iePlayer-homepage-tip"></div>
<div class="iePlayer-homepage-results"></div>
<div class="iePlayer-homepage-pagination" style="display:none;"><button id="iePlayer-prev-homepage" class="iePlayer-page-btn">上一页</button><span class="iePlayer-homepage-page-info"></span><button id="iePlayer-next-homepage" class="iePlayer-page-btn">下一页</button></div>
</div>
<div class="iePlayer-tab-content" id="iePlayer-tab-direct-play"><div class="iePlayer-m3u8-form"><div class="iePlayer-m3u8-header">🔗 直接播放</div><input type="text" class="iePlayer-m3u8-input" placeholder="输入M3U8链接..."><button class="iePlayer-m3u8-btn">播放</button></div></div>
<div class="iePlayer-tab-content" id="iePlayer-tab-history"><div class="iePlayer-history-header"><div class="iePlayer-history-title">📚 播放历史</div><button class="iePlayer-clear-history-btn">清空</button></div><div class="iePlayer-history-list"></div><div class="iePlayer-history-empty" style="display:none;">暂无历史</div></div>
</div>`;
document.body.appendChild(panel);
return panel;
},
initializeSourceSelector() {
const container = document.getElementById('iePlayer-source-options'); if (!container) return; container.innerHTML = '';
[{key: 'aggregated', name: '聚合搜索'}, ...ConfigModule.getEnabledSources().map(([key, val])=>({key, ...val}))].forEach(source => {
const isAgg = source.key === 'aggregated'; const isChecked = isAgg ? StateModule.get('isAggregatedSearch') : StateModule.get('selectedSources').includes(source.key);
const opt = document.createElement('div'); opt.className = 'iePlayer-source-option';
opt.innerHTML = `<label><input type="radio" name="iePlayer-searchType" value="${source.key}" ${isChecked ? 'checked' : ''}><span>${source.name}</span></label>`;
container.appendChild(opt);
});
},
displayCategories(categories) {
const container = document.querySelector('.iePlayer-homepage-categories'); if (!container) return; container.innerHTML = '';
[{type_id: null, type_name: '最新'}, ...categories].forEach(cat => {
const btn = document.createElement('button'); btn.className = 'iePlayer-category-btn';
btn.textContent = cat.type_name; btn.dataset.id = cat.type_id;
if (String(StateModule.get('homepageSelectedCategory')) === String(cat.type_id)) btn.classList.add('active');
container.appendChild(btn);
});
},
renderVideoList(container, videoList) {
container.innerHTML = ''; const fragment = document.createDocumentFragment();
videoList.forEach(video => {
const item = document.createElement('div'); item.className = 'iePlayer-result-item';
const imageUrl = this._extractAndFormatImageUrl(video.vod_pic, video.source_key);
item.innerHTML = `
<div class="iePlayer-video-thumb">
<img src="${imageUrl}" alt="" loading="lazy" onerror="this.style.display='none'; this.parentElement.classList.add('no-image');">
</div>
<div class="iePlayer-video-details">
<div class="iePlayer-video-header"><div class="iePlayer-video-title">${video.vod_name}</div><div class="iePlayer-source-badge">${video.source_name}</div></div>
<div class="iePlayer-video-info">${video.type_name || '未知'} | ${video.vod_year || '未知'} | ${video.vod_remarks || '无'}</div>
<div class="iePlayer-play-sources" data-video-id="${video.vod_id}" data-source-key="${video.source_key}">
<button class="iePlayer-load-sources-btn">加载播放线路</button>
</div>
<div class="iePlayer-episode-list"></div>
</div>`;
fragment.appendChild(item);
});
container.appendChild(fragment);
},
updatePagination(type) {
const isHomepage = type === 'homepage';
const pageInfo = document.querySelector(isHomepage ? '.iePlayer-homepage-page-info' : '.iePlayer-page-info');
const prevBtn = document.getElementById(isHomepage ? 'iePlayer-prev-homepage' : 'iePlayer-prev-page');
const nextBtn = document.getElementById(isHomepage ? 'iePlayer-next-homepage' : 'iePlayer-next-page');
const currentPage = StateModule.get(isHomepage ? 'homepageCurrentPage' : 'currentPage');
const totalPages = StateModule.get(isHomepage ? 'homepageTotalPages' : 'totalPages');
if (pageInfo) pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页`;
if (prevBtn) prevBtn.disabled = currentPage <= 1;
if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
},
toggleSearchPanel() {
let searchPanel = StateModule.get('searchPanel');
if (!searchPanel) {
searchPanel = this.createSearchPanel();
StateModule.set('searchPanel', searchPanel);
EventModule.initSearchPanel();
}
const isVisible = searchPanel.classList.toggle('show');
if (isVisible) {
searchPanel.querySelector('.iePlayer-search-input').value = StorageModule.getLastSearch();
}
},
displayPlayHistory() {
const list = document.querySelector('.iePlayer-history-list');
const empty = document.querySelector('.iePlayer-history-empty');
const history = StorageModule.getPlayHistory();
if (history.length === 0) { list.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
list.innerHTML = history.map(item => `
<div class="iePlayer-history-item">
<div class="iePlayer-history-info">
<div class="iePlayer-history-title">${item.title}</div>
<div class="iePlayer-history-meta"><span class="iePlayer-history-source">${item.source}</span><span class="iePlayer-history-episode">${item.episode}</span></div>
</div>
<div class="iePlayer-history-actions">
<button class="iePlayer-history-play-btn" data-url="${item.url}" data-title="${item.title}" data-source="${item.source}" data-episode="${item.episode}">播放</button>
<button class="iePlayer-history-delete-btn" data-id="${item.id}">删除</button>
</div>
</div>`).join('');
},
};
// ===== 事件管理模块 =====
const EventModule = {
initSearchPanel() {
const panel = StateModule.get('searchPanel');
if (!panel) return;
UIModule.initializeSourceSelector();
panel.addEventListener('click', this.handlePanelClick.bind(this));
panel.querySelector('.iePlayer-search-input').addEventListener('keypress', (e) => { if(e.key === 'Enter') this.performSearch(); });
this.makeDraggable(panel);
},
async handlePanelClick(e) {
const target = e.target;
const panel = StateModule.get('searchPanel');
if (target.matches('.iePlayer-tab-btn')) {
panel.querySelector('.iePlayer-tab-btn.active').classList.remove('active');
target.classList.add('active');
panel.querySelector('.iePlayer-tab-content.active').classList.remove('active');
panel.querySelector(`#iePlayer-tab-${target.dataset.tab}`).classList.add('active');
if (target.dataset.tab === 'homepage') await this.loadHomepage();
if (target.dataset.tab === 'history') UIModule.displayPlayHistory();
} else if (target.matches('input[name="iePlayer-searchType"]')) {
const isAgg = target.value === 'aggregated';
StateModule.set('isAggregatedSearch', isAgg);
StateModule.set('selectedSources', isAgg ? [] : [target.value]);
StorageModule.setIsAggregated(isAgg);
StorageModule.setSelectedSources(isAgg ? [] : [target.value]);
if (panel.querySelector('.iePlayer-tab-btn[data-tab="homepage"].active')) await this.loadHomepage();
} else if (target.matches('.iePlayer-search-btn')) await this.performSearch();
else if (target.id === 'iePlayer-prev-page') this.loadSearchPage(StateModule.get('currentPage') - 1);
else if (target.id === 'iePlayer-next-page') this.loadSearchPage(StateModule.get('currentPage') + 1);
else if (target.id === 'iePlayer-prev-homepage') await this.loadHomepage(StateModule.get('homepageCurrentPage') - 1);
else if (target.id === 'iePlayer-next-homepage') await this.loadHomepage(StateModule.get('homepageCurrentPage') + 1);
else if (target.matches('.iePlayer-category-btn')) {
const catId = target.dataset.id === 'null' ? null : target.dataset.id;
StateModule.set('homepageSelectedCategory', catId);
await this.loadHomepage();
} else if (target.matches('.iePlayer-load-sources-btn')) {
target.textContent = '加载中...';
const sourcesDiv = target.parentElement;
const { videoId, sourceKey } = sourcesDiv.dataset;
try {
const data = await APIModule.getVideoDetail(videoId, sourceKey);
const playUrl = data.list[0].vod_play_url;
sourcesDiv.innerHTML = playUrl.split('$$$').map((source, index) => {
const name = source.split('$')[0];
return `<button class="iePlayer-play-btn" data-source-index="${index}">${/^[a-zA-Z0-9]+$/.test(name) && name.length < 10 ? name : `线路${index + 1}`}</button>`;
}).join('');
} catch { sourcesDiv.innerHTML = '加载失败'; }
} else if (target.matches('.iePlayer-play-btn')) {
const detailsDiv = target.closest('.iePlayer-video-details');
const episodeList = detailsDiv.querySelector('.iePlayer-episode-list');
const { videoId, sourceKey } = detailsDiv.querySelector('.iePlayer-play-sources').dataset;
const sourceIndex = target.dataset.sourceIndex;
if(episodeList.dataset.currentIndex === sourceIndex && episodeList.style.display === 'grid') { episodeList.style.display = 'none'; return; }
episodeList.innerHTML = '加载中...'; episodeList.style.display = 'grid'; episodeList.dataset.currentIndex = sourceIndex;
const data = await APIModule.getVideoDetail(videoId, sourceKey);
const playListString = data.list[0].vod_play_url.split('$$$')[sourceIndex];
const episodes = playListString.split(/[#\n\r]+/).filter(ep => ep.trim());
episodeList.innerHTML = episodes.map(ep => {
const [name, url] = ep.includes('$') ? ep.split('$', 2) : [ep.split('/').pop().slice(0, 15), ep];
return `<button class="iePlayer-episode-btn" data-url="${url.trim()}" data-title="${data.list[0].vod_name} | ${name}">${name}</button>`;
}).join('');
} else if (target.matches('.iePlayer-episode-btn')) {
const { url, title } = target.dataset;
const sourceName = target.closest('.iePlayer-result-item').querySelector('.iePlayer-source-badge').textContent;
PlayerModule.openVideoPlayer(url, title, sourceName, target.textContent);
} else if (target.matches('.iePlayer-close-btn')) panel.classList.remove('show');
else if (target.matches('.iePlayer-m3u8-btn')) PlayerModule.openVideoPlayer(panel.querySelector('.iePlayer-m3u8-input').value.trim(), '直接播放');
else if (target.matches('.iePlayer-clear-history-btn')) { StorageModule.clearPlayHistory(); UIModule.displayPlayHistory(); }
else if (target.matches('.iePlayer-history-play-btn')) { const { url, title, source, episode } = target.dataset; PlayerModule.openVideoPlayer(url, title, source, episode); }
else if (target.matches('.iePlayer-history-delete-btn')) { StorageModule.removePlayHistory(target.dataset.id); UIModule.displayPlayHistory(); }
},
async loadHomepage(page = 1) {
const panel = StateModule.get('searchPanel');
const tipDiv = panel.querySelector('.iePlayer-homepage-tip');
const resultsDiv = panel.querySelector('.iePlayer-homepage-results');
const loadingDiv = panel.querySelector('.iePlayer-homepage-loading');
const paginationDiv = panel.querySelector('.iePlayer-homepage-pagination');
const categoriesDiv = panel.querySelector('.iePlayer-homepage-categories');
resultsDiv.innerHTML = ''; paginationDiv.style.display = 'none'; tipDiv.innerHTML = '';
if (StateModule.get('isAggregatedSearch')) { tipDiv.textContent = '请选择一个单一视频源以浏览。'; categoriesDiv.innerHTML=''; return; }
const sourceKey = StateModule.get('selectedSources')[0];
if (!sourceKey) { tipDiv.textContent = '请先选择一个视频源。'; categoriesDiv.innerHTML=''; return; }
loadingDiv.style.display = 'block';
if (!StateModule.get('homepageCategories')[sourceKey] || page === 1) {
const categories = await APIModule.fetchCategories(sourceKey);
StateModule.get('homepageCategories')[sourceKey] = categories;
if(StateModule.get('selectedSources')[0] === sourceKey) {
UIModule.displayCategories(categories);
}
} else {
UIModule.displayCategories(StateModule.get('homepageCategories')[sourceKey]);
}
const selectedCatId = StateModule.get('homepageSelectedCategory');
const data = await APIModule.fetchSourceHomepage(sourceKey, page, selectedCatId);
loadingDiv.style.display = 'none';
if (data && data.list && data.list.length > 0) {
UIModule.renderVideoList(resultsDiv, data.list);
StateModule.set('homepageCurrentPage', data.page);
StateModule.set('homepageTotalPages', data.pagecount);
paginationDiv.style.display = 'flex';
UIModule.updatePagination('homepage');
} else {
resultsDiv.innerHTML = '<div class="iePlayer-no-results">未能加载到内容。</div>';
}
},
async performSearch() {
const keyword = document.querySelector('.iePlayer-search-input').value.trim();
if (StateModule.get('isSearching')) { StateModule.get('searchController')?.abort(); return; }
if (!keyword) return;
const controller = new AbortController();
StateModule.set('searchController', controller);
StateModule.set('isSearching', true);
document.querySelector('.iePlayer-loading').style.display = 'block';
document.querySelector('.iePlayer-results').innerHTML = '';
try {
const results = await APIModule.search(StateModule.get('selectedSources')[0], keyword, 1, controller.signal);
if (controller.signal.aborted) return;
const resultsDiv = document.querySelector('.iePlayer-results');
UIModule.renderVideoList(resultsDiv, results.list);
} catch (error) {
if (!controller.signal.aborted) document.querySelector('.iePlayer-results').innerHTML = '<div class="iePlayer-no-results">搜索失败或无结果。</div>';
} finally {
StateModule.set('isSearching', false);
document.querySelector('.iePlayer-loading').style.display = 'none';
}
},
makeDraggable(element) {
let isDragging = false, initialX, initialY, xOffset = 0, yOffset = 0;
const header = element.querySelector('.iePlayer-panel-header');
const dragStart = (e) => { initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; if (header.contains(e.target)) isDragging = true; };
const drag = (e) => { if (isDragging) { e.preventDefault(); xOffset = e.clientX - initialX; yOffset = e.clientY - initialY; element.style.transform = `translate3d(${xOffset}px, ${yOffset}px, 0)`; } };
document.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', () => isDragging = false);
},
};
// ===== 播放器模块 =====
const PlayerModule = {
openVideoPlayer(url, title, source = '', episode = '') {
if (!url) { alert('播放地址无效'); return; }
StorageModule.addPlayHistory({ title, url, source, episode });
const container = document.createElement('div');
container.className = 'iePlayer-player-container';
container.innerHTML = `<div class="iePlayer-player-wrapper"><div class="iePlayer-player-header"><div class="iePlayer-player-title">${title}</div><button class="iePlayer-player-close">×</button></div><video style="width:100%;height:100%;background:#000;" controls autoplay playsinline></video><div class="iePlayer-loading-player">加载中...</div></div>`;
document.body.appendChild(container);
container.classList.add('show');
const closeHandler = () => this.closeVideoPlayer(container);
container.querySelector('.iePlayer-player-close').addEventListener('click', closeHandler);
const keyHandler = (e) => { if (e.key === 'Escape') closeHandler(); };
document.addEventListener('keydown', keyHandler);
container.keyHandler = keyHandler;
this.initVideoPlayer(container, url);
},
initVideoPlayer(container, url) {
const video = container.querySelector('video');
const loading = container.querySelector('.iePlayer-loading-player');
const loadHls = () => {
if (typeof Hls !== 'undefined') { this.setupVideoPlayer(Hls, container, url, video, loading); return; }
const script = document.createElement('script');
script.src = ConfigModule.HLS_JS_CDNS[0];
script.onload = () => this.setupVideoPlayer(Hls, container, url, video, loading);
script.onerror = () => { loading.textContent = '播放器库加载失败'; };
document.head.appendChild(script);
};
if (url.toLowerCase().includes('m3u8')) loadHls();
else { video.src = url; loading.style.display = 'none'; video.play().catch(()=>{}); }
},
setupVideoPlayer(Hls, container, url, video, loading) {
loading.style.display = 'none';
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(()=>{}));
container.hlsInstance = hls;
} else {
loading.style.display = 'block';
loading.textContent = '浏览器不支持HLS';
}
},
closeVideoPlayer(container) {
container.hlsInstance?.destroy();
document.removeEventListener('keydown', container.keyHandler);
container.remove();
}
};
// ===== 初始化样式 =====
function initStyles() {
GM_addStyle(`
:root { --panel-width: 420px; --primary-color: #667eea; --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.iePlayer-search-panel { position: fixed; top: 20px; right: 20px; width: var(--panel-width); max-height: 90vh; background: #f8f9fa; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); z-index: 999999; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: none; flex-direction: column; }
.iePlayer-search-panel.show { display: flex; }
.iePlayer-panel-header { background: var(--primary-gradient); color: white; padding: 12px 18px; display: flex; justify-content: space-between; align-items: center; cursor: move; border-radius: 12px 12px 0 0;}
.iePlayer-panel-title { font-size: 16px; font-weight: 600; margin: 0; }
.iePlayer-close-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; }
.iePlayer-close-btn:hover { opacity: 1; }
.iePlayer-tabs { display: flex; background-color: #f1f3f5; padding: 3px 15px 0; flex-shrink: 0; }
.iePlayer-tab-btn { padding: 10px 15px; cursor: pointer; border: none; background: transparent; font-size: 14px; font-weight: 500; color: #868e96; position: relative; }
.iePlayer-tab-btn.active { color: var(--primary-color); }
.iePlayer-tab-btn.active::after { content: ''; position: absolute; bottom: -1px; left: 0; right: 0; height: 3px; background-color: var(--primary-color); border-radius: 3px 3px 0 0; }
.iePlayer-panel-body { padding: 15px; overflow-y: auto; flex-grow: 1; }
.iePlayer-tab-content { display: none; } .iePlayer-tab-content.active { display: block; }
.iePlayer-section, .iePlayer-m3u8-form { margin-bottom: 15px; padding: 15px; background: #fff; border-radius: 8px; border: 1px solid #e9ecef; }
.iePlayer-source-header, .iePlayer-form-header, .iePlayer-m3u8-header, .iePlayer-history-title { font-weight: 600; color: #343a40; font-size: 14px; margin-bottom: 12px; }
.iePlayer-source-options { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.iePlayer-source-option label { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s ease; border: 1px solid #dee2e6; }
.iePlayer-source-option input[type="radio"] { accent-color: var(--primary-color); }
.iePlayer-source-option label:has(input:checked) { background: #e8eaf6; color: var(--primary-color); border-color: var(--primary-color); }
.iePlayer-source-option label:has(input:checked) > span { font-weight: 600; }
.iePlayer-search-input, .iePlayer-m3u8-input { width: 100%; padding: 12px; border: 1px solid #ced4da; border-radius: 6px; margin-bottom: 12px; box-sizing: border-box; }
.iePlayer-search-btn, .iePlayer-m3u8-btn, .iePlayer-page-btn, .iePlayer-clear-history-btn { padding: 10px; color: white; border: none; border-radius: 6px; cursor: pointer; background: var(--primary-gradient); font-size: 14px; }
.iePlayer-page-btn { background: white; color: var(--primary-color); border: 1px solid var(--primary-color); padding: 8px 12px; font-size: 13px; }
.iePlayer-page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.iePlayer-loading, .iePlayer-homepage-loading, .iePlayer-no-results, .iePlayer-homepage-tip { text-align: center; padding: 40px 20px; color: #6c757d; }
.iePlayer-results, .iePlayer-homepage-results { display: flex; flex-direction: column; gap: 12px; }
.iePlayer-result-item { display: flex; gap: 12px; background: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; transition: box-shadow 0.2s; }
.iePlayer-result-item:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.iePlayer-video-thumb { flex-shrink: 0; width: 80px; height: 110px; border-radius: 4px; background-color: #f1f3f5; }
.iePlayer-video-thumb.no-image::before { content: '🖼️'; display: grid; place-items: center; width: 100%; height: 100%; font-size: 30px; color: #adb5bd; }
.iePlayer-video-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
.iePlayer-video-details { flex-grow: 1; display: flex; flex-direction: column; min-width: 0; }
.iePlayer-video-header { display: flex; justify-content: space-between; margin-bottom: 4px; }
.iePlayer-video-title { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.iePlayer-source-badge { font-size: 11px; background: #e9ecef; padding: 2px 6px; border-radius: 4px; flex-shrink: 0; margin-left: 8px; }
.iePlayer-video-info { font-size: 12px; color: #6c757d; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.iePlayer-play-sources { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 8px; }
.iePlayer-play-btn, .iePlayer-load-sources-btn, .iePlayer-episode-btn { font-size: 12px; padding: 4px 8px; border: 1px solid #dee2e6; background: #f8f9fa; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; }
.iePlayer-play-btn:hover, .iePlayer-load-sources-btn:hover, .iePlayer-episode-btn:hover { background-color: #e9ecef; }
.iePlayer-episode-list { display: none; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #e9ecef;}
.iePlayer-episode-btn { width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.iePlayer-pagination, .iePlayer-homepage-pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; }
.iePlayer-homepage-categories { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #e9ecef; }
.iePlayer-category-btn { padding: 6px 12px; font-size: 13px; border: 1px solid #dee2e6; background: #fff; border-radius: 16px; cursor: pointer; }
.iePlayer-category-btn.active { background: var(--primary-color); color: white; border-color: var(--primary-color); }
.iePlayer-history-header { display: flex; justify-content: space-between; align-items: center; }
.iePlayer-clear-history-btn { background: #f8f9fa; color: #dc3545; border: 1px solid #dc3545; }
.iePlayer-history-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e9ecef; }
.iePlayer-history-title { font-weight: 500; }
.iePlayer-history-meta { font-size: 12px; color: #6c757d; }
.iePlayer-history-play-btn { font-size: 13px; padding: 6px 10px; background: var(--primary-color); color: white; border: none; border-radius: 4px; }
.iePlayer-float-btn { position: fixed; bottom: 20px; right: 20px; width: 60px; height: 60px; background: var(--primary-gradient); border-radius: 50%; border: none; color: white; font-size: 24px; cursor: pointer; z-index: 999998; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
.iePlayer-player-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000000; display: none; align-items: center; justify-content: center; }
.iePlayer-player-container.show { display: flex; }
.iePlayer-player-wrapper { width: 90%; max-width: 1200px; height: 70%; position: relative; }
.iePlayer-player-header { position: absolute; top: 0; left: 0; right: 0; padding: 15px; color: white; }
.iePlayer-loading-player { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: white; }
`);
}
// ===== 主初始化函数 =====
function init() {
if (window.self !== window.top) return;
StateModule.init();
initStyles();
const floatBtn = document.createElement('button');
floatBtn.className = 'iePlayer-float-btn';
floatBtn.textContent = '🎬';
floatBtn.onclick = () => UIModule.toggleSearchPanel();
document.body.appendChild(floatBtn);
GM_registerMenuCommand('🎬 打开视频搜索', () => UIModule.toggleSearchPanel());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();