// ==UserScript==
// @name B站合集列表管理器(抽屉面板)
// @namespace http://tampermonkey.net/
// @version 4.0
// @description 侧边抽屉式面板,可视化勾选视频,按脚本列表顺序播放。MemoryList 名称标识,跨视频自动恢复。
// @author Anonymity喵
// @match *://www.bilibili.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const STORAGE_KEY = 'bili_force_video_data_v3'; // 旧版兼容,不再主用
const MEMORY_STORAGE_KEY = 'bili_force_memory_list'; // 新版名称+位图存储
const LOG_PREFIX = '[列表管理]';
// ========== 存储操作 ==========
function getAllData() {
try {
const data = GM_getValue(STORAGE_KEY, '{}');
return JSON.parse(data);
} catch (e) {
console.warn(LOG_PREFIX, '读取存储失败', e);
return {};
}
}
function saveAllData(data) {
GM_setValue(STORAGE_KEY, JSON.stringify(data));
}
// 新版 MemoryList 操作
function getMemoryList() {
try {
const data = GM_getValue(MEMORY_STORAGE_KEY, '{}');
return JSON.parse(data);
} catch (e) {
console.warn(LOG_PREFIX, '读取 MemoryList 失败', e);
return {};
}
}
function saveMemoryList(data) {
GM_setValue(MEMORY_STORAGE_KEY, JSON.stringify(data));
}
// 获取页面合集标题
function getListName() {
const titleEl = document.querySelector('.video-pod__header .title');
if (titleEl) return titleEl.textContent.trim();
// 备选:从路径或meta获取
const metaTitle = document.querySelector('meta[property="og:title"]');
if (metaTitle) {
let t = metaTitle.getAttribute('content') || '';
t = t.replace(/^[^_]*_/,'').trim(); // 去掉作者前缀
return t || '未知合集';
}
return '未知合集';
}
// 兼容旧版(不再使用,但保留函数以防报错)
function getCollectionIdFromPage() {
const container = document.querySelector('.video-pod__list');
if (!container) return null;
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 null;
const sorted = Array.from(bvs).sort();
return 'list_' + sorted.join('|');
}
// 从 MemoryList 按名称恢复勾选
function restoreCheckedSetFromMemoryList(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 null;
const bitmap = memory[matchedKey][1] || '';
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;
}
// 基于当前勾选状态更新 MemoryList
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;
}
}
if (existingKey) {
// 已存在,提示并拒绝覆盖(这里直接返回,不保存)
showToast('❌ 该列表已添加,无法重复添加');
console.warn(LOG_PREFIX, `列表"${listName}"已存在(${existingKey}),拒绝覆盖`);
return false;
}
// 生成位图字符串
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 keys = Object.keys(memory);
const newKey = 'l' + (keys.length + 1);
memory[newKey] = [listName, bitmap];
saveMemoryList(memory);
console.log(LOG_PREFIX, `MemoryList 已更新: ${newKey} -> ${listName}, 位图长度: ${bitmap.length}`);
return true;
}
// 从 MemoryList 获取当前列表的勾选ID(用于播放跳转)
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 bitmap = memory[matchedKey][1] || '';
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;
}
// 兼容旧版读取(已不再使用,但保留 fallback)
function getListForCurrentCollection() {
// 优先从 MemoryList 获取
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) {
// 仅保留兼容,实际保存走 MemoryList
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 parsePageVideoList() {
const container = document.querySelector('.video-pod__list');
if (!container) return [];
const items = container.querySelectorAll('.pod-item');
const videos = [];
items.forEach((item, index) => {
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;
}
// ========== 创建UI ==========
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;
right: 0;
top: 50%;
transform: translateY(-50%) translateX(92px);
z-index: 99998;
display: flex;
align-items: center;
justify-content: flex-start;
width: 120px;
height: 44px;
background: linear-gradient(135deg, #00a1d6, #0088b0);
border-radius: 8px 0 0 8px;
cursor: pointer;
user-select: none;
box-shadow: -3px 2px 12px rgba(0,0,0,0.2);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding-left: 10px;
box-sizing: border-box;
gap: 8px;
overflow: hidden;
}
#bili-force-trigger:hover {
transform: translateY(-50%) translateX(0);
}
#bili-force-trigger.panel-open {
transform: translateY(-50%) translateX(0);
}
#bili-force-trigger .trigger-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
#bili-force-trigger .trigger-text {
color: #fff;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
letter-spacing: 0.5px;
opacity: 0;
transition: opacity 0.25s ease 0.05s;
}
#bili-force-trigger:hover .trigger-text,
#bili-force-trigger.panel-open .trigger-text {
opacity: 1;
}
#bili-force-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.45);
z-index: 99997;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s ease;
}
#bili-force-overlay.active {
opacity: 1;
pointer-events: auto;
}
#bili-force-drawer {
position: fixed;
top: 0;
right: 0;
width: 440px;
max-width: 92vw;
height: 100%;
z-index: 99999;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.38s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -4px 0 30px rgba(0,0,0,0.5);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
#bili-force-drawer.active {
transform: translateX(0);
}
#bili-force-drawer .drawer-header {
flex-shrink: 0;
padding: 18px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
flex-direction: column;
gap: 12px;
}
#bili-force-drawer .drawer-header .header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
#bili-force-drawer .drawer-title {
font-size: 17px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
#bili-force-drawer .drawer-close {
width: 32px;
height: 32px;
border: none;
background: rgba(255,255,255,0.08);
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
line-height: 1;
}
#bili-force-drawer .drawer-close:hover {
background: rgba(255,255,255,0.18);
color: #fff;
}
#bili-force-drawer .header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
#bili-force-drawer .action-btn {
padding: 7px 15px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.05);
color: #ccc;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
#bili-force-drawer .action-btn:hover {
background: rgba(255,255,255,0.12);
color: #fff;
border-color: rgba(255,255,255,0.35);
}
#bili-force-drawer .action-btn.btn-save {
background: #00a1d6;
border-color: #00a1d6;
color: #fff;
font-weight: 600;
}
#bili-force-drawer .action-btn.btn-save:hover {
background: #00b8f0;
border-color: #00b8f0;
}
#bili-force-drawer .action-btn.btn-save.saved-flash {
background: #52c41a;
border-color: #52c41a;
}
#bili-force-drawer .list-info {
font-size: 11px;
color: #999;
padding: 0 4px;
}
#bili-force-drawer .list-info span {
color: #00a1d6;
font-weight: 600;
}
#bili-force-drawer .drawer-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
scroll-behavior: smooth;
}
#bili-force-drawer .drawer-body::-webkit-scrollbar {
width: 5px;
}
#bili-force-drawer .drawer-body::-webkit-scrollbar-track {
background: transparent;
}
#bili-force-drawer .drawer-body::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 10px;
}
#bili-force-drawer .drawer-body::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.25);
}
#bili-force-drawer .video-item {
display: flex;
align-items: center;
padding: 10px 20px;
cursor: pointer;
transition: background 0.15s;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,0.04);
min-height: 48px;
}
#bili-force-drawer .video-item:hover {
background: rgba(255,255,255,0.04);
}
#bili-force-drawer .video-item.sub-item {
padding-left: 50px;
background: rgba(255,255,255,0.015);
}
#bili-force-drawer .video-item.sub-item:hover {
background: rgba(255,255,255,0.05);
}
#bili-force-drawer .video-item .checkbox-wrap {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
#bili-force-drawer .video-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #00a1d6;
margin: 0;
}
#bili-force-drawer .video-item .video-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
#bili-force-drawer .video-item .video-title {
font-size: 13px;
color: #d0d0d0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#bili-force-drawer .video-item .video-meta {
font-size: 11px;
color: #777;
display: flex;
gap: 8px;
}
#bili-force-drawer .video-item .expand-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #888;
transition: transform 0.25s;
font-size: 12px;
cursor: pointer;
}
#bili-force-drawer .video-item .expand-icon.expanded {
transform: rotate(90deg);
}
#bili-force-drawer .video-item .bv-tag {
font-size: 10px;
color: #666;
background: rgba(255,255,255,0.05);
padding: 1px 6px;
border-radius: 3px;
flex-shrink: 0;
}
#bili-force-drawer .video-item.current-playing {
background: rgba(0,161,214,0.12);
border-left: 3px solid #00a1d6;
}
#bili-force-drawer .video-item.current-playing .video-title {
color: #00a1d6;
font-weight: 500;
}
#bili-force-drawer .drawer-footer {
flex-shrink: 0;
padding: 12px 20px;
border-top: 1px solid rgba(255,255,255,0.1);
display: flex;
gap: 8px;
}
#bili-force-drawer .drawer-footer .action-btn {
flex: 1;
text-align: center;
padding: 10px;
font-size: 13px;
}
#bili-force-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-120px);
z-index: 100000;
background: #333;
color: #fff;
padding: 10px 22px;
border-radius: 8px;
font-size: 14px;
pointer-events: none;
transition: transform 0.35s cubic-bezier(0.18, 0.89, 0.32, 1.05);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
#bili-force-toast.show {
transform: translateX(-50%) translateY(0);
}
`;
document.head.appendChild(style);
}
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);
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 = `
`;
document.body.appendChild(drawer);
}
return drawer;
}
// ========== 面板逻辑 ==========
let panelState = {
open: false,
videoData: [],
checkedSet: new Set(),
expandedMultis: new Set()
};
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 videoData = panelState.videoData;
let html = '';
if (videoData.length === 0) {
html = '当前页面未检测到合集视频列表
';
} else {
videoData.forEach((v, idx) => {
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}
${v.bv}
`;
if (isExpanded) {
v.subVideos.forEach((sv, sIdx) => {
const checked = panelState.checkedSet.has(sv.id);
const isCurrentSub = currentBv === sv.bv;
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}
${v.bv}
`;
}
});
}
body.innerHTML = html;
updateCheckedCount();
updateSaveButtonState();
}
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 updateSaveButtonState() {
const drawer = document.getElementById('bili-force-drawer');
if (!drawer) return;
const savedList = new Set(getListForCurrentCollection());
const currentSet = panelState.checkedSet;
const isChanged = savedList.size !== currentSet.size || ![...savedList].every(v => currentSet.has(v));
const saveBtns = drawer.querySelectorAll('.btn-save');
saveBtns.forEach(btn => {
if (isChanged) {
btn.textContent = '💾 保存列表';
btn.classList.remove('saved-flash');
btn.style.opacity = '1';
} else {
btn.textContent = '✅ 已保存';
btn.classList.add('saved-flash');
}
});
}
function collectCheckedIds() {
const drawer = document.getElementById('bili-force-drawer');
if (!drawer) return [];
const checkboxes = drawer.querySelectorAll('.drawer-body input[type="checkbox"]:checked');
const ids = [];
checkboxes.forEach(cb => {
const id = cb.getAttribute('data-id');
if (id && cb.getAttribute('data-is-parent') !== '1') {
ids.push(id);
}
});
return ids;
}
function openPanel() {
const trigger = document.getElementById('bili-force-trigger');
const drawer = document.getElementById('bili-force-drawer');
const overlay = document.getElementById('bili-force-overlay');
panelState.videoData = parsePageVideoList();
panelState.expandedMultis.clear();
// 优先从 MemoryList 恢复勾选状态
const listName = getListName();
const restored = restoreCheckedSetFromMemoryList(listName, panelState.videoData);
if (restored) {
panelState.checkedSet = restored;
console.log(LOG_PREFIX, `从 MemoryList 恢复勾选: ${listName} (${restored.size} 项)`);
} else {
// 回退旧方式
const savedList = getListForCurrentCollection();
panelState.checkedSet = new Set(savedList);
}
if (drawer) drawer.classList.add('active');
if (overlay) overlay.classList.add('active');
if (trigger) trigger.classList.add('panel-open');
panelState.open = true;
renderVideoList();
updateCheckedCount();
console.log(LOG_PREFIX, `面板已打开,检测到 ${panelState.videoData.length} 个视频(含多P分组)`);
}
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() {
if (panelState.open) {
closePanel();
} else {
openPanel();
}
}
function saveCurrentList() {
// 更新 MemoryList(含重复检查)
const success = updateMemoryListFromCheckedSet();
if (!success) return; // 提示已弹出
// 同步更新旧版存储(兼容,可省略)
const ids = [...panelState.checkedSet];
saveListForCurrentCollection(ids);
updateCheckedCount();
updateSaveButtonState();
showToast(`✅ 列表已保存!共 ${ids.length} 个视频`);
console.log(LOG_PREFIX, `保存列表: ${ids.length} 个条目`, ids);
checkAndHijack();
}
// ========== 事件绑定 ==========
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) {
drawer.addEventListener('click', (e) => {
const target = e.target;
if (target.closest('.drawer-close') || target.closest('.btn-close-panel')) {
closePanel();
return;
}
if (target.closest('.btn-save')) {
saveCurrentList();
if (target.closest('.btn-save-bottom')) {
setTimeout(closePanel, 400);
}
return;
}
// 全选
if (target.closest('.btn-select-all')) {
const allIds = [];
panelState.videoData.forEach(v => {
if (v.isMulti && v.subVideos.length > 0) {
v.subVideos.forEach(sv => allIds.push(sv.id));
} else {
allIds.push(v.id);
}
if (v.isMulti) panelState.expandedMultis.add(v.bv);
});
panelState.checkedSet = new Set(allIds);
renderVideoList();
updateCheckedCount();
updateSaveButtonState();
return;
}
// 反选
if (target.closest('.btn-invert')) {
const allIds = [];
panelState.videoData.forEach(v => {
if (v.isMulti && v.subVideos.length > 0) {
v.subVideos.forEach(sv => allIds.push(sv.id));
} else {
allIds.push(v.id);
}
if (v.isMulti) panelState.expandedMultis.add(v.bv);
});
const newSet = new Set(panelState.checkedSet);
allIds.forEach(id => {
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
});
panelState.checkedSet = newSet;
renderVideoList();
updateCheckedCount();
updateSaveButtonState();
return;
}
// 取消全选
if (target.closest('.btn-deselect-all')) {
panelState.videoData.forEach(v => {
if (v.isMulti) panelState.expandedMultis.add(v.bv);
});
panelState.checkedSet.clear();
renderVideoList();
updateCheckedCount();
updateSaveButtonState();
return;
}
// 多P展开/折叠
const expandIcon = target.closest('.expand-icon');
if (expandIcon) {
const parentItem = expandIcon.closest('.video-item');
if (parentItem) {
const bv = parentItem.getAttribute('data-bv');
if (bv) {
if (panelState.expandedMultis.has(bv)) {
panelState.expandedMultis.delete(bv);
} else {
panelState.expandedMultis.add(bv);
}
renderVideoList();
}
}
return;
}
// 多P父级行点击(非复选框区域)
const multiParent = target.closest('.multi-parent');
if (multiParent && !target.closest('input[type="checkbox"]') && !target.closest('.expand-icon')) {
const bv = multiParent.getAttribute('data-bv');
if (bv) {
if (panelState.expandedMultis.has(bv)) {
panelState.expandedMultis.delete(bv);
} else {
panelState.expandedMultis.add(bv);
}
renderVideoList();
}
return;
}
// 复选框变化
if (target.closest('input[type="checkbox"]')) {
setTimeout(() => {
panelState.checkedSet = new Set(collectCheckedIds());
updateCheckedCount();
updateSaveButtonState();
const allParentCbs = drawer.querySelectorAll('input[data-is-parent="1"]');
allParentCbs.forEach(pCb => {
const bv = pCb.getAttribute('data-id');
const subCbs = drawer.querySelectorAll(`input[data-id^="${bv}::"]`);
if (subCbs.length > 0) {
const allChecked = Array.from(subCbs).every(c => c.checked);
const anyChecked = Array.from(subCbs).some(c => c.checked);
pCb.checked = allChecked;
pCb.indeterminate = anyChecked && !allChecked;
}
});
}, 50);
return;
}
// 点击视频项跳转(非复选框、非展开图标)
const videoItem = target.closest('.video-item');
if (videoItem && !target.closest('input[type="checkbox"]') && !target.closest('.expand-icon') && !target.closest('.multi-parent')) {
const bv = videoItem.getAttribute('data-bv');
const pIndex = videoItem.getAttribute('data-pindex');
if (bv) {
navigateToVideo(bv, pIndex);
setTimeout(closePanel, 600);
}
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panelState.open) {
closePanel();
}
});
}
// ========== 视频跳转(URL导航,支持分P) ==========
function navigateToVideo(bv, pIndex) {
if (!bv) return;
let newPath = location.pathname.replace(/BV[0-9A-Za-z]+/, bv);
let search = location.search;
if (pIndex !== null && pIndex !== undefined) {
const params = new URLSearchParams(search);
params.set('p', pIndex + 1);
search = '?' + params.toString();
} else {
const params = new URLSearchParams(search);
params.delete('p');
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'));
console.log(LOG_PREFIX, `URL导航到: ${bv}${pIndex !== null ? ' P' + (parseInt(pIndex) + 1) : ''}`);
}
// ========== 可见性劫持 ==========
function hijackVisibility() {
try {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false, set: undefined });
} catch (e) {}
try {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible', set: undefined });
} catch (e) {}
const blockEvent = (e) => e.stopImmediatePropagation();
document.addEventListener('visibilitychange', blockEvent, true);
window.addEventListener('visibilitychange', blockEvent, true);
}
function checkAndHijack() {
const bv = getCurrentBVID();
if (!bv) return;
const allCheckedIds = [];
// 从 MemoryList 检查当前视频是否在任意列表中
const memory = getMemoryList();
const videoData = panelState.videoData.length ? panelState.videoData : parsePageVideoList();
for (const key in memory) {
const bitmap = memory[key][1] || '';
let idx = 0;
for (const v of videoData) {
if (v.isMulti && v.subVideos.length > 0) {
for (const sv of v.subVideos) {
if (sv.bv === bv && idx < bitmap.length && bitmap[idx] === '1') {
allCheckedIds.push(sv.id);
}
idx++;
}
} else {
if (v.bv === bv && idx < bitmap.length && bitmap[idx] === '1') {
allCheckedIds.push(v.id);
}
idx++;
}
}
}
if (allCheckedIds.length > 0) {
hijackVisibility();
console.log(LOG_PREFIX, `当前视频 ${bv} 在 MemoryList 中,已开启强制画面加载`);
}
}
// ========== 视频结束监听(全局捕获) ==========
function setupVideoEndListener() {
document.addEventListener('ended', onVideoEnded, true);
console.log(LOG_PREFIX, '全局视频结束监听已启动');
}
function onVideoEnded(e) {
const video = e.target;
if (!video.closest('#bilibili-player') && !video.closest('.bpx-player-video-wrap')) return;
const currentBv = getCurrentBVID();
if (!currentBv) return;
const list = getListForCurrentCollection();
if (list.length === 0) {
console.log(LOG_PREFIX, '未找到当前合集的播放列表,跳过自动连播');
return;
}
const pureCurrentBv = currentBv;
let currentIndex = -1;
for (let i = 0; i < list.length; i++) {
const listBv = list[i].includes('::') ? list[i].split('::')[0] : list[i];
if (listBv === pureCurrentBv) {
currentIndex = i;
break;
}
}
if (currentIndex >= 0 && currentIndex < list.length - 1) {
e.stopImmediatePropagation();
e.preventDefault();
const nextId = list[currentIndex + 1];
const nextBv = nextId.includes('::') ? nextId.split('::')[0] : nextId;
const nextPIndex = nextId.includes('::') ? parseInt(nextId.split('::')[1]) : null;
console.log(LOG_PREFIX, `视频结束,脚本列表跳转: ${currentBv} → ${nextId}`);
setTimeout(() => {
navigateToVideo(nextBv, nextPIndex);
}, 300);
} else if (currentIndex >= 0 && currentIndex >= list.length - 1) {
console.log(LOG_PREFIX, '脚本列表已播放完毕');
showToast('📋 脚本列表已播放完毕');
}
}
// ========== 初始化 ==========
function init() {
injectStyles();
function onBodyReady() {
createTriggerButton();
createOverlay();
createDrawer();
bindEvents();
checkAndHijack();
setupVideoEndListener();
console.log(LOG_PREFIX, '侧边抽屉面板已就绪 (MemoryList 模式)');
}
function waitForBody() {
if (document.body) {
onBodyReady();
} else {
setTimeout(waitForBody, 50);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForBody);
} else {
waitForBody();
}
let bodyWatchInitialized = false;
function initBodyWatch() {
if (bodyWatchInitialized || !document.body) return;
bodyWatchInitialized = true;
const bodyObserver = new MutationObserver(() => {
const trigger = document.getElementById('bili-force-trigger');
const drawer = document.getElementById('bili-force-drawer');
const overlay = document.getElementById('bili-force-overlay');
if ((!trigger || !document.body.contains(trigger)) && document.body) {
createTriggerButton();
bindEvents();
}
if ((!drawer || !document.body.contains(drawer)) && document.body) {
createDrawer();
bindEvents();
}
if ((!overlay || !document.body.contains(overlay)) && document.body) {
createOverlay();
}
});
bodyObserver.observe(document.body, { childList: true });
}
const waitForBodyObserver = setInterval(() => {
if (document.body) {
initBodyWatch();
clearInterval(waitForBodyObserver);
}
}, 100);
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (lastUrl !== location.href) {
lastUrl = location.href;
setTimeout(() => {
checkAndHijack();
if (panelState.open) {
panelState.videoData = parsePageVideoList();
panelState.expandedMultis.clear();
const listName = getListName();
const restored = restoreCheckedSetFromMemoryList(listName, panelState.videoData);
panelState.checkedSet = restored || new Set(getListForCurrentCollection());
renderVideoList();
updateCheckedCount();
}
}, 1200);
}
});
const waitForUrlObserver = setInterval(() => {
if (document.body) {
urlObserver.observe(document.body, { subtree: true, childList: true });
clearInterval(waitForUrlObserver);
}
}, 100);
}
// ========== 菜单命令 ==========
GM_registerMenuCommand('📋 打开列表管理面板', () => {
if (!panelState.open) {
openPanel();
}
showToast('面板已打开');
});
GM_registerMenuCommand('🗑 清除当前合集列表', () => {
const listName = getListName();
const memory = getMemoryList();
let removed = false;
for (const key in memory) {
if (memory[key] && memory[key][0] === listName) {
delete memory[key];
removed = true;
break;
}
}
if (removed) {
saveMemoryList(memory);
panelState.checkedSet.clear();
if (panelState.open) {
renderVideoList();
updateCheckedCount();
updateSaveButtonState();
}
GM_notification({ text: `已清除列表"${listName}"`, timeout: 2500, title: '列表管理' });
showToast('🗑 列表已清除');
} else {
GM_notification({ text: '未找到当前合集列表', timeout: 2000, title: '列表管理' });
}
});
GM_registerMenuCommand('📊 查看所有 MemoryList(控制台)', () => {
const memory = getMemoryList();
const keys = Object.keys(memory);
console.log(LOG_PREFIX, 'MemoryList 内容:');
if (keys.length === 0) {
console.log(' 空');
} else {
keys.forEach(key => {
console.log(` ${key}: ${memory[key][0]} -> 位图长度 ${memory[key][1].length}`);
});
}
GM_notification({ text: `共 ${keys.length} 个列表,详情见控制台`, timeout: 3000, title: '列表管理' });
});
GM_registerMenuCommand('🗑 清除所有 MemoryList', () => {
const memory = getMemoryList();
const count = Object.keys(memory).length;
if (count === 0) {
GM_notification({ text: '列表已为空', timeout: 2000, title: '列表管理' });
} else {
saveMemoryList({});
panelState.checkedSet.clear();
if (panelState.open) {
renderVideoList();
updateCheckedCount();
updateSaveButtonState();
}
GM_notification({ text: `已清除 ${count} 个列表`, timeout: 3000, title: '列表管理' });
showToast('🗑 所有列表已清除');
}
});
init();
})();