// ==UserScript== // @name B站合集列表管理器(主题开关版 V7.0) // @namespace http://tampermonkey.net/ // @version 7.8 // @description 侧边吸附按钮可拖拽半隐藏,可选择是否跟随B站深浅主题,可视化勾选,强制从头播放,自动定位当前播放。 // @author 你 // @match *://www.bilibili.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_notification // @run-at document-start // ==/UserScript== (function() { 'use strict'; // ========== 0. 全局配置与存储键 ========== const MEMORY_STORAGE_KEY = 'bili_force_memory_list'; const STORAGE_KEY = 'bili_force_video_data_v3'; const THEME_KEY = 'bili_list_theme'; const THEME_FOLLOW_KEY = 'bili_list_theme_follow'; const POS_KEY = 'bili_trigger_pos'; const BEAUTY_CONFIG_KEY = 'bili_list_beauty_config'; const BG_DB_NAME = 'BiliListManagerDB'; const BG_DB_VERSION = 1; const BG_STORE = 'bg_images'; const LOG_PREFIX = '[列表管理]'; window.__biliListManagerLoaded = true; // ========== 0.0 美化配置与背景图IndexedDB ========== function getBeautyConfig() { try { const saved = JSON.parse(localStorage.getItem(BEAUTY_CONFIG_KEY) || '{}'); return { frostedGlass: saved.frostedGlass !== false, glassBlur: saved.glassBlur || 12, drawerOpacity: saved.drawerOpacity !== undefined ? saved.drawerOpacity : 0.85, defaultBgOpacity: saved.defaultBgOpacity !== undefined ? saved.defaultBgOpacity : 0.15, bgSize: saved.bgSize || 'cover', bgPosition: saved.bgPosition || 'center' }; } catch(e) { return { frostedGlass: true, glassBlur: 12, drawerOpacity: 0.85, defaultBgOpacity: 0.15, bgSize: 'cover', bgPosition: 'center' }; } } // 暴露给2.js实时调用 window.__biliListGetBeautyConfig = getBeautyConfig; function openBgDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(BG_DB_NAME, BG_DB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(BG_STORE)) { db.createObjectStore(BG_STORE, { keyPath: 'id' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function getBgImage(id) { try { const db = await openBgDB(); return new Promise((resolve) => { const tx = db.transaction(BG_STORE, 'readonly'); const store = tx.objectStore(BG_STORE); const req = store.get(id); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); } catch(e) { return null; } } async function applyCurrentBg() { let drawer = document.getElementById('bili-force-drawer'); if (!drawer) { createDrawer(); drawer = document.getElementById('bili-force-drawer'); if (!drawer) return; } const cid = getCollectionIdFromPage(); let bgEntry = null; if (cid) bgEntry = await getBgImage(cid); const useDefault = !bgEntry; if (useDefault) bgEntry = await getBgImage('__default__'); const cfg = getBeautyConfig(); const isDark = getTheme() === 'dark'; const header = drawer.querySelector('.drawer-header'); const body = drawer.querySelector('.drawer-body'); const footer = drawer.querySelector('.drawer-footer'); // 整个面板磨砂 drawer.style.backdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; drawer.style.webkitBackdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; // 背景层/遮罩层放在drawer级(不随body列表滚动,图片稳定显示) let bgLayer = drawer.querySelector('.drawer-bg-layer'); let bgMask = drawer.querySelector('.drawer-bg-mask'); if (!bgLayer || !bgMask) { if (!bgLayer) { bgLayer = document.createElement('div'); bgLayer.className = 'drawer-bg-layer'; bgLayer.style.cssText = 'position:absolute;inset:0;background-repeat:no-repeat;pointer-events:none;z-index:0;transition:opacity 0.2s ease;'; drawer.insertBefore(bgLayer, drawer.firstChild); } if (!bgMask) { bgMask = document.createElement('div'); bgMask.className = 'drawer-bg-mask'; bgMask.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:1;'; drawer.insertBefore(bgMask, bgLayer.nextSibling); } } // header/footer:接近不透明的纯色背景盖住图片,只让body列表区露出图片 const headerFooterBg = isDark ? 'rgba(30,30,30,0.96)' : 'rgba(255,255,255,0.96)'; if (header) { header.style.zIndex = '3'; header.style.position = 'relative'; header.style.background = headerFooterBg; header.style.backdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; } if (footer) { footer.style.zIndex = '3'; footer.style.position = 'relative'; footer.style.background = headerFooterBg; footer.style.backdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; } // body列表区:透明露出背景图,保持可滚动 if (body) { body.style.zIndex = '2'; body.style.position = 'relative'; body.style.background = 'transparent'; body.style.overflow = 'auto'; } // 设置半透明面板遮罩(覆盖在背景图上) const maskColor = bgEntry && bgEntry.imageData ? (isDark ? `rgba(30,30,30,${cfg.drawerOpacity})` : `rgba(255,255,255,${cfg.drawerOpacity})`) : (isDark ? 'rgba(30,30,30,1)' : 'rgba(255,255,255,1)'); bgMask.style.background = maskColor; // 设置背景图(drawer级,不随列表滚动) if (bgEntry && bgEntry.imageData) { bgLayer.style.backgroundImage = `url("${bgEntry.imageData}")`; bgLayer.style.backgroundSize = (bgEntry.bgSize) || cfg.bgSize || 'cover'; bgLayer.style.backgroundPosition = (bgEntry.bgPosition) || cfg.bgPosition || 'center'; bgLayer.style.opacity = bgEntry.opacity !== undefined ? bgEntry.opacity : cfg.defaultBgOpacity; } else { bgLayer.style.backgroundImage = 'none'; bgLayer.style.opacity = '0'; } } // 挂载共享函数到unsafeWindow(页面共享窗口,跨油猴沙箱100%可访问) const _global = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; _global.__biliListRefreshBg = applyCurrentBg; _global.__biliListUpdateBeautyCfg = (partialCfg) => { try { const current = getBeautyConfig(); const merged = { ...current, ...partialCfg }; localStorage.setItem(BEAUTY_CONFIG_KEY, JSON.stringify(merged)); applyCurrentBg(); } catch(e) { console.error(e); } }; _global.__biliListSaveBgImage = async (id, imageData, opts = {}) => { try { const db = await openBgDB(); return new Promise((resolve) => { const tx = db.transaction(BG_STORE, 'readwrite'); const store = tx.objectStore(BG_STORE); const entry = { id, imageData, uploadTime: Date.now(), ...opts }; store.put(entry); tx.oncomplete = () => { applyCurrentBg(); resolve(true); }; tx.onerror = () => resolve(false); }); } catch(e) { return false; } }; async function deleteBgImageById(id) { try { const db = await openBgDB(); return new Promise((resolve) => { const tx = db.transaction(BG_STORE, 'readwrite'); const store = tx.objectStore(BG_STORE); store.delete(id); tx.oncomplete = () => { applyCurrentBg(); resolve(true); }; tx.onerror = () => resolve(false); }); } catch(e) { return false; } } _global.__biliListDeleteBgImage = deleteBgImageById; _global.__biliListGetCollectionId = getCollectionIdFromPage; _global.__biliListGetBeautyConfig = getBeautyConfig; _global.__biliListManagerLoaded = true; // 备用通信:监听信号元素属性变化(即使直接函数调用失败也能工作) function ensureSignalEl() { let signal = document.getElementById('__bili_list_beauty_signal'); if (!signal) { signal = document.createElement('div'); signal.id = '__bili_list_beauty_signal'; signal.style.display = 'none'; signal.setAttribute('data-version', Date.now().toString()); (document.body || document.documentElement).appendChild(signal); } return signal; } const startObserver = () => { const signal = ensureSignalEl(); const observer = new MutationObserver(() => { applyCurrentBg(); }); observer.observe(signal, { attributes: true, attributeFilter: ['data-version'] }); }; if (document.body) startObserver(); else document.addEventListener('DOMContentLoaded', startObserver); // 进度相关 Key 常量 const PROGRESS_KEYS = [ 'bilibili_player_progress', 'bpx_player_history', 'player_progress', 'video_progress', 'play_history', 'bpx_player_progress', 'bpx_player_profile', 'cumulative_play_time', 'bilibili_player_kv_config', 'bpcc_persisted', 'bpcfgzip_prod_36900', 'pcdnzip_prod_36900', 'time_tracker', 'recommend_auto_play' ]; const TARGET_DB_LIST = ['MIRROR_TRACK_V2', 'PLAYER_LOG', 'pbp3']; const TARGET_STORE_LIST = ['log', 'meta', 'pbpZebraCache']; // ========== 0.1 旧版本缓存清理 ========== function cleanupLegacyCache() { const LEGACY_KEYS = [ 'bili_force_video_data', // 无版本号原始版 'bili_force_video_data_v1', // V1版本 'bili_force_video_data_v2', // V2版本(结构不兼容v3) 'bili_force_video_list', // 早期单列表格式 'bili_force_video_lists' // 早期多列表格式 {"lists":[]} ]; let cleanedCount = 0; LEGACY_KEYS.forEach(key => { try { if (GM_getValue(key, null) !== null) { GM_deleteValue(key); cleanedCount++; } } catch(e) {} }); if (cleanedCount > 0) { console.log(LOG_PREFIX, `已清理 ${cleanedCount} 个旧版本缓存`); } } // ========== 1. 存储层永久劫持 ========== const origGetItem = Storage.prototype.getItem; const origSetItem = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (PROGRESS_KEYS.some(k => key.includes(k))) return; return origSetItem.call(this, key, value); }; Storage.prototype.getItem = function(key) { if (window.__biliForceStartEnabled && PROGRESS_KEYS.some(k => key.includes(k))) { return null; } return origGetItem.call(this, key); }; const origIDBOpen = window.indexedDB.open; window.indexedDB.open = function(dbName, version) { const req = origIDBOpen.call(this, dbName, version); if (TARGET_DB_LIST.includes(dbName)) { req.onsuccess = function() { try { const db = req.result; Array.from(db.objectStoreNames).forEach(storeName => { if (TARGET_STORE_LIST.includes(storeName)) { try { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).clear(); } catch(e) {} } }); } catch(e) {} }; } return req; }; // ========== 2. 工具函数 ========== function getMemoryList() { try { return JSON.parse(GM_getValue(MEMORY_STORAGE_KEY, '{}')); } catch(e) { return {}; } } function saveMemoryList(data) { GM_setValue(MEMORY_STORAGE_KEY, JSON.stringify(data)); } function getAllData() { try { return JSON.parse(GM_getValue(STORAGE_KEY, '{}')); } catch(e) { return {}; } } function saveAllData(data) { GM_setValue(STORAGE_KEY, JSON.stringify(data)); } function getLuminance(r, g, b) { return 0.299 * r + 0.587 * g + 0.114 * b; } function getBilibiliTheme() { // 优先级1: data-theme 属性 try { const dataTheme = document.documentElement.getAttribute('data-theme'); if (dataTheme === 'dark' || dataTheme === 'light') return dataTheme; } catch(e) {} // 优先级2: html 类名 try { const html = document.documentElement; if (html.classList.contains('dark')) return 'dark'; if (html.classList.contains('light')) return 'light'; } catch(e) {} // 优先级3: localStorage try { const stored = localStorage.getItem('bilibili_theme'); if (stored === 'dark' || stored === 'light') return stored; } catch(e) {} // 优先级4: 计算 body 背景色亮度 try { const bodyStyle = window.getComputedStyle(document.body); const bgColor = bodyStyle.backgroundColor; const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (match) { const r = parseInt(match[1]); const g = parseInt(match[2]); const b = parseInt(match[3]); const luminance = getLuminance(r, g, b); return luminance < 128 ? 'dark' : 'light'; } } catch(e) {} // 优先级5: 默认 dark return 'dark'; } function getThemeFollow() { return GM_getValue(THEME_FOLLOW_KEY, true); } function saveThemeFollow(follow) { GM_setValue(THEME_FOLLOW_KEY, follow); } function getTheme() { if (getThemeFollow()) { return getBilibiliTheme(); } const manual = GM_getValue(THEME_KEY, 'dark'); return manual === 'dark' || manual === 'light' ? manual : 'dark'; } function saveTheme(theme) { GM_setValue(THEME_KEY, theme); } function getTriggerPos() { try { const saved = JSON.parse(GM_getValue(POS_KEY, '{"side":"right","top":120}')); return { side: saved.side || 'right', top: saved.top || 120 }; } catch(e) { return { side: 'right', top: 120 }; } } function saveTriggerPos(pos) { GM_setValue(POS_KEY, JSON.stringify(pos)); } function getListName() { // 新版:选集页 header .title 只是"视频选集"字样,改用页面 JSON 的视频标题作列表名 if (isNewStyleVideoList()) { const vd = getInitialStateVideoData(); const t = vd && vd.title ? String(vd.title).trim() : ''; if (t) return t; } const titleEl = document.querySelector('.video-pod__header .title'); if (titleEl) return titleEl.textContent.trim(); const metaTitle = document.querySelector('meta[property="og:title"]'); if (metaTitle) { let t = metaTitle.getAttribute('content') || ''; t = t.replace(/^[^_]*_/, '').trim(); return t || '未知合集'; } return '未知合集'; } function syncForceStartState() { const listName = getListName(); const memory = getMemoryList(); for (const key in memory) { if (memory[key] && memory[key][0] === listName) { const entry = memory[key]; window.__biliForceStartEnabled = entry.length >= 3 ? entry[1] : false; return window.__biliForceStartEnabled; } } window.__biliForceStartEnabled = false; return false; } // ========== 3. 视频强制归零 ========== function resetVideo() { if (window.__isOpeningPanel) return; const videos = document.querySelectorAll('video'); videos.forEach(v => { const forceZero = () => { if (window.__biliForceStartEnabled && v.currentTime > 0.5) { v.currentTime = 0; } }; v.removeEventListener('loadedmetadata', forceZero); v.removeEventListener('canplay', forceZero); v.addEventListener('loadedmetadata', forceZero); v.addEventListener('canplay', forceZero); if (v.readyState >= 1 && window.__biliForceStartEnabled && v.currentTime > 0.5) { v.currentTime = 0; } }); } // ========== 4. URL 参数清理 + SPA 路由劫持 ========== const origPushState = history.pushState; let _isRedirecting = false; // 防止自己跳转时死循环 function isCurrentVideoInCheckedList() { if (!window.__biliForceStartEnabled) return true; const currentBv = getCurrentBVID(); if (!currentBv) return true; // 不在视频页面不处理 const currentP = getCurrentPIndex(); const list = getListForCurrentCollection(); if (list.length === 0) return true; // 没有保存列表不拦截 for (const id of list) { if (id.includes('::')) { const [bv, pStr] = id.split('::'); if (bv === currentBv && parseInt(pStr) === currentP) return true; } else { if (id === currentBv && currentP === 0) return true; } } return false; } function redirectToFirstCheckedIfNeeded(delay = 100) { if (_isRedirecting || window.__isOpeningPanel) return; if (!window.__biliForceStartEnabled) return; if (isCurrentVideoInCheckedList()) return; const list = getListForCurrentCollection(); if (list.length === 0) return; _isRedirecting = true; const firstId = list[0]; const firstBv = firstId.includes('::') ? firstId.split('::')[0] : firstId; const firstP = firstId.includes('::') ? parseInt(firstId.split('::')[1]) : null; setTimeout(() => { navigateToVideo(firstBv, firstP); resetVideo(); showToast('🚫 已拦截未勾选视频,回到列表第一首'); setTimeout(() => { _isRedirecting = false; }, 500); }, delay); } history.pushState = function(...args) { origPushState.apply(this, args); if (!window.__isOpeningPanel) { setTimeout(resetVideo, 200); setTimeout(resetVideo, 600); setTimeout(redirectToFirstCheckedIfNeeded, 100); setTimeout(redirectToFirstCheckedIfNeeded, 400); } }; window.addEventListener('popstate', () => { if (!window.__isOpeningPanel) { setTimeout(resetVideo, 200); setTimeout(resetVideo, 600); setTimeout(redirectToFirstCheckedIfNeeded, 100); setTimeout(redirectToFirstCheckedIfNeeded, 400); } }); // 定期检查,防止B站自动连播漏掉 setInterval(() => { if (!window.__isOpeningPanel && window.__biliForceStartEnabled) { redirectToFirstCheckedIfNeeded(0); } }, 800); // ========== 5. 列表解析与导航逻辑 ========== // ---- 新版分P列表(__INITIAL_STATE__)兼容 ---- function getInitialStateVideoData() { // 必须用 unsafeWindow 读页面主 world 的全局 JSON(沙箱里 window.__INITIAL_STATE__ 不存在) const g = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const s = g.__INITIAL_STATE__; return (s && s.videoData) || null; } // 判定是否走新版解析:.video-pod__list 首项 data-key 为纯数字(新版分P cid), // 且与页面 JSON 的 pages 同源(bvid 匹配地址栏,防 SPA 陈旧数据) function isNewStyleVideoList() { try { const list = document.querySelector('.video-pod__list'); const first = list && list.firstElementChild; if (!first) return false; const key = first.getAttribute('data-key') || ''; if (!/^\d+$/.test(key)) return false; // 旧版项 data-key=BV… 走旧解析 const vd = getInitialStateVideoData(); const m = location.pathname.match(/\/video\/(BV[0-9a-zA-Z]+)/); const cur = m ? m[1] : ''; return !!(vd && Array.isArray(vd.pages) && vd.pages.length > 0 && cur && vd.bvid === cur); } catch (e) { return false; } } // 秒 → mm:ss function formatSec(sec) { const n = parseInt(sec, 10); if (isNaN(n)) return ''; return Math.floor(n / 60) + ':' + String(n % 60).padStart(2, '0'); } // 新版:由 __INITIAL_STATE__.videoData 重建列表(同BV多分P → 单个 multi 条目,子集 id=BV::i) function buildListFromInitialState() { const vd = getInitialStateVideoData(); if (!vd || !Array.isArray(vd.pages) || !vd.pages.length) return []; const bv = vd.bvid; const title = (vd.title || '').trim() || '未知标题'; const videos = []; if (vd.pages.length === 1) { videos.push({ id: bv, bv: bv, title: title, duration: formatSec(vd.pages[0].duration), isMulti: false, subVideos: [] }); } else { const subVideos = vd.pages.map((p, i) => ({ id: bv + '::' + i, bv: bv, pIndex: i, title: (p.part || '').trim() || ('分P' + (i + 1)), duration: formatSec(p.duration), isSub: true })); videos.push({ id: bv, bv: bv, title: title, duration: formatSec(vd.duration), isMulti: true, subVideos: subVideos, expanded: false }); } return videos; } function getCollectionIdFromPage() { // 新版:整页为同一 BV 的分P列表 → 合集ID = list_ if (isNewStyleVideoList()) { const m = location.pathname.match(/\/video\/(BV[0-9a-zA-Z]+)/); if (m) return 'list_' + m[1]; } const container = document.querySelector('.video-pod__list'); if (container) { const items = container.querySelectorAll('[data-key]'); const bvs = new Set(); items.forEach(el => { const bv = el.getAttribute('data-key'); if (bv && bv.startsWith('BV')) bvs.add(bv); }); if (bvs.size > 0) { return 'list_' + Array.from(bvs).sort().join('|'); } } // 单个视频:从URL获取BV号 const bvMatch = location.href.match(/\/video\/(BV[0-9a-zA-Z]+)/); if (bvMatch) return 'single_' + bvMatch[1]; return null; } function parsePageVideoList() { // 新版:从页面 JSON(__INITIAL_STATE__)重建;旧版走下方原 DOM 逻辑 if (isNewStyleVideoList()) return buildListFromInitialState(); const container = document.querySelector('.video-pod__list'); if (!container) return []; const items = container.querySelectorAll('.pod-item'); const videos = []; items.forEach((item) => { const bv = item.getAttribute('data-key'); if (!bv || !bv.startsWith('BV')) return; const isMulti = item.classList.contains('multi-p'); const titleEl = item.querySelector('.simple-base-item.normal .title-txt, .simple-base-item.head .title-txt, .simple-base-item.active .title-txt'); const durationEl = item.querySelector('.single-p .duration, .multi-p .head .duration'); const title = titleEl ? titleEl.textContent.trim() : '未知标题'; const duration = durationEl ? durationEl.textContent.trim() : ''; if (isMulti) { const pageList = item.querySelector('.page-list'); const subItems = pageList ? pageList.querySelectorAll('.page-item.sub') : []; const subVideos = []; subItems.forEach((sub, subIdx) => { const subTitleEl = sub.querySelector('.title-txt'); const subDurationEl = sub.querySelector('.duration'); subVideos.push({ id: bv + '::' + subIdx, bv: bv, pIndex: subIdx, title: subTitleEl ? subTitleEl.textContent.trim() : '分P' + (subIdx + 1), duration: subDurationEl ? subDurationEl.textContent.trim() : '', isSub: true }); }); videos.push({ id: bv, bv: bv, title: title, duration: duration, isMulti: true, subVideos: subVideos, expanded: false }); } else { videos.push({ id: bv, bv: bv, title: title, duration: duration, isMulti: false, subVideos: [] }); } }); return videos; } function restoreFromMemoryList(listName, videoData) { const memory = getMemoryList(); let matchedKey = null; for (const key in memory) { if (memory[key] && memory[key][0] === listName) { matchedKey = key; break; } } if (!matchedKey) return { checkedSet: null, forceFromStart: false }; const entry = memory[matchedKey]; const bitmap = entry.length === 2 ? entry[1] : entry[2]; const force = entry.length >= 3 ? entry[1] : false; const checkedSet = new Set(); let idx = 0; for (const v of videoData) { if (v.isMulti && v.subVideos.length > 0) { for (const sv of v.subVideos) { if (idx < bitmap.length && bitmap[idx] === '1') checkedSet.add(sv.id); idx++; } } else { if (idx < bitmap.length && bitmap[idx] === '1') checkedSet.add(v.id); idx++; } } return { checkedSet, forceFromStart: force }; } function updateMemoryListFromCheckedSet() { const listName = getListName(); const memory = getMemoryList(); let existingKey = null; for (const key in memory) { if (memory[key] && memory[key][0] === listName) { existingKey = key; break; } } let bitmap = ''; for (const v of panelState.videoData) { if (v.isMulti && v.subVideos.length > 0) { for (const sv of v.subVideos) bitmap += panelState.checkedSet.has(sv.id) ? '1' : '0'; } else bitmap += panelState.checkedSet.has(v.id) ? '1' : '0'; } const newEntry = [listName, panelState.forceFromStart, bitmap]; if (existingKey) { memory[existingKey] = newEntry; } else { const keys = Object.keys(memory); const newKey = 'l' + (keys.length + 1); memory[newKey] = newEntry; } saveMemoryList(memory); window.__biliForceStartEnabled = panelState.forceFromStart; return true; } function getCheckedIdsFromMemoryList(listName, videoData) { const memory = getMemoryList(); let matchedKey = null; for (const key in memory) { if (memory[key] && memory[key][0] === listName) { matchedKey = key; break; } } if (!matchedKey) return []; const entry = memory[matchedKey]; const bitmap = entry.length === 2 ? entry[1] : entry[2]; const ids = []; let idx = 0; for (const v of videoData) { if (v.isMulti && v.subVideos.length > 0) { for (const sv of v.subVideos) { if (idx < bitmap.length && bitmap[idx] === '1') ids.push(sv.id); idx++; } } else { if (idx < bitmap.length && bitmap[idx] === '1') ids.push(v.id); idx++; } } return ids; } // 合集key解析:页面BV签名与已存合集高度重叠(Jaccard≥0.6)时视为同一合集, // 避免选集框因推荐/页面状态微变BV列表后,保存另建新档而非更新原合集 function resolveCollectionKey(cid) { if (!cid || cid.indexOf('single_') === 0) return cid; const data = getAllData(); if (Array.isArray(data[cid])) return cid; // 精确命中 const cur = collectionKeyToBvs(cid); if (cur.length < 2) return cid; const curSet = new Set(cur); let bestKey = null, bestScore = 0; for (const k in data) { if (k === cid || k.indexOf('_') < 0 || !Array.isArray(data[k])) continue; if (k.indexOf('single_') === 0) continue; const oldSet = new Set(collectionKeyToBvs(k)); if (!oldSet.size) continue; let inter = 0; curSet.forEach(b => { if (oldSet.has(b)) inter++; }); const jac = inter / (oldSet.size + curSet.size - inter); if (jac > bestScore) { bestScore = jac; bestKey = k; } } return (bestKey && bestScore >= 0.6) ? bestKey : cid; } function getListForCurrentCollection() { const listName = getListName(); const videoData = panelState.videoData.length ? panelState.videoData : parsePageVideoList(); const ids = getCheckedIdsFromMemoryList(listName, videoData); if (ids.length > 0) return ids; const cid = resolveCollectionKey(getCollectionIdFromPage()); if (!cid) return []; const data = getAllData(); return Array.isArray(data[cid]) ? data[cid] : []; } function saveListForCurrentCollection(list) { const cid = resolveCollectionKey(getCollectionIdFromPage()); if (!cid) return false; const data = getAllData(); data[cid] = list; saveAllData(data); return true; } function getCurrentBVID() { const match = location.pathname.match(/BV[0-9A-Za-z]+/); return match ? match[0] : null; } function getCurrentPIndex() { const params = new URLSearchParams(location.search); const p = params.get('p'); return p ? parseInt(p) - 1 : 0; } function navigateToVideo(bv, pIndex) { if (!bv) return; let newPath = location.pathname.replace(/BV[0-9A-Za-z]+/, bv); const params = new URLSearchParams(location.search); if (pIndex !== null && pIndex !== undefined) params.set('p', pIndex + 1); else params.delete('p'); params.delete('t'); const search = params.toString() ? '?' + params.toString() : ''; const newUrl = newPath + search + (location.hash || ''); if (newUrl === location.href) return; window.history.pushState({}, '', newUrl); window.dispatchEvent(new PopStateEvent('popstate')); } function hijackVisibility() { try { Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); } catch(e){} try { Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); } catch(e){} const block = (e) => e.stopImmediatePropagation(); document.addEventListener('visibilitychange', block, true); window.addEventListener('visibilitychange', block, true); } function checkAndHijack() { const bv = getCurrentBVID(); if (!bv) return; const memory = getMemoryList(); const videoData = panelState.videoData.length ? panelState.videoData : parsePageVideoList(); let found = false; let listIds = []; for (const key in memory) { const entry = memory[key]; const bitmap = entry.length === 2 ? entry[1] : entry[2]; const force = entry.length >= 3 ? entry[1] : false; let idx = 0; const ids = []; for (const v of videoData) { if (v.isMulti && v.subVideos.length > 0) { for (const sv of v.subVideos) { if (idx < bitmap.length && bitmap[idx] === '1') { ids.push(sv.id); if (sv.bv === bv) found = true; } idx++; } } else { if (idx < bitmap.length && bitmap[idx] === '1') { ids.push(v.id); if (v.bv === bv) found = true; } idx++; } } // 如果开启了强制从头播放,且当前视频不在勾选列表里,自动跳转到第一个勾选视频 if (force && ids.length > 0 && !found) { const currentP = getCurrentPIndex(); // 再精确检查一次:当前BV+P是否在列表里 let exactFound = false; for (const id of ids) { if (id.includes('::')) { const [ibv, ip] = id.split('::'); if (ibv === bv && parseInt(ip) === currentP) { exactFound = true; break; } } else { if (id === bv && currentP === 0) { exactFound = true; break; } } } if (!exactFound && !window.__isOpeningPanel) { const firstId = ids[0]; const firstBv = firstId.includes('::') ? firstId.split('::')[0] : firstId; const firstP = firstId.includes('::') ? parseInt(firstId.split('::')[1]) : null; window.__biliForceStartEnabled = true; setTimeout(() => navigateToVideo(firstBv, firstP), 500); showToast('🎬 已跳转到选定列表第一首'); return; } } if (ids.length > 0) listIds = ids; } if (found) { hijackVisibility(); } } function onVideoEnded(e) { const video = e.target; if (!video.closest('#bilibili-player') && !video.closest('.bpx-player-video-wrap')) return; const currentBv = getCurrentBVID(); const currentP = getCurrentPIndex(); if (!currentBv) return; const list = getListForCurrentCollection(); if (list.length === 0) return; if (!window.__biliForceStartEnabled) return; let currentIndex = -1; for (let i = 0; i < list.length; i++) { const item = list[i]; if (item.includes('::')) { const [bv, pStr] = item.split('::'); const pIdx = parseInt(pStr); if (bv === currentBv && pIdx === currentP) { currentIndex = i; break; } } else { if (item === currentBv && currentP === 0) { currentIndex = i; break; } } } // 无论当前在不在列表里,都跳转到下一个勾选;如果是最后一个,回到第一个循环 e.stopImmediatePropagation(); e.preventDefault(); let nextIndex; if (currentIndex >= 0 && currentIndex < list.length - 1) { nextIndex = currentIndex + 1; } else { // 不在列表里 或者 已经是最后一个 → 回到第一个循环 nextIndex = 0; showToast('🔁 列表播放完毕,回到第一首循环'); } const nextId = list[nextIndex]; const nextBv = nextId.includes('::') ? nextId.split('::')[0] : nextId; const nextPIndex = nextId.includes('::') ? parseInt(nextId.split('::')[1]) : null; setTimeout(() => navigateToVideo(nextBv, nextPIndex), 200); } function setupVideoEndListener() { document.addEventListener('ended', onVideoEnded, true); } // ========== 6. 主题切换与样式注入 ========== function applyTheme(theme) { const drawer = document.getElementById('bili-force-drawer'); const trigger = document.getElementById('bili-force-trigger'); if (drawer) { if (theme === 'dark') { drawer.classList.add('dark'); } else { drawer.classList.remove('dark'); } applyCurrentBg(); } if (trigger) { if (theme === 'dark') { trigger.classList.add('dark'); } else { trigger.classList.remove('dark'); } } } function toggleThemeManual() { const current = getTheme(); const next = current === 'dark' ? 'light' : 'dark'; saveTheme(next); applyTheme(next); updateThemeToggleUI(); showToast(next === 'dark' ? '🌙 已切换暗色主题' : '☀️ 已切换浅色主题'); } function updateThemeToggleUI() { const followSwitch = document.getElementById('theme-follow-switch'); const toggleBtn = document.querySelector('.btn-theme-toggle'); if (followSwitch) { followSwitch.classList.toggle('active', getThemeFollow()); } if (toggleBtn) { const isFollow = getThemeFollow(); toggleBtn.style.display = isFollow ? 'none' : 'inline-block'; const currentTheme = getTheme(); toggleBtn.textContent = currentTheme === 'dark' ? '☀️ 亮色' : '🌙 暗色'; } } function toggleThemeFollow() { const currentFollow = getThemeFollow(); const nextFollow = !currentFollow; saveThemeFollow(nextFollow); applyTheme(getTheme()); updateThemeToggleUI(); showToast(nextFollow ? '🔄 已开启跟随页面明暗' : '✋ 已关闭跟随,可手动切换'); } function setupThemeListener() { let lastDetectedTheme = getBilibiliTheme(); applyTheme(getTheme()); updateThemeToggleUI(); // 定期检测主题变化(核心手段) setInterval(() => { if (!getThemeFollow()) return; const currentTheme = getBilibiliTheme(); if (currentTheme !== lastDetectedTheme) { lastDetectedTheme = currentTheme; applyTheme(currentTheme); updateThemeToggleUI(); } }, 1000); } function injectStyles() { const styleId = 'bili-force-drawer-styles'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` #bili-force-trigger { position: fixed; z-index: 99999; background: #fb7299; color: #fff; padding: 8px 14px; border-radius: 20px; cursor: grab; font-size: 13px; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 8px rgba(251,114,153,0.3); user-select: none; transition: left 0.25s ease, right 0.25s ease, transform 0.2s, box-shadow 0.2s, opacity 0.2s; } #bili-force-trigger:active { cursor: grabbing; transform: scale(1.05); box-shadow: 0 4px 12px rgba(251,114,153,0.4); transition: transform 0.2s, box-shadow 0.2s; } #bili-force-trigger.snap-hidden { opacity: 0.6; } #bili-force-trigger.snap-hidden:hover { opacity: 1; } #bili-force-trigger.snap-left { left: -30px !important; } #bili-force-trigger.snap-right { right: -30px !important; } #bili-force-trigger.snap-left:hover { left: 0 !important; } #bili-force-trigger.snap-right:hover { right: 0 !important; } #bili-force-trigger.panel-open { opacity: 0.3; } #bili-force-trigger.dark { background: #fb7299; color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.4); } #bili-force-trigger.dark.snap-hidden { opacity: 0.7; } #bili-force-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 99998; opacity: 0; visibility: hidden; transition: all 0.3s; } #bili-force-overlay.active { opacity: 1; visibility: visible; } #bili-force-drawer { position: fixed; right: -400px; top: 0; width: 380px; height: 100vh; z-index: 99999; transition: right 0.3s ease; display: flex; flex-direction: column; box-shadow: -2px 0 16px rgba(0,0,0,0.15), -8px 0 40px rgba(0,0,0,0.1), inset 1px 0 0 rgba(0,0,0,0.08); overflow: hidden; } #bili-force-drawer.active { right: 0; } /* 暗色主题 */ #bili-force-drawer.dark { color: #e0e0e0; box-shadow: -2px 0 16px rgba(0,0,0,0.4), -8px 0 40px rgba(0,0,0,0.3), inset 1px 0 0 rgba(255,255,255,0.07); } #bili-force-drawer.dark .action-btn { background: #333; border-color: #444; color: #e0e0e0; } #bili-force-drawer.dark .action-btn:hover { background: #444; } #bili-force-drawer.dark .btn-save { background: #fb7299; border-color: #fb7299; color: #fff; } #bili-force-drawer.dark .btn-save:hover { background: #ff85a8; } #bili-force-drawer.dark .video-item:hover { background: #2a2a2a; } #bili-force-drawer.dark .video-item.current-playing { background: #2d1f24; border-left-color: #fb7299; } #bili-force-drawer.dark .bv-tag, #bili-force-drawer.dark .video-meta { color: #888; } #bili-force-drawer.dark .drawer-close { color: #999; } .drawer-header { padding: 12px; border-bottom: 1px solid #eee; flex-shrink: 0; background: rgba(255,255,255,0.7); position: relative; z-index: 2; } #bili-force-drawer.dark .drawer-header { background: rgba(30,30,30,0.7); border-bottom-color: #333; } .header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .drawer-title { font-size: 16px; font-weight: 600; } .drawer-close { border: none; background: none; font-size: 18px; cursor: pointer; color: #666; } .header-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; } .action-btn { padding: 4px 9px; border: 1px solid #ddd; background: #f5f5f5; border-radius: 6px; cursor: pointer; font-size: 12px; line-height: 1.5; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .action-btn:hover { background: #eee; } .btn-save, .btn-collections-main { margin-left: auto; background: #fb7299; border-color: #fb7299; color: #fff; } .btn-save:hover, .btn-collections-main:hover { background: #ff85a8; } /* 设置行:开关 + 计数合并为一行 */ .header-settings { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid rgba(128,128,128,0.18); font-size: 12px; color: #666; } #bili-force-drawer.dark .header-settings { color: #aaa; border-top-color: rgba(255,255,255,0.12); } .set-item { display: inline-flex; align-items: center; gap: 6px; } .btn-theme-toggle { padding: 3px 8px !important; font-size: 12px !important; } .switch-track { width: 34px; height: 18px; background: #ccc; border-radius: 9px; position: relative; cursor: pointer; transition: background 0.2s; flex-shrink: 0; } .switch-track.active { background: #fb7299; } .switch-track::before { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: left 0.2s; } .switch-track.active::before { left: 18px; } .list-info { margin-left: auto; font-size: 12px; color: #666; } #bili-force-drawer.dark .list-info { color: #aaa; } /* ===== 工具栏/多合集阅览 ===== */ .header-right { display: flex; align-items: center; gap: 8px; } /* list / collections 双模式 */ #bili-force-drawer.mode-collections .list-only { display: none !important; } .collections-view { display: none; } #bili-force-drawer .collections-view { background: rgba(255,255,255,0.55); padding: 0 16px 12px; /* 关键:必须高于 drawer-bg-layer(0) 和 drawer-bg-mask(1),否则被不透明遮罩盖住 */ position: relative; z-index: 2; } #bili-force-drawer.dark .collections-view { background: rgba(24,24,24,0.6); } #bili-force-drawer.mode-collections .collections-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .collections-view .collections-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 2px 10px; font-size: 12px; color: #666; } #bili-force-drawer.dark .collections-view .collections-head { color: #aaa; } .collections-view .collections-list { flex: 1; overflow-y: auto; min-height: 0; } .collection-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid rgba(0,0,0,0.06); border-radius: 8px; margin-bottom: 8px; cursor: pointer; background: rgba(255,255,255,0.5); transition: background 0.2s ease, border-color 0.2s ease; } #bili-force-drawer.dark .collection-item { background: rgba(40,40,40,0.6); border-color: rgba(255,255,255,0.08); } .collection-item:hover { background: rgba(255,255,255,0.9); border-color: #fb7299; } #bili-force-drawer.dark .collection-item:hover { background: rgba(60,60,60,0.8); border-color: #fb7299; } .collection-item .ci-badge { flex-shrink: 0; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: rgba(251,114,153,0.15); color: #fb7299; white-space: nowrap; } #bili-force-drawer.dark .collection-item .ci-badge { background: rgba(251,114,153,0.22); color: #ff9db8; } .collection-item .ci-main { flex: 1; min-width: 0; } .collection-item .ci-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .collection-item .ci-meta { font-size: 11px; color: #999; margin-top: 2px; } #bili-force-drawer.dark .collection-item .ci-meta { color: #888; } .collection-item .ci-last { flex-shrink: 0; font-size: 11px; color: #999; white-space: nowrap; } #bili-force-drawer.dark .collection-item .ci-last { color: #888; } .collection-empty { padding: 30px 10px; text-align: center; color: #888; font-size: 13px; } .drawer-body { flex: 1; overflow-y: auto; padding: 8px 0; position: relative; z-index: 1; background: transparent; } /* 旧伪元素背景已废弃,改用真实DOM .drawer-bg-layer */ .video-item { display: flex; align-items: center; padding: 8px 16px; gap: 8px; cursor: pointer; font-size: 13px; position: relative; z-index: 1; transition: background 0.2s ease; } .video-item:hover { background: rgba(245,245,245,0.8); } #bili-force-drawer.dark .video-item:hover { background: rgba(42,42,42,0.8); } .video-item.current-playing { background: rgba(255,240,243,0.9); border-left: 3px solid #fb7299; } #bili-force-drawer.dark .video-item.current-playing { background: rgba(45,31,36,0.9); border-left-color: #fb7299; } .video-item.sub-item { padding-left: 36px; font-size: 12px; } .expand-icon { width: 16px; text-align: center; color: #999; font-size: 10px; } .expand-icon.expanded { transform: rotate(90deg); } .checkbox-wrap { flex-shrink: 0; } .video-info { flex: 1; min-width: 0; } .video-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .video-meta { font-size: 11px; color: #999; margin-top: 2px; display: flex; gap: 10px; } .bv-tag { flex-shrink: 0; font-size: 11px; color: #999; font-family: monospace; } .drawer-footer { padding: 12px 16px; border-top: 1px solid #eee; display: flex; gap: 10px; flex-shrink: 0; background: rgba(255,255,255,0.7); position: relative; z-index: 2; } #bili-force-drawer.dark .drawer-footer { background: rgba(30,30,30,0.7); border-top-color: #333; } .drawer-footer .action-btn { flex: 1; padding: 8px; } .btn-save { background: #fb7299; color: #fff; border-color: #fb7299; } .btn-save:hover { background: #ff85a8; } #bili-force-toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.9); background: rgba(0,0,0,0.75); color: #fff; padding: 10px 20px; border-radius: 6px; z-index: 100000; opacity: 0; visibility: hidden; transition: all 0.2s; font-size: 14px; } #bili-force-toast.show { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); } `; document.head.appendChild(style); } // ========== 7. 按钮拖拽吸附(优化版) ========== function bindDragBehavior(trigger) { let isDragging = false; let hasMoved = false; let startX = 0, startY = 0; let startLeft = 0, startTop = 0; let lastSnapSide = getTriggerPos().side; function applySnap(side) { trigger.classList.remove('snap-left', 'snap-right', 'snap-hidden'); if (side === 'left') { trigger.classList.add('snap-left', 'snap-hidden'); } else { trigger.classList.add('snap-right', 'snap-hidden'); } lastSnapSide = side; } function onStart(e) { const point = e.touches ? e.touches[0] : e; isDragging = true; hasMoved = false; startX = point.clientX; startY = point.clientY; // 拖拽时移除吸附状态 trigger.classList.remove('snap-left', 'snap-right', 'snap-hidden'); const rect = trigger.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; // 统一使用 left 定位 trigger.style.left = startLeft + 'px'; trigger.style.right = 'auto'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd); } function onMove(e) { if (!isDragging) return; e.preventDefault(); const point = e.touches ? e.touches[0] : e; const dx = point.clientX - startX; const dy = point.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasMoved = true; let newLeft = startLeft + dx; let newTop = startTop + dy; const btnWidth = trigger.offsetWidth; const btnHeight = trigger.offsetHeight; newLeft = Math.max(-40, Math.min(window.innerWidth - btnWidth + 40, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - btnHeight, newTop)); trigger.style.left = newLeft + 'px'; trigger.style.top = newTop + 'px'; } function onEnd() { isDragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); if (!hasMoved) { // 只是点击没有拖拽,恢复之前的吸附状态 applySnap(lastSnapSide); return; } const rect = trigger.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const viewportHalf = window.innerWidth / 2; const posData = { top: rect.top }; if (centerX < viewportHalf) { trigger.style.left = '0px'; trigger.style.right = 'auto'; posData.side = 'left'; posData.left = 0; applySnap('left'); } else { trigger.style.left = 'auto'; trigger.style.right = '0px'; posData.side = 'right'; posData.right = 0; applySnap('right'); } saveTriggerPos(posData); } trigger.addEventListener('click', (e) => { if (hasMoved) { e.stopPropagation(); e.preventDefault(); hasMoved = false; } }, true); trigger.addEventListener('mousedown', onStart); trigger.addEventListener('touchstart', onStart, { passive: false }); // 从存储恢复位置 const savedPos = getTriggerPos(); trigger.style.top = savedPos.top + 'px'; if (savedPos.side === 'left') { trigger.style.left = '0px'; trigger.style.right = 'auto'; applySnap('left'); } else { trigger.style.left = 'auto'; trigger.style.right = '0px'; applySnap('right'); } // 窗口大小变化时修正位置 function handleResize() { const rect = trigger.getBoundingClientRect(); const btnHeight = trigger.offsetHeight; let newTop = rect.top; newTop = Math.max(0, Math.min(window.innerHeight - btnHeight, newTop)); trigger.style.top = newTop + 'px'; const posData = getTriggerPos(); posData.top = newTop; saveTriggerPos(posData); } window.addEventListener('resize', handleResize); } // ========== 8. UI 构建 ========== function createToast() { let toast = document.getElementById('bili-force-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'bili-force-toast'; document.body.appendChild(toast); } return toast; } function showToast(msg, duration = 2000) { const toast = createToast(); toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._timeout); toast._timeout = setTimeout(() => toast.classList.remove('show'), duration); } function createTriggerButton() { let trigger = document.getElementById('bili-force-trigger'); if (trigger && document.body.contains(trigger)) return trigger; if (trigger) trigger.remove(); trigger = document.createElement('div'); trigger.id = 'bili-force-trigger'; trigger.innerHTML = `列表管理`; document.body.appendChild(trigger); bindDragBehavior(trigger); return trigger; } function createOverlay() { let overlay = document.getElementById('bili-force-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'bili-force-overlay'; document.body.appendChild(overlay); } return overlay; } function createDrawer() { let drawer = document.getElementById('bili-force-drawer'); if (!drawer) { drawer = document.createElement('div'); drawer.id = 'bili-force-drawer'; drawer.innerHTML = `
📋 播放列表管理
跟随页面明暗 强制从头播放 已勾选 0
已记录 0 个合集
`; document.body.appendChild(drawer); applyTheme(getTheme()); applyCurrentBg(); } return drawer; } let panelState = { open: false, videoData: [], checkedSet: new Set(), expandedMultis: new Set(), forceFromStart: false }; function updateForceSwitchUI() { const track = document.getElementById('force-start-switch'); if (track) track.classList.toggle('active', panelState.forceFromStart); } // ====================== 观看次数统计(读取 4.js 的 IndexedDB) ====================== const viewCountsCache = { data: null, ts: 0 }; // 优先使用 4.js 暴露的接口,否则直接读同一 IndexedDB(同源可用)兜底 function readViewCountDBDirect() { return new Promise(resolve => { try { const req = indexedDB.open('BiliViewCountDB'); req.onsuccess = () => { try { const tx = req.result.transaction('views', 'readonly'); const getAll = tx.objectStore('views').getAll(); getAll.onsuccess = () => { const map = {}; getAll.result.forEach(r => { map[r.id] = r.times; }); resolve(map); }; getAll.onerror = () => resolve(null); } catch (e) { resolve(null); } }; req.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } async function loadViewCounts(force) { const now = Date.now(); if (!force && viewCountsCache.data && now - viewCountsCache.ts < 60000) return viewCountsCache.data; let data = null; const _g = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; if (typeof _g.__biliViewCountGetAll === 'function') { try { data = await _g.__biliViewCountGetAll(); } catch (e) { data = null; } } if (!data) data = await readViewCountDBDirect(); if (data) { viewCountsCache.data = data; viewCountsCache.ts = now; } return viewCountsCache.data; } function getTimesHtml(bvList) { const vc = viewCountsCache.data; if (!vc) return ''; const sum = bvList.reduce((s, bv) => s + (vc[bv] || 0), 0); return sum > 0 ? `🔁 ${sum}` : ''; } function renderVideoList() { const drawer = document.getElementById('bili-force-drawer'); if (!drawer) return; const body = drawer.querySelector('.drawer-body'); if (!body) return; const currentBv = getCurrentBVID(); const currentP = getCurrentPIndex(); const videoData = panelState.videoData; let html = ''; if (videoData.length === 0) { html = '
当前页面未检测到合集视频列表
'; } else { videoData.forEach(v => { if (v.isMulti && v.subVideos.length > 0) { const isExpanded = panelState.expandedMultis.has(v.bv); const anySubChecked = v.subVideos.some(sv => panelState.checkedSet.has(sv.id)); const allSubChecked = v.subVideos.every(sv => panelState.checkedSet.has(sv.id)); const isCurrentParent = currentBv === v.bv; html += `
${escapeHtml(v.title)}📁 ${v.subVideos.length}个分P${v.duration}${getTimesHtml([v.bv])} ${v.bv}
`; if (isExpanded) { v.subVideos.forEach(sv => { const checked = panelState.checkedSet.has(sv.id); const isCurrentSub = currentBv === sv.bv && sv.pIndex === currentP; html += `
${escapeHtml(sv.title)}⏱ ${sv.duration} P${sv.pIndex + 1}
`; }); } } else { const checked = panelState.checkedSet.has(v.id); const isCurrent = currentBv === v.bv; html += `
${escapeHtml(v.title)}⏱ ${v.duration}${getTimesHtml([v.bv])} ${v.bv}
`; } }); } body.innerHTML = html; updateCheckedCount(); bindListEvents(); } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function updateCheckedCount() { const drawer = document.getElementById('bili-force-drawer'); if (!drawer) return; const countEl = drawer.querySelector('.checked-count'); if (countEl) countEl.textContent = panelState.checkedSet.size; } function bindListEvents() { const body = document.querySelector('#bili-force-drawer .drawer-body'); if (!body) return; body.querySelectorAll('.expand-icon').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const parent = el.closest('.multi-parent'); const bv = parent.getAttribute('data-bv'); if (panelState.expandedMultis.has(bv)) { panelState.expandedMultis.delete(bv); } else { panelState.expandedMultis.add(bv); } renderVideoList(); }); }); body.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', (e) => { e.stopPropagation(); const id = cb.getAttribute('data-id'); const isParent = cb.getAttribute('data-is-parent') === '1'; if (isParent) { const video = panelState.videoData.find(v => v.bv === id); if (video && video.subVideos) { video.subVideos.forEach(sv => { if (cb.checked) panelState.checkedSet.add(sv.id); else panelState.checkedSet.delete(sv.id); }); } } else { if (cb.checked) panelState.checkedSet.add(id); else panelState.checkedSet.delete(id); } renderVideoList(); }); }); body.querySelectorAll('.video-item').forEach(item => { item.addEventListener('click', (e) => { if (e.target.closest('input') || e.target.closest('.expand-icon')) return; const bv = item.getAttribute('data-bv'); const pIndex = item.getAttribute('data-pindex'); navigateToVideo(bv, pIndex ? parseInt(pIndex) : null); }); }); } function openPanel() { const drawer = document.getElementById('bili-force-drawer'); const overlay = document.getElementById('bili-force-overlay'); const trigger = document.getElementById('bili-force-trigger'); // 每次打开默认回到列表模式 if (drawer) drawer.classList.remove('mode-collections'); const titleEl = document.getElementById('bili-drawer-title'); if (titleEl) titleEl.textContent = '📋 播放列表管理'; window.__isOpeningPanel = true; panelState.videoData = parsePageVideoList(); panelState.expandedMultis.clear(); const currentBv = getCurrentBVID(); // 自动展开包含当前播放视频的多P项 panelState.videoData.forEach(v => { if (v.isMulti && v.bv === currentBv) { panelState.expandedMultis.add(v.bv); } }); const listName = getListName(); const restored = restoreFromMemoryList(listName, panelState.videoData); if (restored.checkedSet) { panelState.checkedSet = restored.checkedSet; panelState.forceFromStart = restored.forceFromStart; } else { panelState.checkedSet = new Set(getListForCurrentCollection()); } if (drawer) drawer.classList.add('active'); if (overlay) overlay.classList.add('active'); if (trigger) trigger.classList.add('panel-open'); panelState.open = true; renderVideoList(); updateForceSwitchUI(); window.__biliForceStartEnabled = panelState.forceFromStart; // 异步加载观看次数统计(4.js 的 IndexedDB),加载完成后刷新一次列表显示 🔁 loadViewCounts().then(() => { if (panelState.open) renderVideoList(); }); // 加载当前合集背景图和美化配置 applyCurrentBg(); // 自动滚动到当前播放项 setTimeout(() => { const body = drawer.querySelector('.drawer-body'); const currentItem = body.querySelector('.current-playing'); if (currentItem && body) { currentItem.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); setTimeout(() => { window.__isOpeningPanel = false; }, 800); } function closePanel() { const trigger = document.getElementById('bili-force-trigger'); const drawer = document.getElementById('bili-force-drawer'); const overlay = document.getElementById('bili-force-overlay'); if (drawer) drawer.classList.remove('active'); if (overlay) overlay.classList.remove('active'); if (trigger) trigger.classList.remove('panel-open'); panelState.open = false; } function togglePanel() { panelState.open ? closePanel() : openPanel(); } function saveCurrentList() { updateMemoryListFromCheckedSet(); const ids = [...panelState.checkedSet]; saveListForCurrentCollection(ids); updateCheckedCount(); checkAndHijack(); showToast('✅ 列表已保存'); } function bindEvents() { const drawer = document.getElementById('bili-force-drawer'); const overlay = document.getElementById('bili-force-overlay'); const trigger = document.getElementById('bili-force-trigger'); if (trigger) trigger.addEventListener('click', (e) => { e.stopPropagation(); togglePanel(); }); if (overlay) overlay.addEventListener('click', closePanel); if (drawer) { const closeBtn = drawer.querySelector('.drawer-close'); if (closeBtn) closeBtn.addEventListener('click', closePanel); const closePanelBtn = drawer.querySelector('.btn-close-panel'); if (closePanelBtn) closePanelBtn.addEventListener('click', () => { closePanel(); }); const saveBottomBtn = drawer.querySelector('.btn-save-bottom'); if (saveBottomBtn) saveBottomBtn.addEventListener('click', () => { if (saveBottomBtn._saving) return; saveBottomBtn._saving = true; saveCurrentList(); saveBottomBtn.textContent = '✅ 已保存'; setTimeout(() => { saveBottomBtn.textContent = '💾 保存并关闭'; saveBottomBtn._saving = false; closePanel(); }, 700); }); const selectAll = drawer.querySelector('.btn-select-all'); const deselectAll = drawer.querySelector('.btn-deselect-all'); const invert = drawer.querySelector('.btn-invert'); if (selectAll) selectAll.addEventListener('click', () => { panelState.videoData.forEach(v => { if (v.isMulti) v.subVideos.forEach(sv => panelState.checkedSet.add(sv.id)); else panelState.checkedSet.add(v.id); }); renderVideoList(); }); if (deselectAll) deselectAll.addEventListener('click', () => { panelState.checkedSet.clear(); renderVideoList(); }); if (invert) invert.addEventListener('click', () => { const allIds = []; panelState.videoData.forEach(v => { if (v.isMulti) v.subVideos.forEach(sv => allIds.push(sv.id)); else allIds.push(v.id); }); allIds.forEach(id => { if (panelState.checkedSet.has(id)) panelState.checkedSet.delete(id); else panelState.checkedSet.add(id); }); renderVideoList(); }); // 主题跟随开关 const themeFollowSwitch = drawer.querySelector('#theme-follow-switch'); if (themeFollowSwitch && !themeFollowSwitch._bound) { themeFollowSwitch._bound = true; themeFollowSwitch.addEventListener('click', (e) => { e.stopPropagation(); toggleThemeFollow(); }); } // 手动切换主题按钮 const themeToggleBtn = drawer.querySelector('.btn-theme-toggle'); if (themeToggleBtn && !themeToggleBtn._bound) { themeToggleBtn._bound = true; themeToggleBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleThemeManual(); }); } const forceSwitch = drawer.querySelector('#force-start-switch'); if (forceSwitch && !forceSwitch._bound) { forceSwitch._bound = true; forceSwitch.addEventListener('click', (e) => { e.stopPropagation(); panelState.forceFromStart = !panelState.forceFromStart; updateForceSwitchUI(); window.__biliForceStartEnabled = panelState.forceFromStart; if (window.__biliForceStartEnabled) resetVideo(); showToast(panelState.forceFromStart ? '✅ 强制从头播放已开启' : '⏸ 强制从头播放已关闭'); }); } // 多合集阅览:入口(按钮行「多合集预览」)/ 返回 / 点击合集项 const btnCollections = drawer.querySelector('.btn-collections-main'); if (btnCollections) btnCollections.addEventListener('click', (e) => { e.stopPropagation(); showCollections(); }); const backCollections = drawer.querySelector('#bili-collections-back'); if (backCollections) backCollections.addEventListener('click', backFromCollections); const collList = drawer.querySelector('#bili-collections-list'); if (collList) collList.addEventListener('click', (e) => { const del = e.target.closest('.coll-del'); if (del) { e.stopPropagation(); handleDeleteCollection(del); return; } const it = e.target.closest('.collection-item'); if (it && it.dataset.key) jumpToCollection(it.dataset.key); }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panelState.open) closePanel(); }); } // ========== 8.5 多合集阅览 ========== // 读取 4.js 观看记录(含 lastTime,用于“最近看过哪个视频”) function readViewRecords() { return new Promise(resolve => { try { const req = indexedDB.open('BiliViewCountDB'); req.onsuccess = () => { try { const tx = req.result.transaction('views', 'readonly'); const g = tx.objectStore('views').getAll(); g.onsuccess = () => resolve(g.result || []); g.onerror = () => resolve([]); } catch (e) { resolve([]); } }; req.onerror = () => resolve([]); } catch (e) { resolve([]); } }); } // 从合集 key 解析 BV 列表:single_BV… / list_BV1|BV2… function collectionKeyToBvs(key) { if (!key) return []; const i = key.indexOf('_'); if (i < 0) return []; return String(key.slice(i + 1)).split('|').filter(Boolean); } function showCollections() { const drawer = document.getElementById('bili-force-drawer'); if (!drawer) return; drawer.classList.add('mode-collections'); const title = document.getElementById('bili-drawer-title'); if (title) title.textContent = '📚 多合集阅览'; renderCollections(); } function backFromCollections() { const drawer = document.getElementById('bili-force-drawer'); if (!drawer) return; drawer.classList.remove('mode-collections'); const title = document.getElementById('bili-drawer-title'); if (title) title.textContent = '📋 播放列表管理'; } // 删除单个合集:二次确认防误删;同时清理 v3 记录和该合集的背景图 function handleDeleteCollection(btn) { const key = btn.getAttribute('data-del'); if (!key) return; if (!btn._confirmDel) { btn._confirmDel = true; btn.dataset.oldText = btn.textContent; btn.textContent = '确认删除?'; btn.style.fontWeight = 'bold'; btn._timer = setTimeout(() => { btn._confirmDel = false; btn.textContent = btn.dataset.oldText; btn.style.fontWeight = ''; }, 3000); return; } clearTimeout(btn._timer); const data = getAllData(); if (data[key] !== undefined) { delete data[key]; saveAllData(data); } try { deleteBgImageById(key); } catch (e) {} showToast('🗑 已删除该合集'); renderCollections(); } async function renderCollections() { const listEl = document.getElementById('bili-collections-list'); if (!listEl) return; const countEl = listEl.parentElement ? listEl.parentElement.querySelector('.collections-count') : null; // 数据源:v3 按合集记忆 {collectionId: [勾选ids]}(collectionId 自带 BV); // memory 仅按名记录 force,用于给同名单BV合集补「🔒 强制」标记 const data = getAllData(); const memory = getMemoryList(); const cidKeys = Object.keys(data).filter(k => k.indexOf('_') >= 0 && Array.isArray(data[k])); const curKey = resolveCollectionKey(getCollectionIdFromPage()); if (countEl) countEl.textContent = cidKeys.length; if (!cidKeys.length) { listEl.innerHTML = '
还没有保存过的合集。在列表页勾选后点「💾 保存列表」,就会出现在这里。
'; return; } const nameToForce = {}; for (const k in memory) { const e = memory[k]; if (e && e[0] && e.length >= 3) nameToForce[e[0]] = !!e[1]; } let recs = []; try { recs = await readViewRecords(); } catch (e) {} const titleOf = {}, lastOf = {}, timesOf = {}, lastPOf = {}; recs.forEach(r => { if (!r || !r.id) return; if (r.title) titleOf[r.id] = r.title; timesOf[r.id] = r.times; if (r.lastP) lastPOf[r.id] = r.lastP; const t = r.lastTime || 0; if (!(r.id in lastOf) || t > lastOf[r.id]) lastOf[r.id] = t; }); const rows = []; let failed = 0; cidKeys.forEach(cid => { try { const ids = data[cid] || []; const bvs = collectionKeyToBvs(cid); const firstBv = bvs[0] || ''; const isSingle = cid.indexOf('single_') === 0; let name = firstBv ? (titleOf[firstBv] || '') : ''; if (bvs.length > 1 && name) name += ' 等' + bvs.length + '个'; if (!name) name = isSingle ? '单视频合集' : (bvs.length > 1 ? '多视频合集' : '分P合集'); const force = firstBv && nameToForce[titleOf[firstBv]] ? nameToForce[titleOf[firstBv]] : false; let lastTs = 0, sumTimes = 0, lastPSeen = 0; bvs.forEach(b => { const t = lastOf[b] || 0; if (t > lastTs) { lastTs = t; lastPSeen = lastPOf[b] || 0; } sumTimes += timesOf[b] || 0; }); const isCur = cid === curKey; const badge = isSingle ? '单视频' : (bvs.length > 1 ? '多视频' : '分P'); const metaBits = []; if (bvs.length) metaBits.push(bvs.length + ' 个BV'); if (ids.length) metaBits.push('勾选 ' + ids.length + ' 项'); if (sumTimes) metaBits.push('累计 🔁 ' + sumTimes); if (force) metaBits.push('🔒 强制'); if (lastPSeen > 1) metaBits.push('上次 P' + lastPSeen); const lastStr = lastTs ? new Date(lastTs).toLocaleString('zh-CN', { hour12: false }) : ''; const subLine = [metaBits.join(' · '), lastStr ? '最近 ' + lastStr : ''].filter(Boolean).join(' · ') || '—'; rows.push(`
${badge}
${escapeHtml(name)}${isCur ? '(当前)' : ''}
${escapeHtml(subLine)}
🗑 删除 ▶ 跳转
`); } catch (err) { failed++; console.error('[多合集渲染错误]', err, cid.slice(0, 60)); } }); if (!rows.length) { listEl.innerHTML = failed ? '
解析合集时出错 ' + failed + ' 条,详见 Console
' : '
有合集记录但暂无可显示项
'; } else { listEl.innerHTML = rows.join(''); } } // 跳转到该合集“最近看过”的视频;单BV多P页则回本页或带 p=1 async function jumpToCollection(key) { const data = getAllData(); const ids = data[key]; if (!Array.isArray(ids) || !ids.length) return; const bvs = collectionKeyToBvs(key); if (!bvs.length) return; let recs = []; try { recs = await readViewRecords(); } catch (e) {} let targetBv = bvs[0], lastTs = 0, targetP = 0; bvs.forEach(b => { const r = recs.find(x => x && x.id === b); const t = r ? (r.lastTime || 0) : 0; if (t > lastTs) { lastTs = t; targetBv = b; targetP = (r && r.lastP) || 0; } }); const curBv = (location.pathname.match(/\/video\/(BV[0-9a-zA-Z]+)/) || [])[1]; if (curBv === targetBv) { showToast('✅ 已在当前视频,可在列表中继续勾选'); backFromCollections(); return; } // 带上上次退出的分P(无需精确空降,落到对应P即可) const url = 'https://www.bilibili.com/video/' + targetBv + (targetP > 1 ? '?p=' + targetP : ''); showToast('⏭ 正在跳转到该合集…'); setTimeout(() => { location.href = url; }, 300); } // ========== 9. 初始化 ========== function init() { cleanupLegacyCache(); injectStyles(); function onBodyReady() { createTriggerButton(); createOverlay(); createDrawer(); setupThemeListener(); bindEvents(); syncForceStartState(); checkAndHijack(); setupVideoEndListener(); resetVideo(); // 应用美化配置(磨砂/背景) applyCurrentBg(); // 初始化检查:如果当前不在勾选列表里,跳回第一个 setTimeout(redirectToFirstCheckedIfNeeded, 800); console.log(LOG_PREFIX, 'V7.1 就绪,磨砂玻璃+背景图支持,自动定位当前播放,勾选列表循环播放'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { document.body ? onBodyReady() : setTimeout(onBodyReady, 50); }); } else { document.body ? onBodyReady() : setTimeout(onBodyReady, 50); } } GM_registerMenuCommand('打开列表管理器', openPanel); GM_registerMenuCommand('强制从头播放:切换', () => { window.__biliForceStartEnabled = !window.__biliForceStartEnabled; panelState.forceFromStart = window.__biliForceStartEnabled; updateForceSwitchUI(); if (window.__biliForceStartEnabled) resetVideo(); showToast(window.__biliForceStartEnabled ? '✅ 强制从头播放已开启' : '⏸ 强制从头播放已关闭'); }); GM_registerMenuCommand('切换明暗主题(不跟随页面时有效)', () => { if (getThemeFollow()) { showToast('⚠️ 当前为跟随页面模式,请先在面板中关闭跟随'); return; } toggleThemeManual(); }); init(); })();