/**
* 顶部视频抽屉 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);
}
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]';
this.panel = null;
this.floatToggle = null;
this.videosContainer = null;
this.videos = [];
this.statusEl = null;
this.settingsBtn = null;
this.helpBtn = null;
this.historyBtn = 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.historyList = [];
// 历史列表过滤:'all' | 'liked'
this.historyFilter = 'all';
this.favoriteList = [];
this.resizeHandler = null;
this.init();
}
/**
* 主视频(第一个视频),供最大高度等逻辑使用
*/
get video() {
return this.videos[0] || null;
}
log(...args) {
if (this.config.debug) console.log(this.LOG_PREFIX, ...args);
}
/**
* 初始化:创建样式、DOM、事件
*/
init() {
this.injectStyles();
this.createDOM();
this.updateToggleState();
this.updateAutoHeightState();
this.updateMultiVideoState();
this.updateActionsState();
this.updateAutoPlayState();
this.updateLoopState();
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-history {
right: 50px;
font-size: 16px;
}
#tvd-settings {
right: 88px;
font-size: 14px;
}
#tvd-help {
right: 126px;
font-size: 13px;
}
#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:hover .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.helpBtn = this.panel.querySelector('#tvd-help');
this.historyBtn = this.panel.querySelector('#tvd-history');
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.helpBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleHelp();
});
this.historyBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleHistory();
});
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));
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 ? '循环播放:开启(播完自动重播)' : '循环播放:关闭');
}
/**
* 顶部入口按钮的统一开关:点击当前已激活页对应的按钮则收起信息区,否则展开对应页面
*/
toggleHistory() {
if (this._isInfoOpen() && this.activeInfoPage === 'history') {
this.hideInfoPanel();
} else {
this.loadHistoryList();
this._openInfoPage('history');
}
}
toggleSettings() {
if (this._isInfoOpen() && this.activeInfoPage === 'settings') {
this.hideInfoPanel();
} else {
this._openInfoPage('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();
});
}
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();
}
// 展开信息区(与播放区同面板,仅切换 class,无 DOM 创建)
infoSection.classList.add('tvd-info-open');
if (this.panel) this.panel.classList.add('tvd-info-open');
// 信息区展开时面板整体高度 100vh,视频区按压缩后高度重新智能布局
this._updateVideosLayout();
this.applyPanelHeight();
}
/**
* 同步右上角入口按钮的激活状态(当前展开页对应的按钮高亮)
*/
_syncInfoButtons() {
[
[this.historyBtn, 'history'],
[this.settingsBtn, 'settings'],
[this.helpBtn, 'help'],
].forEach(([btn, page]) => {
if (btn) btn.classList.toggle('tvd-active', this.activeInfoPage === page);
});
}
/**
* 渲染设置页:视频操作栏 / 多开 / 最大高度 / 自动播放 / 循环播放五个开关
*/
_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,
},
];
page.innerHTML = `${settings.map(s => `
`).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();
// 立即刷新开关状态与描述文案(如最大高度说明、多开上限)
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 = `
${desc}
${notifHtml}
按钮说明
🕘 历史 — 查看播放历史,点击卡片继续播放
⚙ 设置 — 视频操作栏 / 多开 / 最大高度开关
❓ 帮助 — 脚本说明、版本号与通知
▼ / ▲ — 收起 / 展开面板
`;
}
/**
* 更新脚本信息(帮助页展示),供外部脚本异步获取后回调传入
* @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.activeInfoPage = null;
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-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-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 / hls.js 兜底)
* @param {string} url - 视频地址
* @param {HTMLVideoElement} video - 目标 video 元素
*/
_loadVideoUrl(url, video) {
if (!url) {
this.showStatus('未配置视频地址');
return;
}
if (!video) return;
this.log('加载视频:', url);
// Safari / iOS 原生支持 HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
this.log('使用原生 HLS 播放');
this.showStatus('使用原生 HLS');
return;
}
// 使用传入的 Hls.js 播放
const Hls = this.config.Hls;
if (Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()) {
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;
}
}
});
return;
}
// 兜底:直接设置 src
const msg = '当前浏览器不支持 HLS 播放,请使用 Safari 或传入 hls.js 的 Hls 类。';
this.log(msg);
this.showStatus(msg);
video.src = url;
}
/**
* 销毁指定 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('顶部视频抽屉已销毁');
}
}
return TopVideoDrawer;
});