/**
* 顶部视频抽屉 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,
// 自定义操作栏按钮数组(参考 floating-ui-library 的 icons + onItemClick)。
// 为 null 时使用内置的「关闭/收藏/下载/打开原页面」按钮;传入数组则按钮完全由数组决定(含关闭按钮):
// 空数组 [] 即不出现任何按钮。每项格式:
// { icon: '❤️', title: '喜欢', action?: 'close'|'like'|'download'|'open', onClick?: (videoInfo, video, wrapper, index) => {}, activeIcon?: '♥️' }
// 只改 icon/title 而不改行为时,设置 action 复用内置回调;设置 onClick 则优先于 action。
videoActions: null,
// 是否自动播放:开启时 play() 调用与展开面板会自动开始播放;关闭时仅加载视频,需手动播放
autoPlayEnabled: true,
// 是否循环播放:开启时视频播放完毕后自动重播
loopEnabled: false,
// 历史/收藏记录的唯一标识来源(不同网站合适的键不同):
// 'auto'(默认)— 视频地址优先、回退页面网址;
// 'url' — 用页面网址(适合视频地址为 blob:/临时签名 URL、每次变化的站点);
// 'video_url' — 用视频地址(适合页面网址带可变参数的站点);
// 函数 (videoInfo) => string — 完全自定义
historyKey: 'auto',
// 脚本信息(帮助页展示:名称、版本、说明、通知等),也可通过 setScriptInfo() 动态传入
// 格式:{ name: '', version: '', description: '', notifications: [{ date: '', text: '' }] }
scriptInfo: null,
// 服务端脚本 ID(如 'haijiao'):配置后若页面已加载 SbCLi(supabaseClientLibrary),
// 帮助页会自动拉取脚本配置(最新版本/公告/网址/购买链接)与激活信息,并提供激活入口
scriptId: null,
// 是否在帮助页展示「激活信息」区块(含激活码输入与激活按钮)。
// 仅作为库的配置项由宿主脚本控制,不在设置页开关;scriptId 未配置或 SbCLi 不可用时自动隐藏
showActivationInfo: true,
// 收藏按钮点击回调,参数为 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.2';
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] - 通知列表
* @param {string} [options.scriptId] - 服务端脚本 ID;配置后若已加载 SbCLi,帮助页自动展示脚本配置与激活信息
* @param {boolean} [options.showActivationInfo=true] - 是否在帮助页展示「激活信息」区块,宿主脚本可设为 false 隐藏
*/
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 || {}
);
// 服务端脚本配置与激活信息(依赖 SbCLi,未加载时保持 null,帮助页只展示本地 scriptInfo)
this.serverConfig = null;
this.activationInfo = null;
this.trialCount = null;
// 优先使用 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();
// 异步拉取服务端脚本配置与激活信息(不阻塞 UI 初始化;失败时帮助页仅展示本地信息)
this.loadServerInfo();
}
/**
* 主视频(第一个视频),供最大高度等逻辑使用
*/
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(' '),
};
// 统一走 _pushLogEntry:连续相同内容会合并并显示重复次数(类似 console 折叠重复日志)
this._pushLogEntry(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 };
// 与内部 log() 共用写入入口,重复日志合并逻辑保持一致
this._pushLogEntry(entry);
}
/**
* 将日志条目写入缓冲区并同步到日志页(内部 log() 与外部 addLog() 的共用入口)。
* 若与上一条内容及来源(内部/外部)完全一致,则不新增行,
* 仅累加重复次数并原地更新数量徽标,模拟浏览器控制台折叠重复日志的效果。
* @param {{time:Date, message:string, external?:boolean}} entry
* @private
*/
_pushLogEntry(entry) {
const last = this._logBuffer[this._logBuffer.length - 1];
if (last && last.message === entry.message && !!last.external === !!entry.external) {
// 与上一条完全相同:合并为同一条,累加次数并更新为最新发生时间
last.count = (last.count || 1) + 1;
last.time = entry.time;
if (this._isInfoOpen() && this.activeInfoPage === 'logs') {
this._updateLogEntryCount(last);
}
return;
}
entry.count = 1;
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);
});
});
}
/**
* 创建一个视频槽位并加入容器。
* @param {HTMLVideoElement} [existingVideo] - 可选:传入页面中已有的 video 元素,
* 则直接将其搬入抽屉(保留原有 src 与播放状态),而非新建 video。
* @returns {HTMLVideoElement}
*/
_createVideo(existingVideo) {
const wrapper = document.createElement('div');
wrapper.className = 'tvd-video-wrapper';
const video = existingVideo || document.createElement('video');
video.className = 'tvd-video-item';
if (!existingVideo) {
// 新建的 video:按默认配置初始化
video.muted = false;
video.autoplay = this.isAutoPlay;
video.controls = true;
video.preload = 'auto';
video.setAttribute('playsinline', '');
video.setAttribute('crossorigin', 'anonymous');
} else {
// 搬入的页面 video:补齐基本播放属性(页面未设置时才补,不覆盖页面已有配置)
if (!video.hasAttribute('controls')) video.controls = true;
if (!video.hasAttribute('playsinline')) video.setAttribute('playsinline', '');
// 清除页面可能设置的宽高内联样式,让 .tvd-video-item 的 100% 布局生效
video.style.width = '';
video.style.height = '';
}
video.addEventListener('loadedmetadata', () => {
this.log('视频元数据:', video.videoWidth, 'x', video.videoHeight);
// 视频宽高比已知后重新计算最优布局和面板高度(两种最大高度模式都需要)
this._updateVideosLayout();
this.applyPanelHeight();
// 新建视频从上次播放进度继续;搬入的页面视频保留页面原播放位置,不恢复
if (!existingVideo) 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(this._identityKey(info), { 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;
}
/**
* 创建视频右侧操作栏。
* 所有按钮(含关闭)统一由 config.videoActions 驱动,不做特殊化:
* - null(默认):内置「关闭 / 收藏 / 下载 / 打开原页面」四个按钮;
* - [] 空数组:不出现任何操作按钮;
* - 非空数组:按钮完全由数组决定。
* 每项格式:{ icon: '❤️', title: '收藏', action?: 'like', onClick?, activeIcon? }
* - action: 'close' | 'like' | 'download' | 'open',复用内置点击行为(只改图标/标题不改行为);
* - onClick: 自定义函数 (videoInfo, video, wrapper, index),优先于 action;
* - activeIcon: 已收藏时展示的图标(仅对 action: 'like' 有意义,默认 ♥️)。
* @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');
// null → 内置定义;数组(含 [])→ 完全以数组为准
const defs = Array.isArray(this.config.videoActions)
? this.config.videoActions
: this._getBuiltinActionDefs();
defs.forEach((def, index) => {
if (!def) return;
const btn = document.createElement('button');
btn.className = 'tvd-action-icon';
btn.dataset.action = def.action || 'custom';
btn.dataset.index = String(index);
btn.textContent = def.icon || '•';
btn.title = def.title || '';
// 记住常态 / 已收藏图标,供收藏状态同步时切换
btn._idleIcon = def.icon || '•';
btn._activeIcon = def.activeIcon || '♥️';
btn.addEventListener('click', (e) => {
e.stopPropagation();
const info = wrapper.__videoInfo;
const video = wrapper.querySelector('.tvd-video-item');
// 未提供 onClick 时按 action 查找内置回调(与参考库 onItemClick(index, icon) 语义对齐)
let handler = typeof def.onClick === 'function' ? def.onClick : null;
if (!handler && def.action) {
const builtin = this._getBuiltinActionDefs().find((b) => b.action === def.action);
handler = builtin ? builtin.onClick : null;
}
if (handler) {
try {
handler(info, video, wrapper, index);
} catch (err) {
this.log('操作按钮回调出错:', err && err.message);
}
}
});
actions.appendChild(btn);
});
wrapper.__actionsEl = actions;
return actions;
}
/**
* 内置操作按钮定义(关闭 / 收藏 / 下载 / 打开原页面)。
* onClick 签名统一为 (videoInfo, video, wrapper, index)。
* @returns {Array