// ==UserScript== // @name B站合集列表管理器(主题开关版 V7.0) // @namespace http://tampermonkey.net/ // @version 7.0 // @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'; // 直接设置面板磨砂(inline style最高优先级) drawer.style.backdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; drawer.style.webkitBackdropFilter = cfg.frostedGlass ? `blur(${cfg.glassBlur}px)` : 'none'; // 确保/创建背景层和遮罩层 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); } const header = drawer.querySelector('.drawer-header'); const body = drawer.querySelector('.drawer-body'); const footer = drawer.querySelector('.drawer-footer'); if (header) { header.style.zIndex = '2'; header.style.position = 'relative'; } if (body) { body.style.zIndex = '2'; body.style.position = 'relative'; body.style.background = 'transparent'; } if (footer) { footer.style.zIndex = '2'; footer.style.position = 'relative'; } } // 设置半透明面板遮罩 const maskColor = isDark ? `rgba(30,30,30,${cfg.drawerOpacity})` : `rgba(255,255,255,${cfg.drawerOpacity})`; bgMask.style.background = maskColor; // 设置背景图(直接用CSS关键字,浏览器原生支持超出0-100%位置自由移动,不需要手动计算像素) 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'; bgMask.style.background = isDark ? `rgba(30,30,30,1)` : `rgba(255,255,255,1)`; } } // 挂载共享函数到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; } }; _global.__biliListDeleteBgImage = async (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.__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() { 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. 列表解析与导航逻辑 ========== function getCollectionIdFromPage() { 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() { 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; } 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 = getCollectionIdFromPage(); if (!cid) return []; const data = getAllData(); return Array.isArray(data[cid]) ? data[cid] : []; } function saveListForCurrentCollection(list) { const cid = 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: 16px; 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; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } .action-btn { padding: 5px 10px; border: 1px solid #ddd; background: #f5f5f5; border-radius: 4px; cursor: pointer; font-size: 12px; } .action-btn:hover { background: #eee; } .theme-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; gap: 8px; } .theme-label { font-size: 13px; } .btn-theme-toggle { padding: 4px 8px !important; font-size: 12px !important; margin-left: auto; } .force-switch-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; } .switch-track { width: 40px; height: 20px; background: #ccc; border-radius: 10px; position: relative; cursor: pointer; transition: background 0.2s; } .switch-track.active { background: #fb7299; } .switch-track::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: left 0.2s; } .switch-track.active::before { left: 22px; } .list-info { font-size: 12px; color: #666; margin-top: 8px; } .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 = `