// ==UserScript==
// @name 阿豆影视
// @namespace https://scriptcat.org/zh-CN/script-show-page/5799
// @match https://movie.douban.com/subject/*
// @match https://m.douban.com/movie/subject/*
// @match https://*.douban.com/*subject*
// @match https://douban.com/doubanapp/dispatch*
// @match https://movie.douban.com/
// @match https://movie.douban.com/tv*
// @match https://movie.douban.com/explore*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_deleteValue
// @connect www.hongniuzy2.com
// @connect bfzyapi.com
// @connect cj.ffzyapi.com
// @connect cj.lziapi.com
// @connect ikunzyapi.com
// @connect api.guangsuapi.com
// @connect suoniapi.com
// @connect sdzyapi.com
// @connect collect.wolongzyw.com
// @connect api.apibdzy.com
// @connect cj.yayazy.net
// @connect jyzyapi.com
// @connect api.wujinapi.me
// @connect www.huyaapi.com
// @connect caiji.dyttzyapi.com
// @connect p2100.net
// @connect zuidazy.me
// @connect api.zuidapi.com
// @connect caiji.moduapi.cc
// @connect www.mdzyapi.com
// @connect wolongzyw.com
// @connect api.xinlangapi.com
// @connect iqiyizyapi.com
// @connect caiji.dbzy5.com
// @connect tyyszy.com
// @connect cj.rycjapi.com
// @connect jinyingzy.com
// @connect www.bing.com
// @connect jszyapi.com
// @connect wjzyapi.com
// @connect apiyutu.com
// @run-at document-end
// @require https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.0/artplayer.min.js
// @require https://unpkg.com/artplayer-plugin-control@2.0.0/dist/artplayer-plugin-control.js
// @require https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/vue/2.7.9/vue.min.js
// @require https://unpkg.com/pinyin@0.4.0/dist/pinyin.js
// @version 6.7
// @author 失辛向南
// @description 在豆瓣电影详情页提供一键播放功能,聚合多影视资源站搜索、播放、历史记录、测速切换源及自动连播。
// @license MIT
// @supportURL https://scriptcat.org/zh-CN/script-show-page/5799/issue
// ==/UserScript==
// ==================== 配置区 ====================
const ANTI_BLOCK_CONFIG = {
maxConcurrent: 3,
requestJitterMin: 100,
requestJitterMax: 300,
doubanDataReadOnce: true,
};
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6小时缓存
const MAX_CACHE_ITEMS_PER_MOVIE = 50;
const SEARCH_CACHE_ENABLED_KEY = 'searchCacheEnabled';
const isSearchCacheEnabled = () => {
const val = GM_getValue(SEARCH_CACHE_ENABLED_KEY, undefined);
return val === undefined ? true : val;
};
const setSearchCacheEnabled = (enabled) => {
GM_setValue(SEARCH_CACHE_ENABLED_KEY, enabled);
};
const cleanSearchCache = () => {
if (!isSearchCacheEnabled()) return;
const allKeys = GM_listValues();
const cacheKeys = allKeys.filter(key => key.startsWith('search_'));
if (cacheKeys.length === 0) return;
const groups = new Map();
cacheKeys.forEach(key => {
const parts = key.split('_');
if (parts.length < 3) return;
const movieId = parts[1];
const val = GM_getValue(key, null);
if (!val || !val.timestamp) return;
if (!groups.has(movieId)) groups.set(movieId, []);
groups.get(movieId).push({ key, timestamp: val.timestamp });
});
for (let [movieId, items] of groups.entries()) {
if (items.length <= MAX_CACHE_ITEMS_PER_MOVIE) continue;
items.sort((a, b) => b.timestamp - a.timestamp);
const toDelete = items.slice(MAX_CACHE_ITEMS_PER_MOVIE);
toDelete.forEach(item => GM_deleteValue(item.key));
}
};
const UNIFIED_HISTORY_KEY = 'unified_watch_history';
const MAX_HISTORY_ITEMS = 50;
const HISTORY_EXPIRE_DAYS = 7;
const AUTO_NEXT_ENABLED_KEY = 'autoPlayNextEnabled';
// 所有资源站原始列表
const ALL_SOURCES = [
{ name: "红牛资源", searchUrl: "https://www.hongniuzy2.com/api.php/provide/vod/from/hnm3u8/" },
{ name: "暴风资源", searchUrl: "https://bfzyapi.com/api.php/provide/vod/" },
{ name: "非凡资源", searchUrl: "https://cj.ffzyapi.com/api.php/provide/vod/" },
{ name: "量子资源", searchUrl: "https://cj.lziapi.com/api.php/provide/vod/" },
{ name: "极速资源", searchUrl: "https://jszyapi.com/api.php/provide/vod" },
{ name: "卧龙资源", searchUrl: "https://collect.wolongzyw.com/api.php/provide/vod/" },
{ name: "百度云资源", searchUrl: "https://api.apibdzy.com/api.php/provide/vod/" },
{ name: "金鹰资源", searchUrl: "https://jyzyapi.com/provide/vod/from/jinyingm3u8/at/json" },
{ name: "无尽资源", searchUrl: "https://api.wujinapi.me/api.php/provide/vod/" },
{ name: "虎牙资源", searchUrl: "https://www.huyaapi.com/api.php/provide/vod/from/hym3u8" },
{ name: "天堂资源", searchUrl: "https://caiji.dyttzyapi.com/api.php/provide/vod/from/dyttm3u8/at/m3u8/" },
{ name: "飘零资源", searchUrl: "https://p2100.net/api.php/provide/vod/" },
{ name: "最大资源", searchUrl: "http://zuidazy.me/api.php/provide/vod/" },
{ name: "最二资源", searchUrl: "https://api.zuidapi.com/api.php/provide/vod/" },
{ name: "魔帝资源", searchUrl: "https://caiji.moduapi.cc/api.php/provide/vod/" },
{ name: "魔都资源", searchUrl: "https://www.mdzyapi.com/api.php/provide/vod/" },
{ name: "卧神资源", searchUrl: "https://wolongzyw.com/api.php/provide/vod/" },
{ name: "心浪资源", searchUrl: "https://api.xinlangapi.com/xinlangapi.php/provide/vod" },
{ name: "爱妻资源", searchUrl: "https://iqiyizyapi.com/api.php/provide/vod/" },
{ name: "都伴资源", searchUrl: "https://caiji.dbzy5.com/api.php/provide/vod/" },
{ name: "如意资源", searchUrl: "https://cj.rycjapi.com/api.php/provide/vod/" },
{ name: "精英资源", searchUrl: "https://jinyingzy.com/api.php/provide/vod/" },
{ name: "无精资源", searchUrl: "https://wjzyapi.com/api.php/provide/vod/" },
{ name: "五斤资源", searchUrl: "https://api.wujinapi.me/api.php/provide/vod/from/wjm3u8/" },
];
// 站源启用状态管理
const SOURCE_ENABLED_PREFIX = "source_enabled_";
const getSourceEnabled = (sourceName) => {
const val = GM_getValue(SOURCE_ENABLED_PREFIX + sourceName, undefined);
return val === undefined ? true : val;
};
const setSourceEnabled = (sourceName, enabled) => {
GM_setValue(SOURCE_ENABLED_PREFIX + sourceName, enabled);
};
const getEnabledSources = () => {
return ALL_SOURCES.filter(source => getSourceEnabled(source.name));
};
// ==================== 工具函数 ====================
const { query: $, isMobile } = Artplayer.utils;
const tip = (message, duration = 3000) => {
try {
const oldTip = document.getElementById("custom-tip");
if (oldTip) oldTip.remove();
const tipEl = document.createElement("div");
tipEl.id = "custom-tip";
tipEl.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(20, 20, 20, 0.9);
backdrop-filter: blur(4px);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
z-index: 9999999;
font-size: 14px;
line-height: 1.5;
text-align: center;
max-width: 80vw;
pointer-events: none;
`;
tipEl.innerText = message;
document.body.appendChild(tipEl);
setTimeout(() => tipEl.remove(), duration);
} catch (e) {
alert(message);
}
};
const escapeHtml = (str) => {
if (!str) return '';
return str.replace(/[&<>"']/g, (match) => {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return map[match];
});
};
const htmlToElement = (html) => {
try {
const template = document.createElement("template");
template.innerHTML = html.trim();
return template.content.firstChild;
} catch (e) {
tip("界面生成失败,请刷新页面重试");
return null;
}
};
const getCachedSearch = (movieId, sourceName) => {
if (!isSearchCacheEnabled()) return null;
const cacheKey = `search_${movieId}_${sourceName}`;
const cached = GM_getValue(cacheKey, null);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
return null;
};
const setCachedSearch = (movieId, sourceName, data) => {
if (!isSearchCacheEnabled()) return;
const cacheKey = `search_${movieId}_${sourceName}`;
GM_setValue(cacheKey, { data, timestamp: Date.now() });
cleanSearchCache();
};
const cleanOldPlayHistory = () => {
try {
const allKeys = GM_listValues();
const now = Date.now();
const expireTime = HISTORY_EXPIRE_DAYS * 24 * 60 * 60 * 1000;
allKeys.forEach(key => {
if (key.startsWith('douban_movie_')) {
const data = GM_getValue(key, {});
if (data.updateTime && now - data.updateTime > expireTime) {
GM_deleteValue(key);
}
}
});
} catch (e) {}
};
const get = (detail, retryCount = 0, timeoutMs = 10000, signal = null) => {
const maxRetry = 2;
const backoff = (retry) => Math.pow(2, retry) * 1000;
return new Promise((resolve) => {
if (signal && signal.aborted) {
resolve({ r: false, errorMsg: "请求已取消" });
return;
}
const timer = setTimeout(() => resolve({ r: false, errorMsg: "请求超时" }), timeoutMs);
const abortHandler = () => {
clearTimeout(timer);
resolve({ r: false, errorMsg: "请求已取消" });
};
if (signal) signal.addEventListener('abort', abortHandler, { once: true });
const defaultConfig = {
method: "GET",
timeout: timeoutMs,
headers: {
"User-Agent": navigator.userAgent,
"Accept": "*/*",
"Cache-Control": "no-cache",
},
anonymous: true,
followRedirects: true,
onload: (r) => {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', abortHandler);
if (r.status >= 400) {
resolve({ r: false, errorMsg: `请求失败(${r.status})` });
return;
}
resolve({ r: true, content: r.response, status: r.status, errorMsg: "请求成功" });
},
onerror: () => {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', abortHandler);
if (retryCount < maxRetry) {
setTimeout(() => resolve(get(detail, retryCount + 1, timeoutMs, signal)), backoff(retryCount));
} else {
resolve({ r: false, errorMsg: "网络异常" });
}
},
onabort: () => {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', abortHandler);
resolve({ r: false, errorMsg: "请求中止" });
},
ontimeout: () => {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', abortHandler);
if (retryCount < maxRetry) {
setTimeout(() => resolve(get(detail, retryCount + 1, timeoutMs, signal)), backoff(retryCount));
} else {
resolve({ r: false, errorMsg: "请求超时" });
}
},
};
GM_xmlhttpRequest(Object.assign(defaultConfig, detail));
});
};
const fetchWithTimeout = (url, timeout = 15000) => {
return Promise.race([
get({ url: encodeURI(url), responseType: "json", overrideMimeType: "application/json" }, 0, timeout),
new Promise((_, reject) => setTimeout(() => reject(new Error("全局超时")), timeout))
]).catch(err => ({ r: false, errorMsg: err.message }));
};
let doubanMovieBaseData = null;
const getDoubanMovieDataOnce = () => {
if (doubanMovieBaseData) return doubanMovieBaseData;
try {
const movieData = {
videoName: "未知影片",
videoYear: "",
videoFirstActor: "",
movieId: "unknown_" + Date.now(),
};
const url = window.location.href;
const match = url.match(/(subject|movie)\/(\d+)/);
if (match) movieData.movieId = match[2];
movieData.videoName = isMobile ? $(".sub-title")?.innerText || document.title.slice(0, -5).trim() : document.title.slice(0, -5).trim();
const yearEl = $(isMobile ? ".sub-original-title" : ".year");
if (yearEl) movieData.videoYear = yearEl.innerText.trim().replace(/[()]/g, "");
const actorEl = $(isMobile ? ".bd" : ".actor");
if (actorEl) {
const actorList = actorEl.innerText.split(/[,,]/).map(item => item.trim());
movieData.videoFirstActor = actorList[0] || "";
}
doubanMovieBaseData = movieData;
return movieData;
} catch (e) {
tip("影片数据读取失败");
return null;
}
};
const getMovieId = () => {
const data = getDoubanMovieDataOnce();
return data ? data.movieId : "unknown_" + Date.now();
};
const movieId = getMovieId();
const STORAGE_KEY = `douban_movie_${movieId}`;
const savePlayHistory = (data) => {
try {
const oldData = GM_getValue(STORAGE_KEY, {});
const newData = { ...oldData, ...data, updateTime: Date.now() };
GM_setValue(STORAGE_KEY, newData);
updateUnifiedHistoryPlayData(movieId, newData);
} catch (e) {}
};
const getPlayHistory = () => {
try {
return GM_getValue(STORAGE_KEY, {});
} catch (e) {
return {};
}
};
// ==================== 统一历史管理 ====================
const getUnifiedHistory = () => {
try {
let history = GM_getValue(UNIFIED_HISTORY_KEY, []);
if (!Array.isArray(history)) history = [];
const now = Date.now();
const expireTime = HISTORY_EXPIRE_DAYS * 24 * 60 * 60 * 1000;
const filtered = history.filter(item => (now - item.timestamp) < expireTime);
if (filtered.length !== history.length) saveUnifiedHistory(filtered);
const trimmed = filtered.slice(0, MAX_HISTORY_ITEMS);
if (trimmed.length !== filtered.length) saveUnifiedHistory(trimmed);
return trimmed;
} catch (e) {
return [];
}
};
const saveUnifiedHistory = (history) => {
try {
GM_setValue(UNIFIED_HISTORY_KEY, history.slice(0, MAX_HISTORY_ITEMS));
} catch (e) {}
};
const addToUnifiedHistory = (movieId, movieName, subjectUrl, playData = {}) => {
if (!movieId || !movieName) return;
let history = getUnifiedHistory();
const existingIndex = history.findIndex(item => item.movieId === movieId);
const newEntry = { movieId, movieName, subjectUrl, timestamp: Date.now(), playHistory: playData };
if (existingIndex !== -1) {
history[existingIndex] = { ...history[existingIndex], ...newEntry, timestamp: Date.now() };
} else {
history.unshift(newEntry);
}
history.sort((a, b) => b.timestamp - a.timestamp);
saveUnifiedHistory(history);
};
const updateUnifiedHistoryPlayData = (movieId, playData) => {
let history = getUnifiedHistory();
const index = history.findIndex(item => item.movieId === movieId);
if (index !== -1) {
history[index].playHistory = { ...history[index].playHistory, ...playData, updateTime: Date.now() };
history[index].timestamp = Date.now();
saveUnifiedHistory(history);
}
};
const deleteUnifiedHistoryItem = (movieId) => {
let history = getUnifiedHistory();
const newHistory = history.filter(item => item.movieId != movieId);
if (newHistory.length === history.length) return false;
saveUnifiedHistory(newHistory);
const allKeys = GM_listValues();
let deleted = false;
allKeys.forEach(key => {
if (key.startsWith('douban_movie_') && key.includes(movieId)) {
GM_deleteValue(key);
deleted = true;
}
});
return deleted;
};
const clearAllUnifiedHistory = () => {
saveUnifiedHistory([]);
const allKeys = GM_listValues();
allKeys.forEach(key => {
if (key.startsWith('douban_movie_')) GM_deleteValue(key);
});
tip("所有观看记录已清除");
};
// 匹配辅助函数
const removeNoiseWords = (str) => {
if (!str) return '';
let result = str.replace(/[(【\[][^)\]】]*?(全集|高清|4K|8K|蓝光|修复版|国语|中字|BD|HD)[^)\]】]*?[)】\]]/gi, '').trim();
const noiseSuffix = /(全集|高清|4K|8K|蓝光|修复版|国语|中字|中文字幕|无水印|无删减|完整版|加长版|导演剪辑版|未删减|BD|HD|WEB-DL|WEBRip|BluRay|DVDRip)$/gi;
result = result.replace(noiseSuffix, '').trim();
return result;
};
const extractYear = (yearStr) => {
if (!yearStr) return null;
const match = yearStr.match(/\d{4}/);
return match ? match[0] : null;
};
const normalizeSeason = (str) => {
if (!str) return '';
let normalized = str.replace(/第(\d+)[部期]/g, '第$1季');
const chineseNumMap = { '一':'1','二':'2','三':'3','四':'4','五':'5','六':'6','七':'7','八':'8','九':'9','十':'10' };
normalized = normalized.replace(/第([一二三四五六七八九十]+)季/g, (match, cn) => {
const num = chineseNumMap[cn] || cn;
return `第${num}季`;
});
return normalized;
};
let pinyinLib = null;
try {
if (typeof pinyin !== 'undefined') pinyinLib = pinyin;
} catch(e) {}
const getPinyinInitials = (str) => {
if (!pinyinLib) return '';
try {
const result = pinyinLib(str, { style: 'firstLetter', heteronym: false });
return result.map(item => item[0].toLowerCase()).join('');
} catch(e) {
return '';
}
};
const smartMatch = (response, doubanData, targetName = null) => {
try {
if (!response || !response.list || response.list.length === 0) return null;
const target = targetName !== null ? targetName : doubanData.videoName;
const targetCleaned = removeNoiseWords(target);
const targetNormalized = normalizeSeason(targetCleaned);
const targetYear = extractYear(doubanData.videoYear);
const targetActor = doubanData.videoFirstActor;
const targetPinyin = getPinyinInitials(targetCleaned);
let candidates = [];
for (let item of response.list) {
const itemName = item.vod_name;
const itemCleaned = removeNoiseWords(itemName);
const itemNormalized = normalizeSeason(itemCleaned);
const itemYear = extractYear(item.vod_year);
let score = 0;
if (targetYear && itemYear === targetYear) score += 10;
if (itemName === target) score += 10;
else if (itemNormalized === targetNormalized) score += 8;
else if (itemNormalized.includes(targetNormalized) || targetNormalized.includes(itemNormalized)) score += 5;
if (targetActor && item.vod_actor && item.vod_actor.includes(targetActor)) score += 3;
if (targetPinyin && getPinyinInitials(itemCleaned).includes(targetPinyin)) score += 2;
if (score > 0) candidates.push({ item, score });
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.score - a.score);
return candidates[0].item;
} catch (e) {
return null;
}
};
const matchResponseWithYear = (response, doubanData, strictYear = false, matchName = null) => {
const matchedVideo = smartMatch(response, doubanData, matchName);
if (!matchedVideo) return { success: false, errorMsg: "无匹配影片" };
if (!matchedVideo.vod_play_url || typeof matchedVideo.vod_play_url !== "string") {
return { success: false, errorMsg: "无播放地址" };
}
let playList = matchedVideo.vod_play_url.split("$$$").filter((str) => str.includes("m3u8"));
if (playList.length === 0) return { success: false, errorMsg: "无m3u8资源" };
playList = playList[0].split("#").map((str) => {
const index = str.indexOf("$");
return { name: str.slice(0, index) || "未知集数", url: str.slice(index + 1) || "", speed: -1 };
}).filter(item => item.url);
if (playList.length === 0) return { success: false, errorMsg: "无有效剧集" };
return { success: true, playList, vod_name: matchedVideo.vod_name, errorMsg: "匹配成功" };
};
const playM3u8 = (video, url, art) => {
try {
if (Hls.isSupported()) {
if (art.hls) art.hls.destroy();
const hls = new Hls({
maxBufferLength: 60,
maxMaxBufferLength: 120,
startLevel: -1,
enableWorker: true,
backBufferLength: 30,
});
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on("destroy", () => hls.destroy());
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
} else {
art.notice.show = "不支持的播放格式: m3u8";
}
} catch (e) {
tip("视频播放初始化失败,请重试");
}
};
const downloadtsList = async (url, signal = null) => {
if (!url) return { r: false };
try {
const baseUrl = new URL(url);
const result = await get({ url: encodeURI(url) }, 0, 10000, signal);
if (!result.r) return { r: false };
const downloadContent = result.content.trim().replace(/^\uFEFF/, "");
if (!downloadContent.includes("#EXTM3U")) return { r: false };
if (downloadContent.includes("#EXT-X-STREAM-INF")) {
const streamLines = downloadContent.split("\n");
const streamList = [];
let lastBandwidth = 0;
for (let i = 0; i < streamLines.length; i++) {
const line = streamLines[i].trim();
if (line.startsWith("#EXT-X-STREAM-INF")) {
const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/);
lastBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1]) : 0;
} else if (line && !line.startsWith("#") && line.includes(".m3u8")) {
const streamUrl = new URL(line, baseUrl).href;
streamList.push({ url: streamUrl, bandwidth: lastBandwidth });
}
}
if (streamList.length > 0) {
streamList.sort((a, b) => b.bandwidth - a.bandwidth);
return await downloadtsList(streamList[0].url, signal);
}
}
const tsList = [];
if (downloadContent.includes(".ts")) {
const lines = downloadContent.split("\n");
for (const item of lines) {
const trimItem = item.trim();
if (/^[#\s]/.test(trimItem) || trimItem === "") continue;
const tsUrl = new URL(trimItem, baseUrl).href;
tsList.push(tsUrl);
}
return { r: true, content: tsList };
}
return { r: false };
} catch (e) {
return { r: false };
}
};
const showLoadingTip = (text, successCount = 0, totalCount = 0, failedCount = 0) => {
try {
const oldTip = $("#loading-tip");
if (oldTip) oldTip.remove();
let rateHtml = "";
if (totalCount > 0) {
rateHtml = `
成功:${successCount} / 失败:${failedCount}`;
}
const tipEl = htmlToElement(`
${text}${rateHtml}
`);
document.body.appendChild(tipEl);
return tipEl;
} catch (e) {
return null;
}
};
const hideLoadingTip = () => {
try {
const tipEl = $("#loading-tip");
if (tipEl) tipEl.remove();
} catch (e) {}
};
const resetPlayBtn = () => {
if (window.doubanPlayBtn) {
window.doubanPlayBtn.innerText = "一键播放";
window.doubanPlayBtn.style.backgroundColor = "";
window.doubanPlayBtn.disabled = false;
window.doubanPlayBtn.style.cursor = "pointer";
}
};
const asyncPool = async (limit, array, iteratorFn) => {
const ret = [];
const executing = [];
for (const item of array) {
const p = Promise.resolve().then(() => iteratorFn(item, array));
ret.push(p);
if (limit <= array.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
const jitter = Math.floor(Math.random() * (ANTI_BLOCK_CONFIG.requestJitterMax - ANTI_BLOCK_CONFIG.requestJitterMin + 1)) + ANTI_BLOCK_CONFIG.requestJitterMin;
await new Promise(resolve => setTimeout(resolve, jitter));
}
}
}
return Promise.all(ret);
};
// ==================== Vue 模板 ====================
const vueAppTemplate = `
请不要相信视频中的广告!
暂无观看记录
{{ item.movieName }}
{{ formatHistoryTime(item.timestamp) }}
`;
// ==================== 样式 ====================
GM_addStyle(`
:root{ font-size:16px; font-family: system-ui, -apple-system, BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",sans-serif !important; }
:root::-webkit-scrollbar, .liu-playContainer::-webkit-scrollbar, .series-contianer::-webkit-scrollbar { display: none; }
.TalionNav{ z-index:10; }
.speed-slow{ color:#9e9e9e; }
.speed-fast{ color:#4aa150; }
.speed-testing{ color:#ff4d4d !important; font-weight:500; }
.failed-status{ color:#F76965 !important; font-weight: 500; }
.mannul{ margin:16px 0px 16px 14px; font-size:16px; display:flex; flex-wrap:wrap; }
.authoralert{ font-size:16px; margin-left:14px; color:#F76965; }
.liu-btn{ cursor:pointer; font-size:1.1rem; padding: 0.8rem 1.2rem; border: 1px solid transparent; border-radius: 6px; max-height:60px; }
.play-btn { border-radius: 8px; cursor: pointer; font-weight: bolder; background-color:#e8f5e9; }
.play-btn:hover { background-color:#c8e6c9; }
.play-btn:active{ background-color: #81c784; }
.play-btn:disabled { background-color:#9e9e9e; cursor:not-allowed; }
.liu-closePlayer{
position: absolute;
top: 10px;
right: 10px;
z-index: 99999;
border-radius: 50%;
background-color: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
color: #ffffff;
width: 36px;
height: 36px;
line-height: 36px;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
transition: all 0.2s ease;
}
.liu-closePlayer:hover{
background-color: rgba(0,112,17,0.9);
transform: scale(1.05);
}
.source-selector{
width: 130px;
height: 56px;
padding: 6px 8px;
margin: 0 10px 10px 0;
border-radius: 6px;
flex-shrink: 0;
background-color: #141414;
color: #99a2aa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
line-height: 1.2;
box-sizing: border-box;
border: 2px solid transparent;
}
.source-selector:hover:not(:disabled) {
background-color: #1f1f1f;
}
.source-selector.selected {
border-color: #007011;
background-color: #153a1d;
}
.source-name {
font-size: 14px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-status {
font-size: 11px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.source-status.speed-fast {
color: #4aa150;
}
.source-status.speed-slow {
color: #9e9e9e;
}
.source-status.speed-testing {
color: #ff4d4d !important;
}
.pc-source-list {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
padding: 5px 0;
margin: 0;
scroll-behavior: smooth;
width: 100%;
box-sizing: border-box;
}
.pc-source-list::-webkit-scrollbar {
height: 4px;
}
.pc-source-list::-webkit-scrollbar-thumb {
background-color: #007011;
border-radius: 4px;
}
.mobile-only .sourceButtonList {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-column-gap: 10px;
grid-row-gap: 10px;
padding: 0 1rem;
box-sizing: border-box;
}
.mobile-only .sourceButtonList .source-selector {
width: 100%;
margin: 0;
}
.series-selector{ background-color: #141414; border-radius:6px; color: #99a2aa; font-size:15px; padding: 10px 8px; box-sizing: border-box; }
.series-selector:hover{ background-color: #153a1d; box-sizing: border-box; }
.playing{ border:2px solid #007011; box-sizing: border-box; }
.love-support{ color:#99a2aa; background-color:transparent; margin-right:32px; }
a:visited{ color:#99a2aa; }
a:hover{ font-weight:bold; color:#A8DB39; background:none; }
.series-contianer{
display:grid;
grid-template-columns: repeat(4,1fr);
grid-auto-rows:50px;
grid-column-gap:12px;
grid-row-gap:12px;
margin-top:16px;
height:400px;
overflow-y:auto;
scroll-behavior: smooth;
position: relative;
box-sizing: border-box;
}
.series-wrapper {
position: relative;
}
.series-wrapper.has-more::after {
content: "▼";
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
color: #99a2aa;
font-size: 14px;
z-index: 2;
animation: bounce 2s ease-in-out 8;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.series-wrapper.has-more::before {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #141414);
z-index: 1;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.series-wrapper.has-more::after,
.series-wrapper.has-more::before {
opacity: 1;
}
@keyframes bounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(4px); }
}
@keyframes scrollTip {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.liu-playContainer{
width: 100vw !important;
width: 100dvw !important;
height: 100vh !important;
height: 100dvh !important;
background-color:#1c2022;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 999999 !important;
overflow: auto;
padding-bottom: 60px;
box-sizing: border-box;
padding-top: 40px;
margin: 0 !important;
}
.video-selector{ display:flex; flex-wrap:wrap; margin-top:1rem; }
.liu-selector:hover{ color:#aed0ee; background-color:none; }
.liu-selector{ color:black; cursor:pointer; padding:3px; margin:5px; border-radius:2px; }
.liu-rapidPlay{ color: #007722; }
.liu-light{ background-color:#7bed9f; }
.seletor-title{
height:50px;
line-height:50px;
background-color: #141414;
color:#fafafa;
font-size:1.1rem;
padding: 0 1rem;
border-radius:6px 6px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.title-text {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-right-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.history-btn {
background: rgba(74, 161, 80, 0.15) !important;
border: 1.5px solid #4aa150 !important;
color: #4aa150 !important;
padding: 4px 14px !important;
font-size: 1.1rem !important;
line-height: 1.2 !important;
border-radius: 24px !important;
transition: all 0.2s ease;
max-height: 38px;
display: inline-flex;
align-items: center;
font-weight: 500;
}
.history-btn:hover {
background: #4aa150 !important;
color: #fff !important;
transform: scale(1.02);
}
.speed-test-btn {
background-color: #007011;
color: white;
margin: 0;
width: 100%;
max-width: 200px;
flex-shrink: 0;
}
.speed-test-btn:disabled {
background-color: #9e9e9e;
cursor: not-allowed;
}
.speed-test-btn:hover:not(:disabled) {
background-color: #00981a;
}
.control-btn-group {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 0.8rem;
}
.mobile-btn-group {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 0 1rem;
}
.pc-only {
display: none;
}
.mobile-only {
display: block;
}
.search-container {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #1c2022;
padding: 1rem;
box-sizing: border-box;
box-shadow: 0 -2px 10px rgba(0,0,0,0.5);
display: flex;
gap: 0.5rem;
z-index: 999999;
}
.search-input {
flex: 1;
padding: 0.8rem 1rem;
border-radius: 6px;
border: 1px solid #333;
background-color: #141414;
color: #fff;
font-size: 1rem;
outline: none;
}
.search-input::placeholder {
color: #99a2aa;
}
.search-input:focus {
border-color: #007011;
}
.search-btn {
background-color: #007011;
color: white;
white-space: nowrap;
flex-shrink: 0;
}
.search-btn:hover:not(:disabled) {
background-color: #00981a;
}
.search-btn:disabled {
background-color: #9e9e9e;
cursor: not-allowed;
}
.loading-tip {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #141414;
color: #fff;
padding: 1rem 2rem;
border-radius: 8px;
z-index: 1000000;
font-size: 1rem;
text-align: center;
line-height: 1.5;
max-width: 80vw;
}
.loading-tip .rate-success { color: #4aa150; font-weight: bold; }
.loading-tip .rate-failed { color: #F76965; font-weight: bold; }
.history-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 10000000;
display: flex;
align-items: center;
justify-content: center;
}
.history-modal-content {
background: #1c2022;
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 20px rgba(0,0,0,0.5);
border: 1px solid #333;
}
.history-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
font-size: 1.2rem;
font-weight: 500;
color: #fafafa;
}
.history-modal-close {
background: none;
border: none;
color: #99a2aa;
font-size: 28px;
cursor: pointer;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.history-modal-close:hover {
background: #2a2a2a;
color: #fff;
}
.history-modal-body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
@media (min-width: 1025px) {
.history-modal-body {
scrollbar-width: thin;
scrollbar-color: #4aa150 #2a2a2a;
}
.history-modal-body::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.history-modal-body::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 4px;
}
.history-modal-body::-webkit-scrollbar-thumb {
background: #4aa150;
border-radius: 4px;
}
.history-modal-body::-webkit-scrollbar-thumb:hover {
background: #6ccf6a;
}
}
@media (max-width: 1024px) {
.history-modal-body {
scrollbar-width: none;
-ms-overflow-style: none;
}
.history-modal-body::-webkit-scrollbar {
display: none;
}
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #2a2a2a;
transition: background 0.2s;
}
.history-item:hover {
background: #2a2a2a;
}
.history-item-info {
flex: 1;
cursor: pointer;
overflow: hidden;
}
.history-item-name {
font-size: 1rem;
color: #fafafa;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item-time {
font-size: 0.75rem;
color: #99a2aa;
}
.history-item-delete {
background: none;
border: none;
color: #99a2aa;
font-size: 1.2rem;
cursor: pointer;
padding: 6px 8px;
border-radius: 4px;
transition: all 0.2s;
opacity: 0.6;
}
.history-item-delete:hover {
background: #3a2a2a;
color: #f76965;
opacity: 1;
}
.history-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-top: 1px solid #333;
font-size: 0.75rem;
color: #99a2aa;
}
.history-tip {
font-size: 0.7rem;
}
.history-clear-all {
background: transparent;
border: 1px solid #f76965;
color: #f76965;
border-radius: 16px;
padding: 4px 12px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.history-clear-all:hover {
background: #f76965;
color: #fff;
}
.history-empty {
text-align: center;
padding: 40px 20px;
color: #99a2aa;
}
/* 全局按钮容器 - 四个按钮水平排列 */
#global-buttons-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 12px;
z-index: 10000000 !important;
}
.global-history-btn {
width: 56px;
height: 56px;
background-color: #007011;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
transition: all 0.2s ease;
border: none;
}
.global-history-btn:hover {
background-color: #00981a;
transform: scale(1.05);
}
/* 当前页面匹配的按钮高亮 */
.global-btn-active {
background-color: #ffac2c !important;
color: #1a1a1a !important;
border: 2px solid #ff8c00 !important;
box-shadow: 0 0 8px rgba(255, 172, 44, 0.6);
}
@media (max-width: 768px) {
#global-buttons-container {
bottom: 16px;
right: 16px;
gap: 8px;
}
.global-history-btn {
width: 48px;
height: 48px;
font-size: 20px;
}
}
/* 搜索浮窗样式 */
.search-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(6px);
z-index: 10000001;
display: flex;
align-items: center;
justify-content: center;
}
.search-modal-container {
background: #1c2022;
border-radius: 16px;
width: 90%;
max-width: 500px;
padding: 1.5rem;
box-shadow: 0 8px 30px rgba(0,0,0,0.5);
border: 1px solid #333;
display: flex;
gap: 0.8rem;
}
.search-modal-container .search-input {
flex: 1;
background: #141414;
border: 1px solid #333;
color: #fff;
font-size: 1rem;
padding: 0.8rem 1rem;
border-radius: 8px;
outline: none;
}
.search-modal-container .search-input:focus {
border-color: #007011;
}
.search-modal-container .search-btn {
background-color: #007011;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.search-modal-container .search-btn:hover {
background-color: #00981a;
}
@media (max-width: 640px) {
.search-modal-container {
flex-direction: column;
gap: 0.8rem;
}
.search-modal-container .search-btn {
width: 100%;
}
}
@media screen and (min-width: 1025px) {
.pc-only {
display: block;
}
.mobile-only {
display: none;
}
.pc-only .speed-test-btn, .pc-only .control-btn-group {
display: none !important;
}
.search-container {
display: none !important;
}
.liu-closePlayer{
position: fixed;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
line-height: 32px;
background-color: #141414;
display: flex;
}
.liu-closePlayer:hover{
background-color:#1f1f1f;
color:white;
transform: scale(1.1);
}
.playSpace {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr;
grid-column-gap: 1rem;
margin: 0.5rem 1rem 1rem;
height: auto;
gap: 1rem;
}
.player-panel .artplayer-app {
width: 100%;
height: 600px;
border-radius: 6px;
overflow: hidden;
}
.series {
height: 600px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.seletor-title {
border-radius: 6px 6px 0 0;
margin: 0;
flex-shrink: 0;
height: 60px;
line-height: 60px;
font-size: 1.25rem;
}
.history-btn {
font-size: 1.25rem !important;
}
.series .series-contianer {
flex: 1;
height: calc(600px - 60px);
margin-top: 0;
margin-bottom: 0;
padding: 1rem;
padding-bottom: 0;
border-radius: 0 0 6px 6px;
background-color: #141414;
grid-template-columns: repeat(4,1fr);
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
}
.control-panel {
margin: 0.5rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
}
@media screen and (max-width: 1024px) {
.liu-closePlayer {
display: none !important;
}
.playSpace{
grid-template-rows: auto 1fr;
grid-template-columns:1fr;
grid-row-gap:10px;
grid-column-gap:0px;
margin: 0 1rem;
}
.series-contianer{
grid-template-columns: repeat(3,1fr);
height: 160px;
padding: 0 1rem;
background-color: #141414;
border-radius: 0 0 6px 6px;
margin-top: 0;
}
.speed-test-btn {
max-width: 100%;
margin: 1rem auto !important;
display: block;
}
.series-tip-active .series-contianer {
animation: scrollTip 1.5s ease-in-out 1;
}
.artplayer-app{
height: calc(42vh);
min-height: 240px;
border-radius: 6px;
overflow: hidden;
}
.liu-playContainer {
padding-top: 15px;
padding-bottom: 90px;
}
.series {
margin-top: 0.5rem;
}
.title-right-actions {
gap: 8px;
}
}
@media screen and (max-width: 768px) {
.series-contianer{
grid-template-columns: repeat(3,1fr);
height: 160px;
}
.artplayer-app{
height: 32vh;
min-height: 200px;
}
.mobile-only .sourceButtonList {
grid-template-columns: repeat(2, 1fr);
}
.mobile-btn-group {
flex-direction: column;
gap: 0;
}
}
@media screen and (max-width: 480px) {
.series-contianer{
grid-template-columns: repeat(3,1fr);
height: 150px;
}
.liu-btn{ font-size:1rem; padding:0.6rem 0.8rem; }
.seletor-title{
font-size:1rem;
height:45px;
line-height:45px;
}
.search-container { padding: 0.8rem; }
.liu-playContainer {
padding-bottom: 90px;
padding-top: 10px;
}
}
@media screen and (max-width: 375px) {
.series-contianer{
grid-template-columns: repeat(2,1fr);
height: 140px;
grid-column-gap: 8px;
grid-row-gap: 8px;
}
.series-selector{ font-size:14px; padding: 8px 4px; }
.artplayer-app{ height: 28vh; min-height: 180px; }
.mobile-only .sourceButtonList { grid-template-columns: repeat(1, 1fr); }
}
`);
// ==================== 全局历史浮窗 ====================
function showGlobalHistoryModal() {
const existingModal = document.getElementById('global-history-modal');
if (existingModal) {
existingModal.remove();
return;
}
const historyList = getUnifiedHistory();
const modalHtml = `
${historyList.length === 0 ? '
暂无观看记录
' :
historyList.map(item => `
${escapeHtml(item.movieName)}
${formatHistoryTime(item.timestamp)}
`).join('')}
`;
const modalElement = htmlToElement(modalHtml);
if (!modalElement) return;
document.body.appendChild(modalElement);
const closeBtn = document.getElementById('global-history-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => modalElement.remove());
}
modalElement.addEventListener('click', (e) => {
if (e.target === modalElement) modalElement.remove();
});
document.querySelectorAll('.history-item-delete').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const movieId = btn.getAttribute('data-delete-id');
if (movieId && deleteUnifiedHistoryItem(movieId)) {
tip("已删除该记录及关联播放进度");
modalElement.remove();
showGlobalHistoryModal();
} else {
tip("删除失败");
}
});
});
const clearAllBtn = document.getElementById('global-history-clear-all');
if (clearAllBtn) {
clearAllBtn.addEventListener('click', () => {
if (confirm("确定要清空所有最近观看记录吗?这将同时清除所有影片的播放进度。")) {
clearAllUnifiedHistory();
modalElement.remove();
tip("已清空所有记录");
}
});
}
document.querySelectorAll('.history-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.classList.contains('history-item-delete')) return;
const subjectUrl = item.getAttribute('data-subject-url');
if (subjectUrl) window.location.href = subjectUrl;
modalElement.remove();
});
});
}
function formatHistoryTime(timestamp) {
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
const date = new Date(timestamp);
return `${date.getMonth()+1}/${date.getDate()}`;
}
// 搜索浮窗
function showSearchModal() {
const existing = document.querySelector('.search-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'search-modal-overlay';
const container = document.createElement('div');
container.className = 'search-modal-container';
const input = document.createElement('input');
input.type = 'text';
input.className = 'search-input';
input.placeholder = '影视搜索 (例如: 肖申克的救赎)';
input.autofocus = true;
const button = document.createElement('button');
button.className = 'search-btn';
button.textContent = '搜索';
const performSearch = () => {
const keyword = input.value.trim();
if (!keyword) {
tip('请输入搜索关键词');
return;
}
const searchUrl = `https://www.bing.com/search?qs=n&sp=-1&q=site%3am.douban.com%2fmovie%2f+${encodeURIComponent(keyword)}`;
window.location.href = searchUrl;
overlay.remove();
};
button.addEventListener('click', performSearch);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
container.appendChild(input);
container.appendChild(button);
overlay.appendChild(container);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.body.appendChild(overlay);
}
// 注入四个全局按钮(增加“短”按钮,并实现高亮)
function injectGlobalButtons() {
const host = window.location.hostname;
const path = window.location.pathname;
const url = window.location.href;
const isDetailPage = /\/subject\/\d+/.test(url) ||
(host === 'm.douban.com' && path.startsWith('/movie/subject/')) ||
(host === 'douban.com' && path === '/doubanapp/dispatch' && url.includes('/subject/'));
const isListPage = (host === 'movie.douban.com' && ['/', '/tv', '/tv/', '/explore', '/explore/'].includes(path));
if (!isDetailPage && !isListPage) return;
if (document.getElementById('global-buttons-container')) return;
const container = document.createElement('div');
container.id = 'global-buttons-container';
// 判断当前页面是否匹配某个目标链接
const isMovieExplorePage = host === 'movie.douban.com' && (path === '/explore' || path.startsWith('/explore/'));
const isTvPage = host === 'movie.douban.com' && (path === '/tv' || path === '/tv/' || path.startsWith('/tv/'));
const historyBtn = document.createElement('button');
historyBtn.className = 'global-history-btn';
historyBtn.innerHTML = '📜';
historyBtn.title = '观看历史';
historyBtn.addEventListener('click', showGlobalHistoryModal);
const movieBtn = document.createElement('button');
movieBtn.className = 'global-history-btn';
if (isMovieExplorePage) movieBtn.classList.add('global-btn-active');
movieBtn.innerHTML = '影';
movieBtn.title = '电影探索';
movieBtn.addEventListener('click', () => {
window.location.href = 'https://movie.douban.com/explore';
});
const tvBtn = document.createElement('button');
tvBtn.className = 'global-history-btn';
if (isTvPage) tvBtn.classList.add('global-btn-active');
tvBtn.innerHTML = '剧';
tvBtn.title = '电视剧';
tvBtn.addEventListener('click', () => {
window.location.href = 'https://movie.douban.com/tv/#douban-desktop-adapt';
});
const duanjuBtn = document.createElement('button');
duanjuBtn.className = 'global-history-btn';
duanjuBtn.innerHTML = '短';
duanjuBtn.title = '短剧百科';
duanjuBtn.addEventListener('click', () => {
window.location.href = 'https://duanjubaike.cn/drama/list';
});
const searchBtn = document.createElement('button');
searchBtn.className = 'global-history-btn';
searchBtn.innerHTML = '🔍';
searchBtn.title = '搜索影视';
searchBtn.addEventListener('click', showSearchModal);
container.appendChild(historyBtn);
container.appendChild(movieBtn);
container.appendChild(tvBtn);
container.appendChild(duanjuBtn);
container.appendChild(searchBtn);
document.body.appendChild(container);
}
// ==================== 站源管理界面 ====================
function showSourceManager() {
const existing = document.getElementById('source-manager-modal');
if (existing) existing.remove();
const sources = ALL_SOURCES;
const modalHtml = `
`;
const modalElement = htmlToElement(modalHtml);
if (!modalElement) return;
document.body.appendChild(modalElement);
const closeBtn = document.getElementById('source-manager-close');
if (closeBtn) closeBtn.addEventListener('click', () => modalElement.remove());
modalElement.addEventListener('click', (e) => {
if (e.target === modalElement) modalElement.remove();
});
const selectAllBtn = document.getElementById('source-manager-select-all');
const deselectAllBtn = document.getElementById('source-manager-deselect-all');
const saveBtn = document.getElementById('source-manager-save');
const checkboxes = modalElement.querySelectorAll('input[type="checkbox"]');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = true);
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = false);
});
}
if (saveBtn) {
saveBtn.addEventListener('click', () => {
checkboxes.forEach(cb => {
const sourceName = cb.getAttribute('data-source-name');
if (sourceName) setSourceEnabled(sourceName, cb.checked);
});
tip('站源配置已保存,刷新页面后生效');
modalElement.remove();
});
}
}
// ==================== 主逻辑 initVue ====================
function initVue() {
const globalBtnContainer = document.getElementById('global-buttons-container');
if (globalBtnContainer) globalBtnContainer.style.display = 'none';
const doubanData = getDoubanMovieDataOnce();
if (!doubanData || !doubanData.videoName || doubanData.videoName === "未知影片") {
tip("影片信息读取失败,请刷新页面重试!");
resetPlayBtn();
return;
}
const oldApp = $("#app");
if (oldApp) oldApp.remove();
const e = htmlToElement(vueAppTemplate);
if (!e) {
tip("界面生成失败,请刷新页面重试");
resetPlayBtn();
return;
}
document.body.appendChild(e);
document.body.style.overflow = "hidden !important";
document.documentElement.style.overflow = "hidden !important";
new Vue({
el: "#app",
data: {
art: {},
ok: false,
vod_name: doubanData.videoName,
searchSource: getEnabledSources(),
sources: [],
selectedSource: 0,
playingList: [],
playingIndex: 0,
isTestingSpeed: false,
isTipActive: false,
isScrollable: false,
searchKeyword: '',
searchBaseUrl: 'https://www.bing.com/search?qs=n&sp=-1&q=site%3am.douban.com%2fmovie%2f+',
retryCount: 0,
maxRetry: 1,
loadSuccessCount: 0,
loadTotalCount: 0,
loadFailedCount: 0,
wheelHandler: null,
speedTestTotal: 0,
speedTestCompleted: 0,
autoSwitchCount: 0,
maxAutoSwitch: 5,
failedSourceIndexes: [],
playTimer: null,
showHistoryModal: false,
historyList: [],
historyRecorded: false,
maxHistoryItems: MAX_HISTORY_ITEMS,
historyExpireDays: HISTORY_EXPIRE_DAYS,
autoPlayNext: GM_getValue(AUTO_NEXT_ENABLED_KEY, true),
speedTestAbortController: null,
autoSpeedTestEnabled: false,
testingSourceSet: new Set(),
visibilityHandler: null,
},
computed: {
successSources() {
return this.sources
.map((source, idx) => ({ ...source, originalIndex: idx }))
.filter(s => s.status === 'success');
},
hasUntestedSources() {
return this.successSources.some(s => s.speed === -1);
}
},
watch: {
playingList() {
this.$nextTick(() => this.checkScrollable());
}
},
methods: {
isSourceTesting(index) {
return this.testingSourceSet.has(index);
},
checkScrollable() {
const container = this.$refs.seriesContainer;
if (!container) return;
container.removeEventListener('scroll', this.checkScrollable);
this.isScrollable = container.scrollHeight > container.clientHeight;
container.addEventListener('scroll', this.checkScrollable);
},
handleSearch() {
const keyword = this.searchKeyword.trim();
if (!keyword) return;
window.location.href = this.searchBaseUrl + encodeURIComponent(keyword);
},
async startSpeedTest(keepExistingResults = false) {
if (this.isTestingSpeed) return;
const validSources = this.sources
.map((source, index) => ({ ...source, index }))
.filter(s => s.status === 'success' && s.playList.length > 0);
if (validSources.length === 0) return;
if (!keepExistingResults) {
validSources.forEach(s => { this.sources[s.index].speed = -1; });
}
const toTest = validSources.filter(s => this.sources[s.index].speed === -1);
if (toTest.length === 0) {
this.autoSpeedTestEnabled = false;
return;
}
this.isTestingSpeed = true;
this.speedTestTotal = toTest.length;
this.speedTestCompleted = 0;
this.$forceUpdate();
this.speedTestAbortController = new AbortController();
const signal = this.speedTestAbortController.signal;
const groupSize = 3;
const sourceGroups = [];
for (let i = 0; i < toTest.length; i += groupSize) {
sourceGroups.push(toTest.slice(i, i + groupSize));
}
try {
for (const group of sourceGroups) {
if (signal.aborted) break;
await Promise.all(
group.map(async (source) => {
if (signal.aborted) return;
const index = source.index;
this.testingSourceSet.add(index);
this.$forceUpdate();
try {
const url = source.playList[this.playingIndex].url;
const result = await downloadtsList(url, signal);
if (signal.aborted) return;
if (!result.r || result.content.length === 0) {
this.sources[index].speed = 0.1;
} else {
let tsList = result.content;
tsList = tsList.length > 8 ? tsList.slice(0, 8) : tsList;
let totalSize = 0;
const startTime = Date.now();
await Promise.all(
tsList.map(async (tsUrl) => {
if (signal.aborted) return;
const res = await get({ url: encodeURI(tsUrl), responseType: "arraybuffer", timeout: 8000 }, 0, 8000, signal);
if (signal.aborted) return;
if (res.r) {
const size = res.content.byteLength ? res.content.byteLength / 1024 / 1024 : 0;
totalSize += size;
}
})
);
if (!signal.aborted) {
const duration = (Date.now() - startTime) / 1000;
let speed = duration > 0 ? totalSize / duration : 0.1;
speed = Math.max(Number(speed.toFixed(2)), 0.1);
this.sources[index].speed = speed;
}
}
} catch (e) {
if (!signal.aborted) this.sources[source.index].speed = 0.1;
} finally {
if (!signal.aborted) {
this.speedTestCompleted++;
this.testingSourceSet.delete(index);
this.$forceUpdate();
} else {
this.testingSourceSet.delete(index);
this.$forceUpdate();
}
}
})
);
}
} catch (e) {}
finally {
if (!signal.aborted) {
this.isTestingSpeed = false;
this.speedTestAbortController = null;
this.$forceUpdate();
if (this.hasUntestedSources === false) this.autoSpeedTestEnabled = false;
} else {
this.isTestingSpeed = false;
this.speedTestAbortController = null;
this.$forceUpdate();
}
}
},
stopSpeedTest() {
if (this.speedTestAbortController) {
this.speedTestAbortController.abort();
this.speedTestAbortController = null;
}
this.isTestingSpeed = false;
this.testingSourceSet.clear();
this.$forceUpdate();
},
async handleManualSpeedTest() {
if (this.isTestingSpeed) {
this.stopSpeedTest();
tip("测速已停止");
return;
}
if (this.successSources.length === 0) {
tip("无可用播放源,无法测速");
return;
}
if (isMobile && this.art && !this.art.paused) {
this.art.pause();
}
await this.startSpeedTest(true);
if (!this.isTestingSpeed && this.hasUntestedSources === false) {
this.art?.notice.show("所有源测速完成");
}
},
autoSpeedTest() {
if (!this.autoSpeedTestEnabled) return;
if (this.isTestingSpeed) return;
if (!this.hasUntestedSources) {
this.autoSpeedTestEnabled = false;
return;
}
this.startSpeedTest(true);
},
sourceSelect(index) {
if (this.sources[index].status === 'failed') return;
const ct = this.art.currentTime;
this.selectedSource = index;
this.playingList = this.sources[index].playList;
this.switchUrl(this.playingList[this.playingIndex].url);
this.art.once("video:canplay", () => { this.art.seek = ct; });
this.vod_name = this.sources[index].vod_name;
savePlayHistory({ selectedSource: index, selectedSourceName: this.sources[index].name });
this.retryCount = 0;
this.autoSwitchCount = 0;
},
playListSelect(index, autoPlay = false) {
this.playingIndex = index;
this.switchUrl(this.playingList[this.playingIndex].url);
savePlayHistory({ playingIndex: index, currentTime: 0 });
this.retryCount = 0;
if (autoPlay) {
setTimeout(() => {
if (this.art && this.art.play) {
this.art.play().catch(e => console.warn('自动播放失败', e));
}
}, 200);
}
},
saveCurrentPlayback() {
if (this.art && this.art.currentTime > 0) {
savePlayHistory({
currentTime: this.art.currentTime,
playbackRate: this.art.playbackRate,
volume: this.art.volume,
});
}
},
startPlayTimer() {
if (this.playTimer) clearInterval(this.playTimer);
this.playTimer = setInterval(() => {
this.saveCurrentPlayback();
}, 5000);
},
stopPlayTimerAndSave() {
if (this.playTimer) {
clearInterval(this.playTimer);
this.playTimer = null;
}
this.saveCurrentPlayback();
},
stopPlayTimer() {
if (this.playTimer) {
clearInterval(this.playTimer);
this.playTimer = null;
}
},
initArt(url) {
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
try {
const autoplay = false;
this.art = new Artplayer({
container: ".artplayer-app",
url: url,
autoplay: autoplay,
pip: true,
fullscreen: true,
fullscreenWeb: true,
autoMini: !isMobile,
screenshot: true,
hotkey: true,
airplay: true,
playbackRate: true,
setting: true,
miniProgressBar: !isMobile,
theme: "#00981a",
moreVideoAttr: { crossOrigin: "anonymous" },
controls: [{ name: "resolution", html: "分辨率", position: "right" }],
type: "m3u8",
customType: { m3u8: playM3u8 },
});
this.visibilityHandler = () => {
if (document.hidden) {
if (this.art && !this.art.paused) {
this.art.pause();
}
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
this.art.on("video:play", () => this.startPlayTimer());
this.art.on("video:pause", () => this.stopPlayTimerAndSave());
this.art.on("video:ended", () => this.stopPlayTimer());
this.art.on("error", () => this.handlePlayError());
this.art.on("video:stalled", () => {
setTimeout(() => {
if (this.art.video.readyState < 3) this.handlePlayError();
}, 5000);
});
this.art.on("video:canplay", () => { this.retryCount = 0; });
let seekRetryCount = 0;
const trySeek = (targetTime) => {
if (this.art.video.readyState >= 1 && this.art.video.duration && !isNaN(this.art.video.duration)) {
this.art.seek = targetTime;
seekRetryCount = 0;
} else if (seekRetryCount < 5) {
seekRetryCount++;
setTimeout(() => trySeek(targetTime), 500);
}
};
this.art.on("video:loadedmetadata", () => {
this.art.controls.resolution.innerText = this.art.video.videoHeight + "P";
const history = getPlayHistory();
if (history.currentTime && history.currentTime > 0) trySeek(history.currentTime);
if (history.playbackRate) this.art.playbackRate = history.playbackRate;
if (history.volume !== undefined) this.art.volume = history.volume;
});
this.art.on("video:ended", () => {
if (!this.autoPlayNext) return;
if (this.playingList.length > 1 && this.playingIndex + 1 < this.playingList.length) {
this.playListSelect(this.playingIndex + 1, true);
}
});
if (!isMobile) {
this.art.on("video:play", () => { if (this.isTestingSpeed) this.stopSpeedTest(); });
this.art.on("video:pause", () => { if (this.hasUntestedSources && !this.isTestingSpeed) this.autoSpeedTest(); });
this.art.on("video:ended", () => { if (this.hasUntestedSources && !this.isTestingSpeed) this.autoSpeedTest(); });
} else {
this.art.on("video:play", () => { if (this.isTestingSpeed) this.stopSpeedTest(); });
}
this.art.on("destroy", () => {
if (!document.hidden && this.art && !this.art.paused && this.art.currentTime > 0) {
savePlayHistory({ currentTime: this.art.currentTime });
}
this.stopPlayTimer();
this.stopSpeedTest();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
});
} catch (e) {
tip("播放器初始化失败,请刷新页面重试");
}
},
handlePlayError() {
if (this.retryCount < this.maxRetry) {
this.retryCount++;
this.art.notice.show = `播放失败,第${this.retryCount}次重试...`;
this.switchUrl(this.playingList[this.playingIndex].url);
} else {
this.retryCount = 0;
if (!this.failedSourceIndexes.includes(this.selectedSource)) this.failedSourceIndexes.push(this.selectedSource);
if (this.autoSwitchCount >= this.maxAutoSwitch) {
this.art.notice.show = "自动换源次数已达上限,请手动切换源";
tip("自动换源次数已达上限,请手动切换源");
return;
}
const validSources = this.sources
.map((s, idx) => ({ ...s, index: idx }))
.filter(s => s.status === 'success' && !this.failedSourceIndexes.includes(s.index));
if (validSources.length > 0) {
const nextSource = validSources[0];
this.autoSwitchCount++;
this.art.notice.show = `当前源播放失败,自动切换到${nextSource.name}`;
this.sourceSelect(nextSource.index);
} else {
this.art.notice.show = "所有源均播放失败,请刷新页面重试";
tip("所有源均播放失败,请刷新页面重试");
}
}
},
switchUrl(url) {
try {
this.art.switchUrl(url);
if (this.art.video.src != url) this.art.video.src = url;
} catch (e) {}
},
closePlayer() {
try {
hideLoadingTip();
this.stopSpeedTest();
this.stopPlayTimer();
if (this.art && this.art.destroy) this.art.destroy();
if (this.$destroy) this.$destroy();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
const container = this.$refs.seriesContainer;
if (container) container.removeEventListener('scroll', this.checkScrollable);
if (this.wheelHandler) {
const sourceList = this.$el && this.$el.querySelector('.pc-source-list');
if (sourceList) sourceList.removeEventListener('wheel', this.wheelHandler);
this.wheelHandler = null;
}
const app = $("#app");
if (app) app.remove();
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
const globalBtnContainer = document.getElementById('global-buttons-container');
if (globalBtnContainer) globalBtnContainer.style.display = 'flex';
resetPlayBtn();
} catch (e) {}
},
async searchSourceWithStrategies(sourceItem) {
const doubanData = getDoubanMovieDataOnce();
const movieId = doubanData.movieId;
const cached = getCachedSearch(movieId, sourceItem.name);
if (cached) {
if (cached.success) return { success: true, playList: cached.playList, vod_name: cached.vod_name, errorMsg: "缓存命中", usedKeyword: cached.usedKeyword };
else return { success: false, errorMsg: cached.errorMsg };
}
const { name: sourceName, searchUrl } = sourceItem;
const originalResult = await fetchWithTimeout(`${searchUrl}?ac=detail&wd=${doubanData.videoName}`);
if (originalResult.r && originalResult.content) {
const matchResult = matchResponseWithYear(originalResult.content, doubanData, false, doubanData.videoName);
if (matchResult.success) {
setCachedSearch(movieId, sourceName, { success: true, playList: matchResult.playList, vod_name: matchResult.vod_name, usedKeyword: doubanData.videoName });
return { success: true, playList: matchResult.playList, vod_name: matchResult.vod_name, errorMsg: matchResult.errorMsg, usedKeyword: doubanData.videoName };
}
}
const colonIndex = doubanData.videoName.search(/[::]/);
if (colonIndex !== -1) {
const mainTitle = doubanData.videoName.slice(0, colonIndex).trim();
const { main: mainTitleClean } = parseTitle(mainTitle);
const subTitle = doubanData.videoName.slice(colonIndex + 1).trim();
const { main: subTitleClean } = parseTitle(subTitle);
if (subTitleClean) {
const subResult = await fetchWithTimeout(`${searchUrl}?ac=detail&wd=${subTitleClean}`);
if (subResult.r && subResult.content) {
const matchSub = smartMatch(subResult.content, doubanData, subTitleClean);
if (matchSub && matchSub.vod_play_url) {
let playList = matchSub.vod_play_url.split("$$$").filter(str => str.includes("m3u8"));
if (playList.length) {
playList = playList[0].split("#").map(str => {
const idx = str.indexOf("$");
return { name: str.slice(0, idx) || "未知集数", url: str.slice(idx + 1) || "", speed: -1 };
}).filter(item => item.url);
if (playList.length) {
setCachedSearch(movieId, sourceName, { success: true, playList, vod_name: matchSub.vod_name, usedKeyword: subTitleClean });
return { success: true, playList, vod_name: matchSub.vod_name, errorMsg: "冒号后匹配成功", usedKeyword: subTitleClean };
}
}
}
}
}
if (mainTitleClean) {
const mainResult = await fetchWithTimeout(`${searchUrl}?ac=detail&wd=${mainTitleClean}`);
if (mainResult.r && mainResult.content) {
const matchMain = smartMatch(mainResult.content, doubanData, mainTitleClean);
if (matchMain && matchMain.vod_play_url) {
let playList = matchMain.vod_play_url.split("$$$").filter(str => str.includes("m3u8"));
if (playList.length) {
playList = playList[0].split("#").map(str => {
const idx = str.indexOf("$");
return { name: str.slice(0, idx) || "未知集数", url: str.slice(idx + 1) || "", speed: -1 };
}).filter(item => item.url);
if (playList.length) {
setCachedSearch(movieId, sourceName, { success: true, playList, vod_name: matchMain.vod_name, usedKeyword: mainTitleClean });
return { success: true, playList, vod_name: matchMain.vod_name, errorMsg: "冒号前匹配成功", usedKeyword: mainTitleClean };
}
}
}
}
}
}
const { main: targetMain } = parseTitle(doubanData.videoName);
if (targetMain && targetMain !== doubanData.videoName) {
const mainResult = await fetchWithTimeout(`${searchUrl}?ac=detail&wd=${targetMain}`);
if (mainResult.r && mainResult.content) {
const matchResult = matchResponseWithYear(mainResult.content, doubanData, false, targetMain);
if (matchResult.success) {
setCachedSearch(movieId, sourceName, { success: true, playList: matchResult.playList, vod_name: matchResult.vod_name, usedKeyword: targetMain });
return { success: true, playList: matchResult.playList, vod_name: matchResult.vod_name, errorMsg: matchResult.errorMsg, usedKeyword: targetMain };
}
}
}
setCachedSearch(movieId, sourceName, { success: false, errorMsg: "所有策略均未匹配到资源" });
return { success: false, errorMsg: "所有策略均未匹配到资源" };
},
openHistoryModal() { this.loadHistoryList(); this.showHistoryModal = true; },
closeHistoryModal() { this.showHistoryModal = false; },
loadHistoryList() { this.historyList = getUnifiedHistory().slice(0, MAX_HISTORY_ITEMS); },
formatHistoryTime(timestamp) {
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
const date = new Date(timestamp);
return `${date.getMonth()+1}/${date.getDate()}`;
},
jumpToHistory(item) {
if (item.subjectUrl) window.location.href = item.subjectUrl;
this.closeHistoryModal();
},
deleteHistoryItem(movieId) { if (deleteUnifiedHistoryItem(movieId)) { this.loadHistoryList(); tip("已删除该记录及关联播放进度"); } else tip("删除失败,请重试"); },
confirmClearAllHistory() { if (confirm("确定要清空所有最近观看记录吗?这将同时清除所有影片的播放进度。")) { clearAllUnifiedHistory(); this.loadHistoryList(); tip("已清空所有记录"); } },
recordCurrentToHistory() {
if (this.historyRecorded) return;
const doubanData = getDoubanMovieDataOnce();
if (!doubanData || !doubanData.movieId) return;
const subjectUrl = `https://m.douban.com/movie/subject/${doubanData.movieId}/`;
addToUnifiedHistory(doubanData.movieId, doubanData.videoName, subjectUrl, getPlayHistory());
this.historyRecorded = true;
}
},
async created() {
try {
const totalSource = this.searchSource.length;
this.loadTotalCount = totalSource;
let loadedCount = 0;
let tempSources = [];
showLoadingTip(`正在搜索... ${loadedCount}/${totalSource}`, this.loadSuccessCount, this.loadTotalCount, this.loadFailedCount);
await asyncPool(ANTI_BLOCK_CONFIG.maxConcurrent, this.searchSource, async (sourceItem) => {
const searchResult = await this.searchSourceWithStrategies(sourceItem);
loadedCount++;
if (searchResult.success) {
this.loadSuccessCount++;
tempSources.push({ name: sourceItem.name, playList: searchResult.playList, vod_name: searchResult.vod_name, speed: -1, status: 'success', errorMsg: searchResult.errorMsg });
} else {
this.loadFailedCount++;
tempSources.push({ name: sourceItem.name, playList: [], vod_name: doubanData.videoName, speed: -1, status: 'failed', errorMsg: searchResult.errorMsg || '无匹配影片' });
}
showLoadingTip(`正在搜索... ${loadedCount}/${totalSource}`, this.loadSuccessCount, this.loadTotalCount, this.loadFailedCount);
});
hideLoadingTip();
this.sources = tempSources;
const history = getPlayHistory();
let targetSourceIndex = -1;
if (history.selectedSourceName) {
const nameMatchIndex = this.sources.findIndex(s => s.name === history.selectedSourceName && s.status === 'success');
if (nameMatchIndex !== -1) targetSourceIndex = nameMatchIndex;
}
if (targetSourceIndex === -1 && history.selectedSource !== undefined && this.sources[history.selectedSource]?.status === 'success') targetSourceIndex = history.selectedSource;
if (targetSourceIndex === -1) targetSourceIndex = this.sources.findIndex(s => s.status === 'success');
if (targetSourceIndex !== -1) {
this.selectedSource = targetSourceIndex;
this.playingList = this.sources[targetSourceIndex].playList;
this.vod_name = this.sources[targetSourceIndex].vod_name;
let targetPlayingIndex = 0;
if (history.playingIndex !== undefined && history.playingIndex >= 0 && history.playingIndex < this.playingList.length) targetPlayingIndex = history.playingIndex;
this.playingIndex = targetPlayingIndex;
this.ok = true;
this.$nextTick(() => {
this.checkScrollable();
if (isMobile && this.isScrollable) {
const movieId = getMovieId();
const key = `tip_shown_${movieId}`;
const alreadyShown = GM_getValue(key, false);
if (!alreadyShown) {
this.isTipActive = true;
GM_setValue(key, true);
setTimeout(() => { this.isTipActive = false; }, 1500);
}
}
});
this.initArt(this.playingList[this.playingIndex].url);
if (!isMobile) {
this.autoSpeedTestEnabled = true;
setTimeout(() => { if (this.autoSpeedTestEnabled && this.hasUntestedSources) this.startSpeedTest(true); }, 500);
}
this.recordCurrentToHistory();
} else {
tip("未搜索到任何可用资源,可使用底部搜索框查找对应影片");
this.closePlayer();
}
} catch (e) {
hideLoadingTip();
tip("界面加载失败,请刷新页面重试");
this.closePlayer();
} finally {
resetPlayBtn();
}
},
mounted() {
if (!isMobile) {
this.$nextTick(() => {
const sourceList = this.$el.querySelector('.pc-source-list');
if (sourceList && !this.wheelHandler) {
this.wheelHandler = (e) => {
if (sourceList.scrollWidth > sourceList.clientWidth) {
e.preventDefault();
sourceList.scrollLeft += e.deltaY;
}
};
sourceList.addEventListener('wheel', this.wheelHandler, { passive: false });
}
});
}
},
beforeDestroy() {
if (this.wheelHandler) {
const sourceList = this.$el && this.$el.querySelector('.pc-source-list');
if (sourceList) sourceList.removeEventListener('wheel', this.wheelHandler);
this.wheelHandler = null;
}
this.stopPlayTimer();
this.stopSpeedTest();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
});
}
function parseTitle(name) {
if (!name) return { main: '', suffix: null };
let main = name;
let suffix = null;
const seasonMatch = name.match(/第(\d+)[季部]/);
if (seasonMatch) {
suffix = parseInt(seasonMatch[1], 10);
main = name.replace(/第\d+[季部]/, '').trim();
} else {
const numMatch = name.match(/\d+$/);
if (numMatch) {
suffix = parseInt(numMatch[0], 10);
main = name.replace(/\d+$/, '').trim();
}
}
return { main, suffix };
}
// ==================== 播放按钮与菜单 ====================
const isDetailPage = (() => {
const host = window.location.hostname;
const path = window.location.pathname;
if (host === 'movie.douban.com' && path.startsWith('/subject/')) return true;
if (host === 'm.douban.com' && path.startsWith('/movie/subject/')) return true;
if (window.location.href.match(/\/subject\/\d+/)) return true;
if (host === 'douban.com' && path === '/doubanapp/dispatch' && window.location.search.includes('/subject/')) return true;
return false;
})();
if (isDetailPage) {
try {
new (class PlayBtn {
constructor() {
const e = htmlToElement(``);
window.doubanPlayBtn = e;
const targetEl = $(isMobile ? ".sub-original-title" : "h1");
if (targetEl && e) {
targetEl.appendChild(e);
e.onclick = () => {
e.innerText = "搜索中...";
e.style.backgroundColor = "#9e9e9e";
e.disabled = true;
e.style.cursor = "not-allowed";
initVue();
};
} else {
setTimeout(() => {
const retryEl = $(isMobile ? ".sub-original-title" : "h1");
if (retryEl && e) {
retryEl.appendChild(e);
e.onclick = () => {
e.innerText = "搜索中...";
e.style.backgroundColor = "#9e9e9e";
e.disabled = true;
e.style.cursor = "not-allowed";
initVue();
};
} else {
document.body.appendChild(e);
e.style.position = "fixed";
e.style.right = "16px";
e.style.top = "16px";
e.style.zIndex = "9999";
e.onclick = () => {
e.innerText = "搜索中...";
e.style.backgroundColor = "#9e9e9e";
e.disabled = true;
e.style.cursor = "not-allowed";
initVue();
};
}
}, 1000);
}
}
})();
} catch (e) {
tip("播放按钮加载失败,请刷新页面重试");
}
}
// 注册菜单
GM_registerMenuCommand("清除观看历史", () => {
if (confirm("确定要清除所有观看历史吗?这将同时删除所有影片的播放进度和记录。")) {
clearAllUnifiedHistory();
tip("所有观看历史已清除");
}
});
GM_registerMenuCommand("站源管理", () => {
showSourceManager();
});
GM_registerMenuCommand("清除搜索缓存", () => {
if (confirm("清除所有搜索缓存后,下次播放将重新请求所有资源源。确定清除吗?")) {
const allKeys = GM_listValues();
let deletedCount = 0;
allKeys.forEach(key => { if (key.startsWith('search_')) { GM_deleteValue(key); deletedCount++; } });
tip(`已清除 ${deletedCount} 个搜索缓存,重新点击“一键播放”即可重新搜索全部源。`);
}
});
const searchCacheEnabled = isSearchCacheEnabled();
GM_registerMenuCommand(`${searchCacheEnabled ? '✅' : '❌'} 切换搜索缓存状态`, () => {
const current = isSearchCacheEnabled();
const newState = !current;
setSearchCacheEnabled(newState);
tip(`搜索缓存已${newState ? '开启' : '关闭'}。${newState ? '将使用缓存加速搜索' : '每次播放都将重新请求所有源'}`);
});
let autoPlayNextEnabled = GM_getValue(AUTO_NEXT_ENABLED_KEY, true);
GM_registerMenuCommand(`${autoPlayNextEnabled ? '✅' : '❌'} 自动连播`, () => {
autoPlayNextEnabled = !autoPlayNextEnabled;
GM_setValue(AUTO_NEXT_ENABLED_KEY, autoPlayNextEnabled);
tip(`自动连播已${autoPlayNextEnabled ? '开启' : '关闭'}(仅对多集剧集生效)`);
});
cleanOldPlayHistory();
injectGlobalButtons();