/** * 顶部视频抽屉 UI 库 * 纯 JavaScript 实现,不依赖任何 GM_* API,兼容 Chrome / Edge / Safari 等浏览器。 * @module TopVideoDrawerUI */ (function (root, factory) { 'use strict'; if (typeof module === 'object' && typeof module.exports === 'object') { // CommonJS module.exports = factory(); } else if (typeof define === 'function' && define.amd) { // AMD define([], factory); } else { // 浏览器全局变量 root.TopVideoDrawer = factory(); } })(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this, function () { 'use strict'; /** * 默认配置 */ const DEFAULT_CONFIG = { defaultHeight: '40vh', autoHeightMax: '100vh', autoHeightMin: '180px', transitionDuration: '0.35s', debug: true, storageKey: 'tvd-autoheight', // 是否启用视频区最大高度(开启:视频区最高 100vh;关闭:最高默认高度 40vh),默认开启 autoHeightEnabled: true, // 是否启用视频多开,默认关闭;开启后每次 play 新增一个视频,最多同时播放 maxVideos 个 multiVideoEnabled: false, // 多开模式下最多同时播放的视频数量;null = 按设备方向自动(竖屏 2 个,宽屏 4 个) maxVideos: null, // 是否启用视频右侧操作栏,默认关闭 videoActionsEnabled: false, // 是否自动播放:开启时 play() 调用与展开面板会自动开始播放;关闭时仅加载视频,需手动播放 autoPlayEnabled: true, // 是否循环播放:开启时视频播放完毕后自动重播 loopEnabled: false, // 脚本信息(帮助页展示:名称、版本、说明、通知等),也可通过 setScriptInfo() 动态传入 // 格式:{ name: '', version: '', description: '', notifications: [{ date: '', text: '' }] } scriptInfo: null, // 收藏按钮点击回调,参数为 videoInfo onLike: null, // 下载按钮点击回调,参数为 videoInfo onDownload: null, // Hls 类,由调用方传入(例如 hls.js 的 Hls),Safari 原生支持 HLS 时可不传 Hls: null, // Hls.js 配置 hlsConfig: { debug: false, enableWorker: true, lowLatencyMode: false, }, }; /** * 深度合并对象(仅处理纯对象) * @param {Object} target * @param {Object} source * @returns {Object} */ function mergeOptions(target, source) { const result = Object.assign({}, target); if (!source || typeof source !== 'object') return result; for (const key of Object.keys(source)) { const srcVal = source[key]; if (srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal)) { result[key] = mergeOptions(result[key] || {}, srcVal); } else if (srcVal !== undefined) { result[key] = srcVal; } } return result; } /** * 安全读写 localStorage,兼容隐私模式等异常情况 */ const safeStorage = { get(key, defaultValue) { try { const raw = localStorage.getItem(key); return raw === null ? defaultValue : JSON.parse(raw); } catch (e) { return defaultValue; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { return false; } }, }; /** * 解析 CSS 长度值为像素值 * @param {string|number} value * @param {number} base * @returns {number} */ function parseCssLength(value, base) { if (typeof value !== 'string') return Number(value) || 0; const str = value.trim(); if (str.endsWith('px')) return parseFloat(str); if (str.endsWith('vh')) return (parseFloat(str) / 100) * base; if (str.endsWith('%')) return (parseFloat(str) / 100) * base; return parseFloat(str); } /** * UI 库版本号 * 对外暴露在 TopVideoDrawer.VERSION(静态)与实例的 drawer.version 上, * 便于宿主脚本读取并在日志/帮助页展示,便于排查不同构建之间的差异。 */ const LIB_VERSION = '1.0.1'; class TopVideoDrawer { /** * 构造函数 * @param {Object} options - 配置项 * @param {string} [options.videoUrl] - 视频地址(m3u8 或其他浏览器支持格式) * @param {string} [options.defaultHeight='40vh'] - 抽屉默认高度(也是最大高度关闭时视频区最高高度) * @param {string} [options.autoHeightMax='100vh'] - 最大高度开启时视频区最高高度 * @param {string} [options.autoHeightMin='180px'] - 视频区高度下限 * @param {string} [options.transitionDuration='0.35s'] - 动画时长 * @param {boolean} [options.debug=true] - 是否输出调试日志 * @param {string} [options.storageKey='tvd-autoheight'] - localStorage 键名 * @param {Object} [options.Hls] - hls.js 的 Hls 类,传入后可播放 m3u8 * @param {Object} [options.hlsConfig] - Hls.js 额外配置 * @param {Object} [options.scriptInfo] - 脚本信息(帮助页展示),也可通过 setScriptInfo() 动态更新 * @param {string} [options.scriptInfo.name] - 脚本名称 * @param {string} [options.scriptInfo.version] - 版本号 * @param {string} [options.scriptInfo.description] - 脚本说明 * @param {Array<{date:string,text:string}>} [options.scriptInfo.notifications] - 通知列表 */ constructor(options) { this.config = mergeOptions(DEFAULT_CONFIG, options || {}); this.LOG_PREFIX = '[TopVideoDrawer]'; // UI 库版本(与静态 TopVideoDrawer.VERSION 一致) this.version = LIB_VERSION; this.panel = null; this.floatToggle = null; this.videosContainer = null; this.videos = []; this.statusEl = null; this.settingsBtn = null; this.isCollapsed = true; // 公共信息区当前激活页:'history' | 'settings' | 'help' | null(收起) this.activeInfoPage = null; // 脚本信息(帮助页展示),可由外部脚本通过配置或 setScriptInfo() 传入 this.scriptInfo = Object.assign( { name: '', version: '', description: '', notifications: [] }, this.config.scriptInfo || {} ); // 优先使用 localStorage 中用户手动切换的状态,无记录时使用配置的默认值 this.isAutoHeight = safeStorage.get(this.config.storageKey, this.config.autoHeightEnabled); this.isMultiVideo = safeStorage.get('tvd-multi', this.config.multiVideoEnabled); this.isActionsEnabled = safeStorage.get('tvd-actions', this.config.videoActionsEnabled); this.isAutoPlay = safeStorage.get('tvd-autoplay', this.config.autoPlayEnabled); this.isLoop = safeStorage.get('tvd-loop', this.config.loopEnabled); this.isDebug = safeStorage.get('tvd-debug', this.config.debug); this.historyList = []; // 日志缓冲区(调试开启时收集日志条目,供日志页展示) this._logBuffer = []; // 历史列表过滤:'all' | 'liked' this.historyFilter = 'all'; this.favoriteList = []; this.resizeHandler = null; this.init(); } /** * 主视频(第一个视频),供最大高度等逻辑使用 */ get video() { return this.videos[0] || null; } log(...args) { if (!this.isDebug) return; //console.log(this.LOG_PREFIX, ...args); const entry = { time: new Date(), message: args.map(a => { if (a instanceof Error) return a.message; if (a && typeof a === 'object') { try { return JSON.stringify(a); } catch { return String(a); } } return String(a); }).join(' '), }; this._logBuffer.push(entry); if (this._logBuffer.length > 500) this._logBuffer.shift(); if (this._isInfoOpen() && this.activeInfoPage === 'logs') { this._appendLogEntry(entry); } } /** * 对外日志接口:供宿主脚本(外部用户脚本)将诊断信息写入抽屉「日志」页。 * * 与内部 log() 的区别: * - 内部 log() 受「设置 → 调试日志」开关控制,关闭时不收集、不输出; * - addLog() 始终收集并输出(无论调试开关状态),便于外部脚本在不开启 * 内部调试的情况下,仍把关键事件推送到日志页集中排查。 * * @param {...any} args - 任意日志内容(对象会被 JSON 序列化,Error 取 message) * * @example * const drawer = new TopVideoDrawer({...}); * drawer.addLog('视频加载失败', url, err); * window.addEventListener('error', (e) => drawer.addLog('全局错误', e.message)); */ addLog(...args) { const message = args.map(a => { if (a instanceof Error) return a.message; if (a && typeof a === 'object') { try { return JSON.stringify(a); } catch { return String(a); } } return String(a); }).join(' '); const entry = { time: new Date(), external: true, message }; this._logBuffer.push(entry); if (this._logBuffer.length > 500) this._logBuffer.shift(); if (this._isInfoOpen() && this.activeInfoPage === 'logs') { this._appendLogEntry(entry); } } /** * 初始化:创建样式、DOM、事件 */ init() { this.injectStyles(); this.createDOM(); this.updateToggleState(); this.updateAutoHeightState(); this.updateMultiVideoState(); this.updateActionsState(); this.updateAutoPlayState(); this.updateLoopState(); this.updateDebugState(); this._syncPanelMode(); this.showStatus('⚙=设置 ▼=收起'); this.log('顶部视频抽屉已初始化'); } /** * 注入样式(使用标准 style 标签,兼容无 GM_addStyle 的环境) */ injectStyles() { const styleId = 'tvd-ui-styles'; if (document.getElementById(styleId)) return; const inject = () => { if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` :root { --tvd-bg: rgba(20, 20, 25, 0.96); --tvd-text: #ffffff; --tvd-accent: #00d4ff; --tvd-transition: ${this.config.transitionDuration} cubic-bezier(0.25, 0.8, 0.25, 1); } #tvd-panel { position: fixed; top: 0; left: 0; width: 100%; height: ${this.config.defaultHeight}; display: flex; flex-direction: column; background: var(--tvd-bg); color: var(--tvd-text); z-index: 2147483646; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); transform: translateY(0); transition: height var(--tvd-transition), transform var(--tvd-transition), opacity var(--tvd-transition); overflow: hidden; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } #tvd-panel.tvd-collapsed { transform: translateY(-100%); opacity: 0; pointer-events: none; } .tvd-btn { position: absolute; top: 10px; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 8px; background: rgba(30, 30, 35, 0.85); color: var(--tvd-text); font-size: 14px; line-height: 1; cursor: pointer; transition: background 0.2s, transform 0.15s, box-shadow 0.2s; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index: 2; } .tvd-btn:hover { background: rgba(50, 50, 58, 0.95); } .tvd-btn:active { transform: scale(0.94); } .tvd-btn.tvd-active { background: var(--tvd-accent); color: #000; } #tvd-settings { right: 50px; font-size: 14px; } #tvd-float-toggle { position: fixed; top: 10px; right: 10px; width: 32px; height: 32px; border-radius: 8px; z-index: 2147483647; display: inline-flex; align-items: center; justify-content: center; background: rgba(30, 30, 35, 0.85); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--tvd-text); font-size: 14px; line-height: 1; cursor: pointer; transition: background 0.2s, transform 0.15s; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } #tvd-float-toggle:hover { background: rgba(50, 50, 58, 0.95); } #tvd-float-toggle:active { transform: scale(0.94); } /* 视频容器:根据视频数量与布局模式切换网格 */ #tvd-videos { position: relative; top: 0; left: 0; width: 100%; flex: 1; min-height: 0; display: grid; gap: 2px; background: #000; } /* 无视频时播放区隐藏,不占高度 */ #tvd-videos[data-count="0"] { display: none; } /* 面板空置(无视频且历史收起)时压缩为按钮条高度 */ #tvd-panel.tvd-no-videos { height: 52px !important; } /* 公共信息区展开时空置面板恢复整屏 */ #tvd-panel.tvd-no-videos.tvd-info-open { height: 100vh !important; } /* 视频区隐藏时,信息区顶部让出右上角按钮组的高度(52px 按钮条),避免页签被遮挡 */ #tvd-panel.tvd-no-videos.tvd-info-open #tvd-info-section { margin-top: 52px; } #tvd-videos[data-count="1"] { grid-template-columns: 1fr; } #tvd-videos[data-count="2"] { grid-template-columns: repeat(2, 1fr); } /* grid 模式:2×2 */ #tvd-videos[data-layout="grid"][data-count="3"], #tvd-videos[data-layout="grid"][data-count="4"] { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); } /* 宽屏横排模式(最大高度关闭时的 CSS 兜底,正常由 JS 内联覆盖) */ #tvd-videos[data-layout="row"][data-count="3"] { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr; } #tvd-videos[data-layout="row"][data-count="4"] { grid-template-columns: repeat(4, 1fr); grid-template-rows: 1fr; } /* 视频包裹容器 */ .tvd-video-wrapper { position: relative; width: 100%; height: 100%; min-height: 0; overflow: hidden; } /* 视频标题 */ .tvd-video-title { position: absolute; top: 8px; left: 8px; background: rgba(0, 0, 0, 0.65); color: #fff; padding: 4px 10px; border-radius: 4px; font-size: 13px; font-weight: 500; line-height: 1.4; max-width: 80%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; z-index: 2; display: none; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .tvd-video-wrapper.tvd-video-paused .tvd-video-title { display: block; } .tvd-video-item { width: 100%; height: 100%; min-height: 0; object-fit: contain; background: #000; } /* 视频右侧操作栏(类似抖音) */ .tvd-video-actions { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: none; flex-direction: column; gap: 10px; z-index: 3; } .tvd-video-actions.tvd-visible { display: flex; } .tvd-action-icon { width: 36px; height: 36px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.2s, transform 0.15s; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); padding: 0; line-height: 1; } .tvd-action-icon:hover { background: rgba(0, 0, 0, 0.75); transform: scale(1.1); } .tvd-action-icon:active { transform: scale(0.95); } #tvd-status { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); padding: 6px 14px; border-radius: 20px; background: rgba(0, 0, 0, 0.55); font-size: 12px; color: #ccc; pointer-events: none; opacity: 0; transition: opacity 0.25s; z-index: 1; } #tvd-status.tvd-visible { opacity: 1; } `; (document.head || document.body || document.documentElement).appendChild(style); }; if (document.head || document.body) { inject(); } else { document.addEventListener('DOMContentLoaded', inject, { once: true }); } } /** * 创建 DOM 元素 */ createDOM() { const build = () => { this.floatToggle = document.createElement('button'); this.floatToggle.id = 'tvd-float-toggle'; this.floatToggle.title = '收起'; this.floatToggle.textContent = '▼'; this.panel = document.createElement('div'); this.panel.id = 'tvd-panel'; this.panel.innerHTML = `
播放历史
`; document.body.appendChild(this.panel); document.body.appendChild(this.floatToggle); this.cacheElements(); this.bindDOMEvents(); // 预创建公共信息区(页签 + 历史/设置/帮助页面),避免首次点击时才构建 DOM 造成迟钝 this._ensureInfoPanel(); }; if (document.body) { build(); } else { document.addEventListener('DOMContentLoaded', build, { once: true }); } } cacheElements() { this.settingsBtn = this.panel.querySelector('#tvd-settings'); this.videosContainer = this.panel.querySelector('#tvd-videos'); this.infoSection = this.panel.querySelector('#tvd-info-section'); this.statusEl = this.panel.querySelector('#tvd-status'); } bindDOMEvents() { this.floatToggle.addEventListener('click', () => this.toggle()); this.settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleSettings(); }); this.resizeHandler = () => { if (!this.isCollapsed) { // 设备方向变化可能改变多开上限(竖屏 2 / 宽屏 4),先裁剪多余视频 this._trimVideosToMax(); this._updateVideosLayout(); this.applyPanelHeight(); } }; window.addEventListener('resize', this.resizeHandler); // 页面隐藏/关闭前,保存所有视频的播放进度并更新预览图 window.addEventListener('pagehide', () => { this.videos.forEach(v => { this._saveProgress(v); this._captureAndSaveFrame(v); }); }); } /** * 创建一个新的 video 元素并加入容器 * @returns {HTMLVideoElement} */ _createVideo() { const wrapper = document.createElement('div'); wrapper.className = 'tvd-video-wrapper'; const video = document.createElement('video'); video.className = 'tvd-video-item'; video.muted = false; video.autoplay = this.isAutoPlay; video.controls = true; video.preload = 'auto'; video.setAttribute('playsinline', ''); video.setAttribute('crossorigin', 'anonymous'); video.addEventListener('loadedmetadata', () => { this.log('视频元数据:', video.videoWidth, 'x', video.videoHeight); // 视频宽高比已知后重新计算最优布局和面板高度(两种最大高度模式都需要) this._updateVideosLayout(); this.applyPanelHeight(); // 从上次播放进度继续 this._restoreProgress(video); }); // 记录播放进度(timeupdate 节流 + 暂停时) video.addEventListener('timeupdate', () => { const now = Date.now(); if (!video.__lastProgressSave || now - video.__lastProgressSave >= 5000) { video.__lastProgressSave = now; this._saveProgress(video); } }); video.addEventListener('pause', () => { this._saveProgress(video); wrapper.classList.add('tvd-video-paused'); }); video.addEventListener('play', () => { wrapper.classList.remove('tvd-video-paused'); }); video.addEventListener('ended', () => { // 播完后清除进度,下次从头播放 const info = video.__wrapper && video.__wrapper.__videoInfo; if (info) this._updateHistoryEntry(info.url || info.video_url, { progress: 0, position: 0 }); // 循环播放开启时自动重播 if (this.isLoop) { this.log('循环播放:重新播放'); const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((err) => this.log('循环重播失败:', err)); } } }); // 创建标题(当操作栏启用时显示) const title = document.createElement('div'); title.className = 'tvd-video-title'; title.textContent = ''; // 创建操作栏 const actions = this._createVideoActions(wrapper); wrapper.appendChild(title); wrapper.appendChild(video); wrapper.appendChild(actions); this.videosContainer.appendChild(wrapper); video.__wrapper = wrapper; this.videos.push(video); this._updateVideosLayout(); return video; } /** * 创建视频右侧操作栏 * @param {HTMLElement} wrapper - 视频包裹容器 * @returns {HTMLElement} */ _createVideoActions(wrapper) { const actions = document.createElement('div'); actions.className = 'tvd-video-actions'; if (this.isActionsEnabled) actions.classList.add('tvd-visible'); // 关闭按钮(在最上面) const closeBtn = document.createElement('button'); closeBtn.className = 'tvd-action-icon'; closeBtn.dataset.action = 'close'; closeBtn.textContent = '❌'; closeBtn.title = '关闭视频'; closeBtn.addEventListener('click', (e) => { e.stopPropagation(); // 找到对应的 video 元素 const video = wrapper.querySelector('.tvd-video-item'); if (video) { // 关闭前保存播放进度,并用当前画面更新预览图 this._saveProgress(video); this._captureAndSaveFrame(video); // 销毁 Hls 实例 this._destroyVideoHls(video); // 从 videos 数组中移除 const index = this.videos.indexOf(video); if (index > -1) { this.videos.splice(index, 1); } // 从 DOM 中移除 wrapper if (wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } // 更新布局 this._updateVideosLayout(); this.applyPanelHeight(); this.showStatus('视频已关闭'); } }); // 收藏按钮 const likeBtn = document.createElement('button'); likeBtn.className = 'tvd-action-icon'; likeBtn.dataset.action = 'like'; likeBtn.textContent = '🤍'; likeBtn.title = '收藏'; likeBtn.addEventListener('click', (e) => { e.stopPropagation(); const info = wrapper.__videoInfo; if (!info) return; // 加入喜欢时,用当前播放画面(重新)生成预览图 if (!this.isFavorite(info.url || info.video_url)) { const video = wrapper.querySelector('.tvd-video-item'); this._captureAndSaveFrame(video); } // 切换收藏状态,并同步历史列表与其他视频上的喜欢标记 this.toggleFavorite(info); // 如果配置了 onLike 回调,调用它(默认不再打开新标签页) if (typeof this.config.onLike === 'function') { this.config.onLike(info); } }); // 下载按钮 const dlBtn = document.createElement('button'); dlBtn.className = 'tvd-action-icon'; dlBtn.dataset.action = 'download'; dlBtn.textContent = '⏬'; dlBtn.title = '下载'; dlBtn.addEventListener('click', (e) => { e.stopPropagation(); const info = wrapper.__videoInfo; // 如果配置了 onDownload 回调,调用它 if (typeof this.config.onDownload === 'function' && info) { this.config.onDownload(info); } else { // 否则默认打开 URL const downurl = `https://tools.thatwind.com/tool/m3u8downloader#m3u8=${info.video_url}&referer=${info.url}&filename=${info.content}`; window.open(downurl, '_blank'); } }); // 打开原页面按钮 const openBtn = document.createElement('button'); openBtn.className = 'tvd-action-icon'; openBtn.dataset.action = 'open'; openBtn.textContent = '🌍'; openBtn.title = '打开原页面'; openBtn.addEventListener('click', (e) => { e.stopPropagation(); const info = wrapper.__videoInfo; if (info && info.url) window.open(info.url, '_blank'); }); actions.appendChild(closeBtn); actions.appendChild(likeBtn); actions.appendChild(dlBtn); actions.appendChild(openBtn); wrapper.__actionsEl = actions; return actions; } /** * 是否宽屏设备(宽 ≥ 高) */ _isWidescreen() { return window.innerWidth >= window.innerHeight; } /** * 视频区最大高度(px): * 最大高度开启时最高 autoHeightMax(100vh),关闭时最高默认高度(40vh) */ _getMaxVideosAreaHeight() { const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); if (this.isAutoHeight) { return Math.min(vh, parseCssLength(this.config.autoHeightMax, vh)); } return Math.min(vh, parseCssLength(this.config.defaultHeight, vh)); } /** * 最大高度关闭时视频区的固定高度 * 宽屏强制单行(高度随视频宽高比自适应,上限 40vh);竖屏按智能布局(≤ 40vh) * 该高度与历史开/关无关,保证开/关历史时视频区保持不变 */ _getOffVideoAreaHeight() { const layout = this._calculateOptimalLayout(null, this._isWidescreen()); return layout.height; } /** * 根据视频数量、设备方向与最大高度模式更新智能布局 * 始终基于视频区最大高度(开启 100vh / 关闭 40vh)计算最优排列: * - 最大高度关闭:视频区高度固定(宽屏强制单行),开/关历史不影响布局 * - 历史展开(最大高度开启):在压缩后的高度(>80vh 压至 50vh)内求最优 * - 其余:按自然高度求最优(不超过视频区最大高度) */ _updateVideosLayout() { if (!this.videosContainer) return; const count = this.videos.length; this.videosContainer.dataset.count = String(count); if (count === 0) { this.videosContainer.style.gridTemplateColumns = ''; this.videosContainer.style.gridTemplateRows = ''; this.videosContainer.dataset.layout = 'grid'; this._syncPanelMode(); return; } let layout; if (!this.isAutoHeight) { // 最大高度关闭:视频区高度固定(宽屏单行 / 竖屏智能),开/关历史不影响布局 layout = this._calculateOptimalLayout(null, this._isWidescreen()); } else if (this._isInfoOpen()) { // 信息区展开 + 最大高度开启:在压缩后的高度内求最优 layout = this._calculateOptimalLayout(this._infoVideosHeight(), false); } else { layout = this._calculateOptimalLayout(null, false); } this.videosContainer.dataset.layout = 'auto'; this.videosContainer.style.gridTemplateColumns = `repeat(${layout.cols}, 1fr)`; this.videosContainer.style.gridTemplateRows = `repeat(${layout.rows}, 1fr)`; this._syncPanelMode(); } /** * 计算最优布局 * 尝试 1~N 列(或仅单行方案),对每种方案计算面板高度和总视频显示面积,选择面积最大的方案 * 面板高度上限 = 视频区最大高度(最大高度开启 100vh,关闭 40vh) * @param {number} [availH] - 固定可用高度(px)。传入时在该高度内求最优排列,否则按自然高度计算 * @param {boolean} [singleRow=false] - 仅考虑单行方案(宽屏 + 最大高度关闭时强制单行) * @returns {{cols:number, rows:number, height:number, totalArea:number}} */ _calculateOptimalLayout(availH, singleRow) { const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); const count = this.videos.length; if (count === 0) return { cols: 1, rows: 1, height: 0, totalArea: 0 }; const maxPx = this._getMaxVideosAreaHeight(); const minPx = Math.max(100, parseCssLength(this.config.autoHeightMin, vh)); // 获取所有视频的宽高比 const aspects = this.videos.map(v => { if (v.videoWidth && v.videoHeight) return v.videoWidth / v.videoHeight; return 16 / 9; // 默认 16:9 }); const avgAspect = aspects.reduce((a, b) => a + b, 0) / aspects.length; let best = { cols: 1, rows: count, height: minPx, totalArea: 0 }; const colOptions = singleRow ? [count] : Array.from({ length: count }, (_, i) => i + 1); for (const cols of colOptions) { const rows = Math.ceil(count / cols); const cellW = vw / cols; let panelH; if (availH) { // 固定可用高度:在该高度内选择最优排列 panelH = Math.min(Math.max(availH, minPx), maxPx); } else { // 面板自然高度:让视频在格子中无黑边地展示 // panelH = rows × cellW / avgAspect panelH = (rows * cellW) / avgAspect; // 不超过视频区最大高度,不低于下限 if (panelH > maxPx) panelH = maxPx; if (panelH < minPx) panelH = minPx; } const cellH = panelH / rows; const cellAspect = cellW / cellH; // 计算每个视频在格子中的实际显示面积(object-fit: contain) let totalArea = 0; for (let i = 0; i < count; i++) { const a = aspects[i]; if (a > cellAspect) { // 视频比格子宽 → 宽度受限 totalArea += (cellW * cellW) / a; } else { // 视频比格子高 → 高度受限 totalArea += (cellH * cellH) * a; } } if (totalArea > best.totalArea) { best = { cols, rows, height: panelH, totalArea }; } } //this.log('最优布局:', best.cols, '列', best.rows, '行, 高度', best.height, 'px, 面积', Math.round(best.totalArea)); return best; } /** * 同步面板空置状态:无视频时面板压缩为按钮条高度(历史展开时除外) */ _syncPanelMode() { if (!this.panel) return; this.panel.classList.toggle('tvd-no-videos', this.videos.length === 0); } /** * 多开模式下最多同时播放的视频数量 * 未显式配置 maxVideos 时按设备方向自动:竖屏设备 2 个,宽屏设备 4 个 */ getMaxVideos() { const configured = this.config.maxVideos; if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); const isPortrait = window.innerHeight > window.innerWidth; return isPortrait ? 2 : 4; } /** * 裁剪超出多开上限的视频(设备方向变化时上限可能变小,如宽屏转竖屏 4 → 2) */ _trimVideosToMax() { const max = this.getMaxVideos(); while (this.videos.length > max) { const video = this.videos.pop(); this._saveProgress(video); this._captureAndSaveFrame(video); this._destroyVideoHls(video); const wrapper = video.__wrapper; if (wrapper && wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } } } /** * 持久化多开状态(开关位于公共信息区设置页) */ updateMultiVideoState() { safeStorage.set('tvd-multi', this.isMultiVideo); } toggleMultiVideo() { this.isMultiVideo = !this.isMultiVideo; this.updateMultiVideoState(); // 关闭多开时,移除多余的视频,只保留第一个 if (!this.isMultiVideo && this.videos.length > 1) { while (this.videos.length > 1) { const video = this.videos.pop(); this._destroyVideoHls(video); const wrapper = video.__wrapper; if (wrapper && wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } } this._updateVideosLayout(); this.applyPanelHeight(); this.showStatus('多开已关闭,保留第一个视频'); } else { this.showStatus(this.isMultiVideo ? '多开:开启' : '多开:关闭'); } } /** * 更新操作栏按钮状态与所有视频操作栏可见性 */ updateActionsState() { safeStorage.set('tvd-actions', this.isActionsEnabled); this._updateVideoActionsVisibility(); } toggleActions() { this.isActionsEnabled = !this.isActionsEnabled; this.updateActionsState(); this.showStatus(this.isActionsEnabled ? '操作栏:开启' : '操作栏:关闭'); } /** * 持久化自动播放状态(开关位于公共信息区设置页) */ updateAutoPlayState() { safeStorage.set('tvd-autoplay', this.isAutoPlay); } toggleAutoPlay() { this.isAutoPlay = !this.isAutoPlay; this.updateAutoPlayState(); if (this.isAutoPlay) { // 开启后恢复播放所有已加载的视频 this.videos.forEach((v) => { const playPromise = v.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((err) => this.log('恢复播放失败:', err)); } }); this.showStatus('自动播放:开启'); } else { // 关闭后暂停所有视频(仅加载,需手动播放) this.videos.forEach((v) => v.pause()); this.showStatus('自动播放:关闭(仅加载,需手动播放)'); } } /** * 持久化循环播放状态(开关位于公共信息区设置页) */ updateLoopState() { safeStorage.set('tvd-loop', this.isLoop); } toggleLoop() { this.isLoop = !this.isLoop; this.updateLoopState(); this.showStatus(this.isLoop ? '循环播放:开启(播完自动重播)' : '循环播放:关闭'); } /** * 持久化调试日志状态(开关位于公共信息区设置页) */ updateDebugState() { safeStorage.set('tvd-debug', this.isDebug); } toggleDebug() { this.isDebug = !this.isDebug; this.updateDebugState(); if (this.isDebug) { this.showStatus('调试日志:开启'); // 开启后若日志页正打开则立即渲染已有日志 if (this._isInfoOpen() && this.activeInfoPage === 'logs') this._renderLogsPage(); } else { this.showStatus('调试日志:关闭'); } } /** * 顶部入口按钮的统一开关:点击当前已激活页对应的按钮则收起信息区,否则展开对应页面 */ toggleHistory() { if (this._isInfoOpen() && this.activeInfoPage === 'history') { this.hideInfoPanel(); } else { this.loadHistoryList(); this._openInfoPage('history'); } } toggleSettings() { if (this._isInfoOpen()) { // 已展开:当前是设置页则收起,否则切换到设置页 if (this.activeInfoPage === 'settings') { this.hideInfoPanel(); } else { this._openInfoPage('settings'); } } else { // 未展开:打开公共信息区并定位到设置页(记忆上次页面) this._openInfoPage(this.activeInfoPage || 'settings'); } } toggleHelp() { if (this._isInfoOpen() && this.activeInfoPage === 'help') { this.hideInfoPanel(); } else { this._openInfoPage('help'); } } /** * 加载播放历史列表 */ loadHistoryList() { try { const stored = safeStorage.get('tvd-history-list', []); this.historyList = Array.isArray(stored) ? stored : []; this._historyLoaded = true; // 确保是对象数组格式 this.historyList = this.historyList.map(item => { if (typeof item === 'string') { return { url: item, content: `历史视频 ${this.historyList.indexOf(item) + 1}`, video_url: item, image_url: '', timestamp: Date.now(), liked: false }; } return item; }); this.log('加载历史记录:', this.historyList.length, '条'); } catch (e) { this.log('加载历史记录失败:', e); this.historyList = []; this._historyLoaded = true; } } /** * 加载喜欢列表 */ loadFavoriteList() { try { const stored = safeStorage.get('tvd-favorite-list', []); this.favoriteList = Array.isArray(stored) ? stored : []; // 确保是对象数组格式 this.favoriteList = this.favoriteList.map(item => { if (typeof item === 'string') { return { url: item, content: `收藏视频 ${this.favoriteList.indexOf(item) + 1}`, video_url: item, image_url: '', timestamp: Date.now(), liked: true }; } return item; }); this.log('加载收藏列表:', this.favoriteList.length, '条'); } catch (e) { this.log('加载收藏列表失败:', e); this.favoriteList = []; } this._favoritesLoaded = true; } /** * 保存播放历史 */ saveHistory(videoInfo) { if (!videoInfo) return; // play() 要求 video_url,兼容只传 url 或只传 video_url 的调用方 const videoUrl = videoInfo.video_url || videoInfo.url; if (!videoUrl) return; // 懒加载:避免空列表覆盖存储中已有的历史记录 if (!this._historyLoaded) { this.loadHistoryList(); } // 去重:同一视频重复播放时,移到列表顶部并更新时间戳 const existingIndex = this.historyList.findIndex( (h) => h.video_url === videoUrl || h.url === videoUrl ); if (existingIndex !== -1) { const [existing] = this.historyList.splice(existingIndex, 1); existing.timestamp = Date.now(); if (videoInfo.content) existing.content = videoInfo.content; if (videoInfo.image_url) existing.image_url = videoInfo.image_url; this.historyList.unshift(existing); this.saveHistoryList(); this.log('更新播放历史:', existing.content); return; } const historyItem = { url: videoInfo.url || videoUrl, content: videoInfo.content || `历史视频 ${this.historyList.length + 1}`, video_url: videoUrl, image_url: videoInfo.image_url || '', timestamp: Date.now(), liked: false }; // 添加到历史列表开头 this.historyList.unshift(historyItem); // 限制历史记录数量(已喜欢的条目永久保留,仅裁剪未喜欢的) if (this.historyList.length > 50) { this.historyList = this.historyList.filter( (h, i) => i < 50 || this.isFavorite(h.url || h.video_url) ); } this.saveHistoryList(); this.log('保存播放历史:', historyItem.content); // 若历史区域正展开,实时刷新列表 if (this._isHistoryOpen()) { this.updateHistoryList(); } } /** * 保存历史列表到存储 */ saveHistoryList() { safeStorage.set('tvd-history-list', this.historyList); } /** * 截取视频当前画面为 JPEG data URL(用于历史/收藏预览图) * 跨域视频可能导致 canvas 被污染,失败时返回 null */ _captureFrame(video) { try { if (!video || !video.videoWidth || !video.videoHeight) return null; const canvas = document.createElement('canvas'); const w = 160; const h = Math.max(1, Math.round(w * video.videoHeight / video.videoWidth)); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, w, h); return canvas.toDataURL('image/jpeg', 0.6); } catch (e) { this.log('截取视频画面失败:', e && e.message); return null; } } /** * 截取视频当前画面并保存为预览图(同步历史条目与收藏条目) * 适用场景:加入喜欢、视频关闭、视频被替换、页面关闭 */ _captureAndSaveFrame(video) { try { if (!video) return; const info = video.__wrapper && video.__wrapper.__videoInfo; if (!info) return; const frame = this._captureFrame(video); if (!frame) return; info.image_url = frame; // 同步历史条目(若面板打开会实时刷新) this._updateHistoryEntry(info.url || info.video_url, { image_url: frame }); // 同步收藏条目 if (!this._favoritesLoaded) this.loadFavoriteList(); const fav = this.favoriteList.find(f => f.url === (info.url || info.video_url)); if (fav && fav.image_url !== frame) { fav.image_url = frame; this.saveFavoriteList(); } } catch (e) { this.log('保存预览图失败:', e && e.message); } } /** * 更新历史条目的部分字段并持久化 * @param {string} url - 视频 URL(url 或 video_url) * @param {Object} patch - 要合并的字段 */ _updateHistoryEntry(url, patch) { if (!url || !patch) return; if (!this._historyLoaded) this.loadHistoryList(); const entry = this.historyList.find(h => h.url === url || h.video_url === url); if (!entry) return; Object.assign(entry, patch); this.saveHistoryList(); // 若历史区域正展开,实时刷新 if (this._isHistoryOpen()) { this.updateHistoryList(); } } /** * 保存视频当前播放进度到对应历史条目 */ _saveProgress(video) { const info = video.__wrapper && video.__wrapper.__videoInfo; if (!info) return; const url = info.url || info.video_url; const duration = video.duration; if (!url || !isFinite(duration) || duration <= 0) return; const progress = video.currentTime / duration; if (!isFinite(progress) || progress <= 0) return; this._updateHistoryEntry(url, { progress: Math.min(progress, 1), duration: Math.round(duration), position: Math.round(video.currentTime), }); } /** * 从历史条目恢复播放进度(接近播完的不续播) */ _restoreProgress(video) { try { const info = video.__wrapper && video.__wrapper.__videoInfo; if (!info) return; const url = info.url || info.video_url; if (!url) return; if (!this._historyLoaded) this.loadHistoryList(); const entry = this.historyList.find(h => h.url === url || h.video_url === url); if (!entry || !entry.progress || entry.progress >= 0.98) return; const duration = isFinite(video.duration) ? video.duration : entry.duration; const target = entry.position && entry.duration ? Math.min(entry.position, Math.max(entry.duration - 1, 0)) : entry.progress * duration; if (isFinite(target) && target > 1 && video.currentTime < 1) { video.currentTime = target; this.log('从上次进度继续播放:', Math.round(entry.progress * 100) + '%'); } } catch (e) { this.log('恢复播放进度失败:', e && e.message); } } /** * 格式化时长(秒 → mm:ss 或 h:mm:ss) */ _formatDuration(sec) { if (!isFinite(sec) || sec <= 0) return ''; sec = Math.round(sec); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const pad = n => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; } /** * 保存喜欢列表到存储 */ saveFavoriteList() { safeStorage.set('tvd-favorite-list', this.favoriteList); } /** * 切换喜欢状态 */ toggleFavorite(videoInfo) { if (!videoInfo) return; const url = videoInfo.url || videoInfo.video_url; if (!url) return; // 懒加载:确保收藏列表已从存储读取 if (!this._favoritesLoaded) this.loadFavoriteList(); const index = this.favoriteList.findIndex(item => item.url === url); if (index > -1) { // 如果已经喜欢,则取消喜欢 this.favoriteList[index].liked = false; this.favoriteList.splice(index, 1); this.showStatus('已取消收藏'); } else { // 添加到喜欢列表 const favoriteItem = { url: url, content: videoInfo.content || `收藏视频 ${this.favoriteList.length + 1}`, video_url: videoInfo.video_url || url, image_url: videoInfo.image_url || '', timestamp: Date.now(), liked: true }; this.favoriteList.push(favoriteItem); this.showStatus('已收藏'); } this.saveFavoriteList(); this.log('喜欢状态已更新,收藏列表:', this.favoriteList.length, '条'); // 双向同步:更新历史列表与播放区视频上的喜欢标记 this._syncLikeMarks(url); } /** * 同步所有界面上的喜欢标记(历史列表 + 播放中的视频操作栏) * @param {string} [url] - 仅同步该视频;不传则同步全部 */ _syncLikeMarks(url) { // 刷新历史列表(若展开) if (this._isHistoryOpen()) { this.updateHistoryList(); } // 刷新播放区各视频操作栏的喜欢按钮 this.videos.forEach(video => { const wrapper = video.__wrapper; const info = wrapper && wrapper.__videoInfo; if (!info) return; const infoUrl = info.url || info.video_url; if (url && infoUrl !== url) return; const likeBtn = wrapper.__actionsEl && wrapper.__actionsEl.querySelector('[data-action="like"]'); if (likeBtn) { likeBtn.textContent = this.isFavorite(infoUrl) ? '♥️' : '🤍'; } }); } /** * 根据视频URL检查是否已喜欢 */ isFavorite(url) { if (!url) return false; // 懒加载:确保收藏列表已从存储读取 if (!this._favoritesLoaded) this.loadFavoriteList(); return this.favoriteList.some(item => item.url === url); } /** * 确保公共信息区已创建并绑定事件(信息区与播放区同属主面板,以页签形式切换页面) * @returns {HTMLElement} */ _ensureInfoPanel() { let infoSection = this.infoSection || document.getElementById('tvd-info-section'); if (infoSection && infoSection.__tvdBound) return infoSection; if (!infoSection) { // 兜底:主面板中找不到信息区时手动补建 infoSection = document.createElement('div'); infoSection.id = 'tvd-info-section'; infoSection.innerHTML = `
播放历史
`; (this.panel || document.getElementById('tvd-panel')).appendChild(infoSection); this.infoSection = infoSection; } // 页签切换:仅切换页面,不收起信息区 infoSection.querySelectorAll('.tvd-tab-btn').forEach(btn => { btn.addEventListener('click', () => { this._openInfoPage(btn.dataset.page); }); }); // 历史页过滤按钮:全部 / 已喜欢 infoSection.querySelectorAll('.tvd-filter-btn').forEach(btn => { btn.addEventListener('click', () => { this.historyFilter = btn.dataset.filter; infoSection.querySelectorAll('.tvd-filter-btn').forEach(b => { b.classList.toggle('tvd-active', b === btn); }); this.updateHistoryList(); }); }); // 历史页清空按钮:仅清空未喜欢的记录 const clearBtn = infoSection.querySelector('.tvd-clear-btn'); if (clearBtn) { clearBtn.addEventListener('click', () => { this.clearHistory(); }); } // 信息区关闭按钮 const closeBtn = infoSection.querySelector('.tvd-info-close'); if (closeBtn) { closeBtn.addEventListener('click', () => { this.hideInfoPanel(); }); } infoSection.__tvdBound = true; // 添加样式 this.injectInfoStyles(); return infoSection; } /** * 公共信息区当前是否展开 */ _isInfoOpen() { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); return !!(infoSection && infoSection.classList.contains('tvd-info-open')); } /** * 历史页是否正展示(信息区展开且当前页为历史页),用于历史列表实时刷新 */ _isHistoryOpen() { return this._isInfoOpen() && this.activeInfoPage === 'history'; } /** * 在公共信息区展开指定页面(历史 / 设置 / 帮助) * @param {string} page - 'history' | 'settings' | 'help' */ _openInfoPage(page) { const infoSection = this._ensureInfoPanel(); this.activeInfoPage = page; // 页签与页面切换 infoSection.querySelectorAll('.tvd-tab-btn').forEach(btn => { btn.classList.toggle('tvd-active', btn.dataset.page === page); }); infoSection.querySelectorAll('.tvd-info-page').forEach(p => { p.classList.toggle('tvd-page-active', p.dataset.page === page); }); this._syncInfoButtons(); // 各页内容初始化 if (page === 'history') { if (!this._historyLoaded) this.loadHistoryList(); this.updateHistoryList(); } else if (page === 'settings') { this._renderSettingsPage(); } else if (page === 'help') { this._renderHelpPage(); } else if (page === 'logs') { this._renderLogsPage(); } // 展开信息区(与播放区同面板,仅切换 class,无 DOM 创建) infoSection.classList.add('tvd-info-open'); if (this.panel) this.panel.classList.add('tvd-info-open'); // 信息区展开时面板整体高度 100vh,视频区按压缩后高度重新智能布局 this._updateVideosLayout(); this.applyPanelHeight(); } /** * 同步右上角入口按钮的激活状态(当前展开页对应的按钮高亮) */ _syncInfoButtons() { if (this.settingsBtn) { this.settingsBtn.classList.toggle('tvd-active', this._isInfoOpen()); } } /** * 渲染设置页:视频操作栏 / 多开 / 最大高度 / 自动播放 / 循环播放五个开关 */ _renderSettingsPage() { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); const page = infoSection && infoSection.querySelector('.tvd-info-page[data-page="settings"]'); if (!page) return; const settings = [ { key: 'actions', name: '视频操作栏', desc: '在视频右侧显示关闭、收藏、下载、打开原页面按钮', on: this.isActionsEnabled, }, { key: 'multi', name: '视频多开', desc: `每次播放新增一个视频槽位,超出上限替换最早的视频(当前设备上限 ${this.getMaxVideos()} 个)`, on: this.isMultiVideo, }, { key: 'autoheight', name: '最大高度', desc: this.isAutoHeight ? '开启:视频区最高 100vh' : '关闭:视频区最高 40vh', on: this.isAutoHeight, }, { key: 'autoplay', name: '自动播放', desc: this.isAutoPlay ? '开启:play() 调用与展开面板时自动开始播放' : '关闭:仅加载视频,需手动点击播放', on: this.isAutoPlay, }, { key: 'loop', name: '循环播放', desc: this.isLoop ? '开启:视频播放完毕后自动重播' : '关闭:视频播放完毕后停止', on: this.isLoop, }, { key: 'debug', name: '调试日志', desc: this.isDebug ? '开启:运行日志输出到日志页与控制台' : '关闭:不收集调试日志', on: this.isDebug, }, ]; page.innerHTML = `
${settings.map(s => `
${s.name}
${s.desc}
`).join('')}
`; page.querySelectorAll('.tvd-setting-row').forEach(row => { row.addEventListener('click', () => { const key = row.dataset.setting; if (key === 'actions') this.toggleActions(); else if (key === 'multi') this.toggleMultiVideo(); else if (key === 'autoheight') this.toggleAutoHeight(); else if (key === 'autoplay') this.toggleAutoPlay(); else if (key === 'loop') this.toggleLoop(); else if (key === 'debug') this.toggleDebug(); // 立即刷新开关状态与描述文案(如最大高度说明、多开上限) this._renderSettingsPage(); }); }); } /** * 渲染帮助页:脚本名称、版本号、说明、通知(内容由外部脚本传入,未传时显示内置说明) */ _renderHelpPage() { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); const page = infoSection && infoSection.querySelector('.tvd-info-page[data-page="help"]'); if (!page) return; const info = this.scriptInfo || {}; const notifications = Array.isArray(info.notifications) ? info.notifications : []; const notifHtml = notifications.length ? `
通知
${notifications.map(n => `
${n.date ? `${n.date}` : ''} ${n.text || ''}
`).join('')}
` : ''; const desc = info.description || '顶部视频抽屉播放器:点击页面中的视频即可在顶部抽屉中播放,支持 HLS(m3u8)、多开、收藏、播放历史与断点续播。'; page.innerHTML = `
${info.name || '顶部视频抽屉'}
${info.version ? `
v${info.version}
` : ''}
${desc}
${notifHtml}
按钮说明
⚙ 设置 — 打开面板,内含「历史 / 设置 / 日志 / 帮助」四个页签
▶ 视频暂停时显示标题,继续播放后自动隐藏
✕ — 关闭信息区(在信息区右上角)
▼ / ▲ — 收起 / 展开面板
`; } /** * 渲染日志页:将已有的日志缓冲区条目输出到日志页,便于排查问题 * 调试未开启时提示用户开启 */ _renderLogsPage() { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); const page = infoSection && infoSection.querySelector('.tvd-info-page[data-page="logs"]'); if (!page) return; let body = page.querySelector('.tvd-logs-body'); if (!body) { body = document.createElement('div'); body.className = 'tvd-logs-body'; page.appendChild(body); } body.innerHTML = ''; // 顶部状态条:提示调试开关状态(外部 addLog 日志不受该开关限制) const banner = document.createElement('div'); banner.className = 'tvd-logs-banner'; banner.textContent = this.isDebug ? '调试日志:开启(内部运行日志已记录)' : '调试日志:关闭(仅显示外部 addLog 日志;开启后可见内部运行日志)'; body.appendChild(banner); if (this._logBuffer.length === 0) { const empty = document.createElement('div'); empty.className = 'tvd-logs-empty'; empty.textContent = '暂无日志'; body.appendChild(empty); return; } // 渲染全部已缓存的日志条目(含外部脚本通过 addLog 写入的日志) this._logBuffer.forEach((entry) => this._appendLogEntry(entry, false)); body.scrollTop = body.scrollHeight; } /** * 向日志页追加一条日志 * @param {{time:Date, message:string}} entry * @param {boolean} [autoScroll=true] - 是否自动滚动到底部 */ _appendLogEntry(entry, autoScroll = true) { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); const page = infoSection && infoSection.querySelector('.tvd-info-page[data-page="logs"]'); if (!page) return; let body = page.querySelector('.tvd-logs-body'); if (!body) { body = document.createElement('div'); body.className = 'tvd-logs-body'; page.appendChild(body); } const line = document.createElement('div'); line.className = 'tvd-log-line' + (entry.external ? ' tvd-log-external' : ''); const t = new Date(entry.time); const timeStr = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0') + ':' + String(t.getSeconds()).padStart(2, '0'); const timeSpan = document.createElement('span'); timeSpan.className = 'tvd-log-time'; timeSpan.textContent = timeStr; const msgSpan = document.createElement('span'); msgSpan.className = 'tvd-log-msg'; // 使用 textContent,避免日志内容中的特殊字符破坏布局 msgSpan.textContent = entry.message; line.appendChild(timeSpan); line.appendChild(msgSpan); body.appendChild(line); // 限制 DOM 行数,避免无限增长影响性能 while (body.children.length > 500) body.removeChild(body.firstChild); if (autoScroll) body.scrollTop = body.scrollHeight; } /** * 更新脚本信息(帮助页展示),供外部脚本异步获取后回调传入 * @param {Object} info - { name, version, description, notifications: [{date, text}] } */ setScriptInfo(info) { if (!info || typeof info !== 'object') return; this.scriptInfo = Object.assign( { name: '', version: '', description: '', notifications: [] }, this.scriptInfo, info ); if (this._isInfoOpen() && this.activeInfoPage === 'help') { this._renderHelpPage(); } } /** * 清空历史列表(已喜欢的视频永久保留) */ clearHistory() { if (!this._historyLoaded) this.loadHistoryList(); if (!this._favoritesLoaded) this.loadFavoriteList(); const keep = this.historyList.filter(h => this.isFavorite(h.url || h.video_url)); const removed = this.historyList.length - keep.length; if (removed === 0) { this.showStatus('没有可清空的记录(已喜欢的视频会保留)'); return; } this.historyList = keep; this.saveHistoryList(); this.updateHistoryList(); this.showStatus(`已清空 ${removed} 条记录,已喜欢的 ${keep.length} 条保留`); } /** * 收起公共信息区 * 最大高度开启:与展开做反向操作,恢复视频区自然高度(打开时压缩到 50vh 的在此还原); * 最大高度关闭:视频区高度固定(宽屏单行 / 智能布局),开/关信息区保持不变,无动画重绘 */ hideInfoPanel() { const infoSection = this.infoSection || document.getElementById('tvd-info-section'); if (infoSection) { infoSection.classList.remove('tvd-info-open'); } if (this.panel) this.panel.classList.remove('tvd-info-open'); this._syncInfoButtons(); if (this.videos.length > 0) { // 视频区固定为最终高度:开启时反向恢复自然高度;关闭时保持固定高度(不变) const targetH = this.isAutoHeight ? this._calculateOptimalLayout().height : this._getOffVideoAreaHeight(); this.videosContainer.style.flex = 'none'; this.videosContainer.style.height = `${Math.round(targetH)}px`; this.panel.dataset.autoheight = String(this.isAutoHeight); this.panel.style.height = `${Math.round(targetH)}px`; this._updateVideosLayout(); // 面板高度过渡结束后再清理视频区内联高度,恢复 flex 自适应 this._releaseVideosHeightAfterTransition(); } else { this.videosContainer.style.flex = ''; this.videosContainer.style.height = ''; this.applyPanelHeight(); } this._syncPanelMode(); } /** * 历史收起后延迟清理视频区的内联高度 * 等待面板高度过渡完成后再恢复 flex 自适应,避免视频区跟着面板动画缩放 */ _releaseVideosHeightAfterTransition() { clearTimeout(this._releaseTimer); const duration = (parseFloat(this.config.transitionDuration) || 0.35) * 1000; this._releaseTimer = setTimeout(() => { // 过渡期间又展开了信息区,则不清理 if (this._isInfoOpen() || !this.videosContainer) return; this.videosContainer.style.flex = ''; this.videosContainer.style.height = ''; }, duration + 100); } /** * 生成占位封面 data URI(encodeURIComponent 编码,可安全包含 emoji 等非 Latin1 字符) * 结果缓存,避免重复编码 */ _placeholderThumb() { if (!this.__placeholderThumb) { const svg = '' + '' + '📺' + ''; this.__placeholderThumb = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); } return this.__placeholderThumb; } /** * 更新历史列表 */ updateHistoryList() { const historyListEl = document.querySelector('.tvd-history-list'); if (!historyListEl) return; historyListEl.innerHTML = ''; // 过滤:'liked' 仅显示已喜欢的视频 const isLikedFilter = this.historyFilter === 'liked'; const items = isLikedFilter ? this.historyList.filter(h => this.isFavorite(h.url || h.video_url)) : this.historyList; if (items.length === 0) { historyListEl.innerHTML = `
${isLikedFilter ? '暂无已喜欢的视频' : '暂无播放历史'}
`; return; } items.forEach((item, index) => { // 进度信息:百分比 + 总时长 const progressPct = item.progress ? Math.min(100, Math.round(item.progress * 100)) : 0; const durationText = this._formatDuration(item.duration); const progressText = [ progressPct > 0 ? `已播 ${progressPct}%` : '', durationText ? `时长 ${durationText}` : '', ].filter(Boolean).join(' · '); const historyItem = document.createElement('div'); historyItem.className = 'tvd-history-item'; historyItem.title = item.content || ''; historyItem.innerHTML = `
封面 ${progressPct > 0 ? `
` : ''}
${item.content}
${this.formatTimestamp(item.timestamp)}${progressText ? ` · ${progressPct > 0 ? '▶' : ''}${progressText}` : ''}
`; // 封面:无图或加载失败时使用占位图(encodeURIComponent 编码,兼容非 Latin1 字符) const thumb = historyItem.querySelector('.tvd-history-thumbnail img'); thumb.onerror = () => { thumb.src = this._placeholderThumb(); }; thumb.src = item.image_url || this._placeholderThumb(); // 收藏按钮 const favoriteBtn = historyItem.querySelector('.tvd-favorite-btn'); favoriteBtn.addEventListener('click', (e) => { e.stopPropagation(); const videoUrl = favoriteBtn.dataset.url; const videoItem = this.historyList.find(h => h.url === videoUrl); if (videoItem) { this.toggleFavorite(videoItem); this.updateHistoryList(); } }); // 点击卡片任意位置即可播放 historyItem.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (e.target.closest('.tvd-favorite-btn')) return; const videoItem = this.historyList.find(h => h === item); if (videoItem) { this.play(videoItem); } }); historyListEl.appendChild(historyItem); }); } /** * 格式化时间戳 */ formatTimestamp(timestamp) { const date = new Date(timestamp); const now = new Date(); const diff = now - timestamp; if (diff < 3600000) { // 小于1小时,显示分钟 const minutes = Math.floor(diff / 60000); return minutes === 0 ? '刚刚' : `${minutes}分钟前`; } else if (diff < 86400000) { // 小于1天,显示小时 const hours = Math.floor(diff / 3600000); return `${hours}小时前`; } else { // 超过1天,显示日期 const month = date.getMonth() + 1; const day = date.getDate(); return `${month}月${day}日`; } } /** * 注入公共信息区样式(页签 + 历史/设置/帮助页面) */ injectInfoStyles() { if (document.getElementById('tvd-info-styles')) return; const style = document.createElement('style'); style.id = 'tvd-info-styles'; style.textContent = ` /* 公共信息区:位于主面板内、播放区下方,以页签形式承载历史/设置/帮助页面,默认收起 */ #tvd-info-section { display: none; flex-direction: column; flex: 1 1 auto; min-height: 0; border-top: 1px solid rgba(255, 255, 255, 0.12); background: rgba(0, 0, 0, 0.45); overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } #tvd-info-section.tvd-info-open { display: flex; animation: tvd-info-in 0.18s ease-out; } @keyframes tvd-info-in { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } /* 页签栏 */ .tvd-info-tabs { display: flex; align-items: center; gap: 4px; padding: 6px 12px 0; flex: 0 0 auto; } .tvd-tab-btn { padding: 6px 16px; border: none; border-bottom: 2px solid transparent; border-radius: 8px 8px 0 0; background: transparent; color: rgba(255, 255, 255, 0.55); font-size: 13px; cursor: pointer; transition: color 0.15s, border-color 0.15s, background 0.15s; } .tvd-tab-btn:hover { color: var(--tvd-text, #fff); background: rgba(255, 255, 255, 0.06); } .tvd-tab-btn.tvd-active { color: var(--tvd-accent, #00d4ff); border-bottom-color: var(--tvd-accent, #00d4ff); font-weight: 600; } /* 关闭按钮:页签栏右侧 */ .tvd-info-close { margin-left: auto; width: 28px; height: 28px; border: none; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); font-size: 14px; line-height: 1; cursor: pointer; flex: 0 0 auto; transition: background 0.15s, color 0.15s; } .tvd-info-close:hover { background: rgba(255, 90, 90, 0.2); color: #fff; } /* 页面容器:同一时间只显示一页 */ .tvd-info-page { display: none; flex-direction: column; flex: 1 1 auto; min-height: 0; } .tvd-info-page.tvd-page-active { display: flex; } /* 设置页 */ .tvd-settings-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 10px 12px 16px; } .tvd-setting-row { display: flex; align-items: center; gap: 12px; padding: 14px 12px; border-radius: 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.05); cursor: pointer; transition: background 0.2s; } .tvd-setting-row + .tvd-setting-row { margin-top: 10px; } .tvd-setting-row:hover { background: rgba(0, 0, 0, 0.5); } .tvd-setting-text { flex: 1; min-width: 0; } .tvd-setting-name { font-size: 14px; font-weight: 500; color: var(--tvd-text, #fff); } .tvd-setting-desc { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 3px; line-height: 1.5; } /* 开关 */ .tvd-switch { flex: 0 0 auto; width: 44px; height: 24px; border-radius: 12px; background: rgba(255, 255, 255, 0.18); position: relative; transition: background 0.2s; } .tvd-switch .tvd-switch-knob { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform 0.2s; } .tvd-switch.tvd-on { background: var(--tvd-accent, #00d4ff); } .tvd-switch.tvd-on .tvd-switch-knob { transform: translateX(20px); } /* 帮助页 */ .tvd-help-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px; } .tvd-help-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .tvd-help-name { font-size: 17px; font-weight: 700; color: var(--tvd-text, #fff); } .tvd-help-version { padding: 2px 10px; border-radius: 10px; background: var(--tvd-accent, #00d4ff); color: #000; font-size: 12px; font-weight: 700; } .tvd-help-desc { font-size: 13px; color: rgba(255, 255, 255, 0.75); line-height: 1.7; margin-bottom: 16px; word-break: break-all; } .tvd-help-section { margin-bottom: 16px; } .tvd-help-heading { font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.85); margin-bottom: 8px; } .tvd-help-notice { display: flex; gap: 8px; padding: 10px 12px; border-radius: 10px; background: rgba(0, 0, 0, 0.3); font-size: 12px; line-height: 1.6; color: rgba(255, 255, 255, 0.75); } .tvd-help-notice + .tvd-help-notice { margin-top: 8px; } .tvd-help-notice-date { color: var(--tvd-accent, #00d4ff); flex: 0 0 auto; font-weight: 600; } .tvd-help-tips { font-size: 13px; color: rgba(255, 255, 255, 0.65); line-height: 2; } /* 日志页 */ .tvd-logs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; background: #0a0a0c; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.25) transparent; } .tvd-logs-body::-webkit-scrollbar { width: 8px; } .tvd-logs-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.25); border-radius: 4px; } .tvd-log-line { display: flex; gap: 8px; padding: 2px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); white-space: pre-wrap; word-break: break-all; } .tvd-log-time { flex: 0 0 auto; color: #6a9955; user-select: none; } .tvd-log-msg { flex: 1; color: #d4d4d4; } .tvd-logs-empty { color: rgba(255, 255, 255, 0.45); padding: 30px 16px; text-align: center; font-size: 13px; line-height: 1.7; } .tvd-logs-banner { position: sticky; top: 0; z-index: 1; padding: 6px 10px; background: rgba(0, 0, 0, 0.7); border-bottom: 1px solid rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.6); font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; backdrop-filter: blur(4px); } /* 外部脚本通过 addLog() 写入的日志:左侧高亮,便于与内部日志区分 */ .tvd-log-line.tvd-log-external { background: rgba(79, 195, 247, 0.08); border-left: 2px solid #4fc3f7; padding-left: 6px; } .tvd-log-line.tvd-log-external .tvd-log-msg { color: #b3e5fc; } .tvd-history-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); flex: 0 0 auto; /* 吸顶:不参与压缩,始终可见 */ position: sticky; top: 0; z-index: 3; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .tvd-history-heading { font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.85); margin-right: 4px; } .tvd-toolbar-spacer { flex: 1; } .tvd-filter-btn { padding: 5px 14px; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 14px; background: transparent; color: rgba(255, 255, 255, 0.6); font-size: 12px; cursor: pointer; transition: all 0.15s; } .tvd-filter-btn.tvd-active { background: var(--tvd-accent, #00d4ff); border-color: transparent; color: #000; font-weight: 600; } .tvd-filter-btn:not(.tvd-active):hover { background: rgba(255, 255, 255, 0.08); color: var(--tvd-text); } .tvd-clear-btn { padding: 5px 14px; border: 1px solid rgba(255, 90, 90, 0.3); border-radius: 14px; background: transparent; color: rgba(255, 120, 120, 0.9); font-size: 12px; cursor: pointer; transition: all 0.15s; } .tvd-clear-btn:hover { background: rgba(255, 90, 90, 0.15); } .tvd-history-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-auto-rows: max-content; /* 行高由内容决定,卡片不重叠不挤压 */ gap: 12px; align-content: start; flex: 1 1 auto; min-height: 0; overflow-y: auto; /* 只滚动历史区,顶部工具栏与上方视频区保持吸顶 */ overflow-x: hidden; overscroll-behavior: contain; padding: 12px; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.25) transparent; } .tvd-history-list::-webkit-scrollbar { width: 8px; } .tvd-history-list::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.25); border-radius: 4px; } .tvd-history-item { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: rgba(0, 0, 0, 0.3); border-radius: 12px; overflow: hidden; cursor: pointer; transition: background 0.2s, transform 0.15s; border: 1px solid rgba(255, 255, 255, 0.05); } .tvd-history-item:hover { background: rgba(0, 0, 0, 0.5); transform: translateY(-2px); } .tvd-history-thumbnail { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 0; overflow: hidden; flex-shrink: 0; background: #000; } /* 悬停时展示播放提示 */ .tvd-thumb-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.8); width: 36px; height: 36px; border-radius: 50%; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s, transform 0.15s; pointer-events: none; } .tvd-history-item:hover .tvd-thumb-play { opacity: 1; transform: translate(-50%, -50%) scale(1); } .tvd-history-thumbnail img { width: 100%; height: 100%; object-fit: cover; } .tvd-thumb-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(0, 0, 0, 0.5); z-index: 1; } .tvd-thumb-progress-bar { height: 100%; background: var(--tvd-accent, #00d4ff); border-radius: 0 2px 2px 0; } .tvd-favorite-btn { position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; z-index: 2; } .tvd-favorite-btn:hover { background: rgba(0, 0, 0, 0.8); transform: scale(1.1); } .tvd-history-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-start; padding: 8px 10px 10px; } .tvd-history-title { font-size: 13px; font-weight: 500; color: var(--tvd-text); margin-bottom: 4px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; } .tvd-history-time { font-size: 12px; color: rgba(255, 255, 255, 0.5); } .tvd-empty-tip { text-align: center; color: rgba(255, 255, 255, 0.5); padding: 40px 20px; font-size: 14px; } `; document.head.appendChild(style); } /** * 更新所有视频操作栏的可见性 */ _updateVideoActionsVisibility() { this.videos.forEach((video) => { const wrapper = video.__wrapper; if (wrapper && wrapper.__actionsEl) { if (this.isActionsEnabled) { wrapper.__actionsEl.classList.add('tvd-visible'); } else { wrapper.__actionsEl.classList.remove('tvd-visible'); } } }); } /** * 显示状态提示 * @param {string} text */ showStatus(text) { if (!this.statusEl) return; this.statusEl.textContent = text; this.statusEl.classList.add('tvd-visible'); setTimeout(() => this.statusEl.classList.remove('tvd-visible'), 2200); } /** * 应用面板高度 * 信息区展开时整屏(视频区固定,信息区占其余空间); * 最大高度开启:面板高度 = 最优布局自然高度(≤ 100vh),信息区展开超 80vh 压缩为 50vh; * 最大高度关闭:面板高度 = 视频区固定高度(宽屏单行 / 智能布局,≤ 40vh),开/关信息区不变 */ applyPanelHeight() { if (!this.panel) return; // 同步 data-autoheight 属性供 CSS 条件使用 this.panel.dataset.autoheight = String(this.isAutoHeight); // 公共信息区展开时,面板整体高度 100vh if (this._isInfoOpen()) { this.panel.style.height = '100vh'; if (this.videos.length > 0) { // 视频区固定高度:开启按压缩规则(>80vh → 50vh),关闭为固定高度(与收起状态一致,保证无动画) const targetH = this.isAutoHeight ? this._infoVideosHeight() : this._getOffVideoAreaHeight(); this.videosContainer.style.flex = 'none'; this.videosContainer.style.height = `${Math.round(targetH)}px`; } else { this.videosContainer.style.flex = ''; this.videosContainer.style.height = ''; } return; } // 无视频时恢复默认高度(CSS tvd-no-videos 会覆盖为按钮条高度) if (this.videos.length === 0) { this.panel.style.height = this.config.defaultHeight; this.videosContainer.style.flex = ''; this.videosContainer.style.height = ''; return; } if (this.isAutoHeight) { // 最大高度开启:由 _calculateOptimalLayout 决定面板高度(≤ 100vh) const layout = this._calculateOptimalLayout(); this.panel.style.height = `${layout.height}px`; // 历史收起后的过渡窗口期内,同步更新视频区固定高度,避免与面板高度不一致 if (this.videosContainer.style.height) { this.videosContainer.style.height = `${layout.height}px`; } this.log('最大高度开启: 面板', layout.height, 'px, 布局:', layout.cols, '×', layout.rows); } else { // 最大高度关闭:面板高度 = 视频区固定高度(宽屏单行 / 智能布局,≤ 40vh) const targetH = Math.round(this._getOffVideoAreaHeight()); this.panel.style.height = `${targetH}px`; this.videosContainer.style.flex = ''; if (this.videosContainer.style.height) { this.videosContainer.style.height = `${targetH}px`; } this.log('最大高度关闭: 面板', targetH, 'px'); } } /** * 信息区展开时(最大高度开启)视频区的目标高度: * 自然高度超过 80vh 时压缩为 50vh,为信息区留出空间;否则保持自然高度不变 */ _infoVideosHeight() { const vh = window.innerHeight; const natural = this._calculateOptimalLayout().height; return natural > vh * 0.8 ? vh * 0.5 : natural; } /** * 持久化最大高度状态并应用面板高度(开关位于公共信息区设置页) */ updateAutoHeightState() { this.applyPanelHeight(); safeStorage.set(this.config.storageKey, this.isAutoHeight); } toggleAutoHeight() { this.isAutoHeight = !this.isAutoHeight; this.updateAutoHeightState(); // 最大高度变化可能影响布局(宽屏单行 / 智能布局) this._updateVideosLayout(); this.showStatus(this.isAutoHeight ? '最大高度:开启(视频区最高100vh)' : '最大高度:关闭(视频区最高40vh)'); } /** * 更新展开/收起按钮状态(统一使用 floatToggle) */ updateToggleState() { if (!this.floatToggle || !this.panel) return; if (this.isCollapsed) { this.panel.classList.add('tvd-collapsed'); this.floatToggle.textContent = '▲'; this.floatToggle.title = '展开'; // 收起时暂停所有视频 this.videos.forEach((v) => v.pause()); } else { this.panel.classList.remove('tvd-collapsed'); this.floatToggle.textContent = '▼'; this.floatToggle.title = '收起'; } } /** * 展开面板 */ expand() { if (!this.isCollapsed) return; this.isCollapsed = false; this.panel.classList.remove('tvd-collapsed'); this.updateToggleState(); this.applyPanelHeight(); // 展开时恢复所有视频播放(自动播放关闭时仅展示,不自动恢复) if (this.isAutoPlay) { this.videos.forEach((v) => { const playPromise = v.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((err) => this.log('恢复播放失败:', err)); } }); } this.showStatus('已展开'); } /** * 收起面板 */ collapse() { if (this.isCollapsed) return; this.isCollapsed = true; this.panel.classList.add('tvd-collapsed'); this.updateToggleState(); this.showStatus('已收起,播放已暂停'); } /** * 切换面板展开/收起 */ toggle() { if (this.isCollapsed) { this.expand(); } else { this.collapse(); } } /** * 播放视频(单开模式重复调用替换当前视频;多开模式重复调用新增视频,最多 maxVideos 个) * @param {Object} videoInfo - 视频信息对象 * @param {string} videoInfo.url - 页面地址 * @param {string} videoInfo.content - 视频标题/内容 * @param {string} videoInfo.video_url - 实际视频地址 * @param {string} [videoInfo.image_url] - 视频封面地址 */ play(videoInfo) { if (!videoInfo || !videoInfo.video_url) { this.showStatus('未提供视频地址'); return; } if (!this.videosContainer) return; // 防抖:同一视频 500ms 内不重复加载(防止双击/事件重入导致重复播放) const now = Date.now(); if (this._lastPlayUrl === videoInfo.video_url && this._lastPlayTime && now - this._lastPlayTime < 500) { this.log('play() 防抖拦截,同一视频间隔', now - this._lastPlayTime, 'ms'); return; } this._lastPlayUrl = videoInfo.video_url; this._lastPlayTime = now; // 同一视频已在任意播放槽位中:不再重复加载(修复竖屏多开/双击导致加载两个相同视频) const alreadyPlaying = this.videos.some(v => { const info = v.__wrapper && v.__wrapper.__videoInfo; return info && info.video_url === videoInfo.video_url; }); if (alreadyPlaying) { this.log('该视频已在播放中,跳过重复加载'); this.showStatus('该视频已在播放'); return; } // 如果当前处于收起状态,自动展开 if (this.isCollapsed) { this.isCollapsed = false; this.panel.classList.remove('tvd-collapsed'); this.updateToggleState(); this.applyPanelHeight(); } this.log('准备播放:', videoInfo.content || '', videoInfo.video_url); // 记录到播放历史 this.saveHistory(videoInfo); let video; if (!this.isMultiVideo) { // 单开模式:复用唯一的视频槽位 video = this.videos[0] || this._createVideo(); } else if (this.videos.length < this.getMaxVideos()) { // 多开模式:未达上限,新增一个视频槽位 video = this._createVideo(); } else { // 多开模式:已达上限,复用最早的视频槽位 video = this.videos.shift(); this.videos.push(video); this._updateVideosLayout(); this.showStatus(`多开已达上限(${this.getMaxVideos()}),替换最早的视频`); } // 保存 videoInfo 到 wrapper,供操作栏使用 const wrapper = video.__wrapper; if (wrapper) { // 切换前保存上一个视频的播放进度,并用当前画面更新预览图 if (wrapper.__videoInfo) { this._saveProgress(video); this._captureAndSaveFrame(video); } wrapper.__videoInfo = videoInfo; // 更新标题内容 const title = wrapper.querySelector('.tvd-video-title'); if (title) { title.textContent = videoInfo.content || ''; } // 收藏按钮状态与收藏列表保持一致 const likeBtn = wrapper.__actionsEl && wrapper.__actionsEl.querySelector('[data-action="like"]'); if (likeBtn) { likeBtn.textContent = this.isFavorite(videoInfo.url || videoInfo.video_url) ? '♥️' : '🤍'; } } // 销毁旧的 Hls 实例并重置 video 元素 this._destroyVideoHls(video); video.pause(); video.removeAttribute('src'); video.load(); // 播放区从隐藏恢复显示后,重算面板高度 this.applyPanelHeight(); // 设置或清空封面 if (videoInfo.image_url) { video.poster = videoInfo.image_url; } else { video.removeAttribute('poster'); } // 同步自动播放设置(关闭时仅加载视频,不自动开始播放) video.autoplay = this.isAutoPlay; this._loadVideoUrl(videoInfo.video_url, video); } /** * 内部方法:根据 URL 加载视频(hls.js 优先 / 原生 HLS 兜底) * 注意:安卓 Chromium(含 Edge)的 video.canPlayType('application/vnd.apple.mpegurl') * 会误报“支持原生 HLS”,但实际无法播放,因此 HLS 源(blob 伪清单 / .m3u8)一律优先走 * hls.js,不再依赖 canPlayType 的判断。Safari/iOS 上 Hls.isSupported() 为 false, * 会自动回落到原生 HLS 分支,不影响体验。 * @param {string} url - 视频地址 * @param {HTMLVideoElement} video - 目标 video 元素 */ _loadVideoUrl(url, video) { if (!url) { this.showStatus('未配置视频地址'); return; } if (!video) return; this.log('加载视频:', url); const Hls = this.config.Hls; const isHlsSource = typeof url === 'string' && (url.startsWith('blob:') || /\.m3u8/i.test(url)); // 优先 hls.js:只要 hls.js 可用且为 HLS 源,直接解码(修复安卓原生误报) if (Hls && typeof Hls.isSupported === 'function' && Hls.isSupported() && isHlsSource) { this._attachHls(url, video, Hls); return; } // Safari / iOS 原生支持 HLS(无 hls.js 或源非 HLS 时) if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; this.log('使用原生 HLS 播放'); this.showStatus('使用原生 HLS'); return; } // 其它情况仍尝试 hls.js 兜底 if (Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()) { this._attachHls(url, video, Hls); return; } // 兜底:直接设置 src const msg = '当前浏览器不支持 HLS 播放,请使用 Safari 或传入 hls.js 的 Hls 类。'; this.log(msg); this.showStatus(msg); video.src = url; } /** * 使用 hls.js 加载并播放 HLS 源(blob 伪清单 或 .m3u8 真实地址) * @param {string} url - HLS 地址 * @param {HTMLVideoElement} video - 目标 video 元素 * @param {Object} Hls - hls.js 的 Hls 类 */ _attachHls(url, video, Hls) { const hls = new Hls({ ...this.config.hlsConfig, debug: this.config.debug, }); // 每个 video 元素各自持有一个 Hls 实例 video.__hls = hls; hls.loadSource(url); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { this.log('HLS manifest 解析完成'); if (!this.isAutoPlay) { // 自动播放关闭:仅加载,等用户手动播放 this.showStatus('已加载(自动播放已关闭)'); return; } this.showStatus('正在播放'); const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((err) => { this.log('自动播放被阻止:', err); this.showStatus('自动播放被浏览器阻止,请点击播放按钮'); }); } }); hls.on(Hls.Events.ERROR, (event, data) => { this.log('HLS 错误:', data.type, data.details); if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: this.showStatus('网络错误,正在尝试恢复...'); hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: this.showStatus('媒体错误,正在恢复...'); hls.recoverMediaError(); break; default: this.showStatus('无法播放该视频流'); this._destroyVideoHls(video); break; } } }); } /** * 销毁指定 video 元素绑定的 Hls.js 实例 * @param {HTMLVideoElement} video - 目标 video 元素 */ _destroyVideoHls(video) { if (video && video.__hls) { try { video.__hls.destroy(); } catch (e) { this.log('销毁 Hls 实例出错:', e); } video.__hls = null; } } /** * 销毁整个 UI 实例 */ destroy() { // 销毁所有视频绑定的 Hls 实例 this.videos.forEach((v) => this._destroyVideoHls(v)); this.videos = []; if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); this.resizeHandler = null; } if (this.panel && this.panel.parentNode) { this.panel.parentNode.removeChild(this.panel); } if (this.floatToggle && this.floatToggle.parentNode) { this.floatToggle.parentNode.removeChild(this.floatToggle); } ['tvd-ui-styles', 'tvd-info-styles'].forEach(id => { const style = document.getElementById(id); if (style && style.parentNode) { style.parentNode.removeChild(style); } }); this.log('顶部视频抽屉已销毁'); } } // 对外暴露 UI 库版本(静态属性) TopVideoDrawer.VERSION = LIB_VERSION; return TopVideoDrawer; });