视频播放器
Dette script bør ikke installeres direkte. Det er et bibliotek, som andre scripts kan inkludere med metadirektivet // @require https://update.greasyfork.org/scripts/591096/1902703/VideoPlayer%20421.js
/**
* VideoPlayer v4.2.1 (modified)
* 一个功能丰富的 HTML5 视频播放器。
* 修改点:
* 1. _initHLS 方法支持从 <video> 内部的 <source> 标签提取 HLS 源。
* 2. _loadSource 方法在 HLS 场景下直接使用 hls.js 加载,不再创建 <source> 标签。
*/
(function (global) {
'use strict';
// ---------- 工具函数 ----------
var isElement = function (obj) { return !!(obj && obj.nodeType === 1); };
var query = function (sel, ctx) { ctx = ctx || document; return ctx.querySelector(sel); };
var extend = function (target, source) {
for (var key in source) {
if (source.hasOwnProperty(key)) target[key] = source[key];
}
return target;
};
var loadScript = function (url) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
};
// ---------- SVG 图标 ----------
var ICONS = {
play: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>',
pause: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill="currentColor"/></svg>',
volumeHigh: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/><path d="M14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/></svg>',
volumeMuted: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" fill="currentColor"/></svg>',
fullscreen: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg>',
fullscreenExit: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" fill="currentColor"/></svg>',
menu: '<svg viewBox="0 0 24 24" width="24" height="24"><circle cx="12" cy="5" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="19" r="2" fill="currentColor"/></svg>',
lock: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" fill="currentColor"/></svg>',
unlock: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h2c0-1.66 1.34-3 3-3s3 1.34 3 3v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z" fill="currentColor"/></svg>',
abLoop: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" fill="currentColor"/></svg>',
pip: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19 11h-8v6h8v-6zm-2 4h-4v-2h4v2zm4-12H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" fill="currentColor"/></svg>',
cast: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z" fill="currentColor"/></svg>',
orientationAuto: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M7 7h10v10H7z" fill="currentColor"/><path d="M9 2v2h6V2h2v2h3v2h-2v12h2v2h-2v2h-2v-2H9v2H7v-2H4v-2h2V6H4V4h3V2h2zm-1 4v12h8V6H8z" fill="currentColor"/></svg>',
orientationLandscape: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M2 5h20v14H2z" fill="currentColor"/></svg>',
orientationPortrait: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M5 2h14v20H5z" fill="currentColor"/></svg>',
debug: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="currentColor"/></svg>',
close: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="currentColor"/></svg>',
brightness: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3zm0-6v2h-2V3h2zm0 16v2h-2v-2h2zm8-9h-2v-2h2v2zm-16 0H2v-2h2v2zm12.36-5.64l-1.41 1.41-1.41-1.41 1.41-1.41 1.41 1.41zm-10.72 0l1.41 1.41L4.64 5.36 3.23 3.95l1.41 1.41zm12.72 12.72l-1.41-1.41 1.41-1.41 1.41 1.41-1.41 1.41zm-10.72 0l-1.41-1.41 1.41-1.41 1.41 1.41-1.41 1.41z"/></svg>',
settings: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg>',
download: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor"/></svg>'
};
// ---------- 默认配置 ----------
var DEFAULTS = {
video: null,
sources: null,
tracks: [],
poster: '',
autoplay: false,
loop: false,
muted: false,
controls: true,
themeColor: '#6c5ce7',
keyboardShortcuts: true,
playbackRates: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 5.0],
controlsList: ['play', 'volume', 'progress', 'time', 'subtitles', 'abloop', 'fullscreen', 'orientation'],
playlist: null,
autoNext: false,
enableProgressMemory: false,
storageKey: 'video-player',
enablePreferenceMemory: false,
subtitleDelay: 0,
subtitleStyle: null,
audioGain: 1,
downmixToStereo: false,
wakeLock: false,
videoFilter: null,
videoZoom: 1,
videoPan: {x:0, y:0},
rotation: 0,
eqSettings: null,
hlsConfig: null,
fallbackSources: null,
chapters: null,
controlsInitiallyVisible: false,
aspectRatio: null,
videoFit: 'contain',
onReady: null,
onPlay: null,
onPause: null,
onEnded: null,
onTimeUpdate: null,
onError: null,
onQualityChange: null,
onAudioTrackChange: null,
onSubtitleTrackChange: null,
onBufferProgress: null,
onLoadProgress: null,
onSourceChange: null,
onChapter: null,
onOrientationChange: null
};
// ---------- 事件总线 ----------
function EventEmitter() {
this._events = {};
}
EventEmitter.prototype.on = function (event, handler) {
if (!this._events[event]) this._events[event] = [];
this._events[event].push(handler);
return this;
};
EventEmitter.prototype.off = function (event, handler) {
if (!this._events[event]) return this;
if (handler) {
var idx = this._events[event].indexOf(handler);
if (idx > -1) this._events[event].splice(idx, 1);
} else {
this._events[event] = [];
}
return this;
};
EventEmitter.prototype.emit = function (event, data) {
if (!this._events[event]) return this;
this._events[event].forEach(function (fn) {
try { fn.call(this, data); } catch (e) { console.error(e); }
}, this);
return this;
};
// ---------- 样式注入 ----------
var injectStyles = function () {
if (document.getElementById('video-player-styles')) return;
var style = document.createElement('style');
style.id = 'video-player-styles';
style.textContent = `
/* ===== 播放器容器 ===== */
.video-player {
position:relative; display:block; background:#000; border-radius:12px; overflow:hidden;
font-family:system-ui,sans-serif; line-height:1.4; color:#fff; user-select:none;
height:auto; aspect-ratio:auto;
}
.video-player video { display:block; width:100%; height:auto; background:#000; outline:none; cursor:default; }
.video-player video::cue {
font-size: var(--cp-subtitle-size, 16px);
color: var(--cp-subtitle-color, #ffffff);
background-color: var(--cp-subtitle-bg, rgba(0,0,0,0.7));
font-family: var(--cp-subtitle-font, system-ui, sans-serif);
text-shadow: var(--cp-subtitle-shadow, none);
}
/* ===== 控制栏容器 ===== */
.video-player .cp-controls {
position:absolute; bottom:0; left:0; right:0;
padding:8px 12px 10px;
background:linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 70%, transparent 100%);
opacity:0;
transition:opacity 0.3s, transform 0.3s;
display:flex; flex-direction:column; gap:4px;
pointer-events:none;
transform:translateY(10px);
}
.video-player .cp-controls.cp-controls-visible {
opacity:1; pointer-events:auto; transform:translateY(0);
}
.video-player:hover .cp-controls,
.video-player.is-focused .cp-controls {
opacity:1; pointer-events:auto; transform:translateY(0);
}
/* ===== 控制栏顶部行 ===== */
.video-player .cp-controls-top {
display:flex; justify-content:space-between; align-items:center;
font-size:13px; padding:0 2px; color:rgba(255,255,255,0.85); gap:6px; flex-wrap:wrap;
}
/* ===== 进度条 ===== */
.video-player .cp-progress {
width:100%; height:3px; background:rgba(255,255,255,0.2);
border-radius:2px; cursor:pointer; position:relative; touch-action:none;
}
.video-player .cp-progress .cp-progress-filled {
height:100%; background:var(--cp-theme,#6c5ce7);
border-radius:2px; width:0%; transition:width 0.05s linear; position:relative;
}
.video-player .cp-progress .cp-progress-filled::after {
content:''; position:absolute; right:-5px; top:-3.5px;
width:10px; height:10px; background:var(--cp-theme,#6c5ce7);
border-radius:50%; opacity:0; transition:opacity 0.2s;
box-shadow:0 0 8px rgba(0,0,0,0.3);
}
.video-player .cp-progress:hover .cp-progress-filled::after { opacity:1; }
.video-player .cp-progress .cp-progress-buffered {
position:absolute; left:0; top:0; height:100%;
background:rgba(255,255,255,0.25); border-radius:2px;
width:0%; pointer-events:none; z-index:0;
}
.video-player .cp-progress .cp-progress-filled { z-index:1; }
/* ===== 控制栏底部行 ===== */
.video-player .cp-controls-bottom {
display:flex; align-items:center; gap:2px; flex-wrap:wrap; min-height:36px;
}
/* ===== 按钮通用样式 ===== */
.video-player .cp-btn {
background:none; border:none; color:rgba(255,255,255,0.9);
cursor:pointer; padding:4px;
display:inline-flex; align-items:center; justify-content:center;
width:36px; height:36px; border-radius:6px;
transition:background 0.15s, transform 0.15s;
background:transparent; flex-shrink:0;
}
.video-player .cp-btn:hover {
background:rgba(255,255,255,0.1);
}
.video-player .cp-btn:active {
transform:scale(0.92);
}
.video-player .cp-btn:focus-visible {
outline:2px solid var(--cp-theme,#6c5ce7); outline-offset:2px;
}
.video-player .cp-btn svg {
width:18px; height:18px; fill:currentColor;
}
/* ===== 时间显示 ===== */
.video-player .cp-time {
font-size:12px; color:rgba(255,255,255,0.75);
min-width:60px; letter-spacing:0.3px;
font-variant-numeric:tabular-nums; line-height:36px; padding:0 4px;
}
/* ===== 音量控制 ===== */
.video-player .cp-volume-wrap { display:flex; align-items:center; }
/* ===== 字幕选择 ===== */
.video-player .cp-subtitle-select {
background:rgba(0,0,0,0.4); border:1px solid rgba(255,255,255,0.1);
color:#fff; padding:2px 8px; border-radius:4px; font-size:11px;
cursor:pointer; outline:none; font-family:inherit; height:28px;
backdrop-filter:blur(4px);
}
.video-player .cp-subtitle-select option { background:#222; }
/* ===== 全屏按钮 ===== */
.video-player .cp-fullscreen-btn { margin-left:auto; }
/* ===== 海报 ===== */
.video-player .cp-poster {
position:absolute; top:0; left:0; right:0; bottom:0;
background-size:cover; background-position:center;
pointer-events:none; transition:opacity 0.5s; z-index:1;
}
.video-player .cp-poster.hidden { opacity:0; }
/* ===== 中间大播放按钮 ===== */
.video-player .cp-big-play {
position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%);
width:64px; height:64px; border-radius:50%;
background:rgba(0,0,0,0.3);
border:none;
color:#fff; display:flex; align-items:center; justify-content:center;
cursor:pointer; z-index:2;
transition:opacity 0.3s, transform 0.2s, background 0.2s;
backdrop-filter:blur(6px);
-webkit-backdrop-filter:blur(6px);
box-shadow:0 4px 20px rgba(0,0,0,0.25);
opacity:0; pointer-events:none;
}
.video-player .cp-big-play.show { opacity:1; pointer-events:auto; }
.video-player .cp-big-play:hover {
transform:translate(-50%,-50%) scale(1.06);
background:rgba(0,0,0,0.45);
}
.video-player .cp-big-play svg { width:30px; height:30px; }
/* ===== 加载动画 ===== */
.video-player .cp-loading {
position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%);
width:40px; height:40px;
border:3px solid rgba(255,255,255,0.12);
border-top:3px solid var(--cp-theme,#6c5ce7);
border-radius:50%; animation:cp-spin 0.8s linear infinite;
display:none; z-index:3;
}
@keyframes cp-spin { to { transform:translate(-50%,-50%) rotate(360deg); } }
.video-player.is-loading .cp-loading { display:block; }
/* ===== 右上角菜单(精简) ===== */
.video-player .cp-top-right {
position:absolute; top:10px; right:10px;
display:flex; gap:4px; z-index:15;
opacity:0; transition:opacity 0.3s; pointer-events:none;
}
.video-player:hover .cp-top-right,
.video-player.is-focused .cp-top-right {
opacity:1; pointer-events:auto;
}
.video-player .cp-top-right .cp-btn {
width:28px; height:28px; padding:0;
background:rgba(0,0,0,0.25) !important;
backdrop-filter:blur(4px);
-webkit-backdrop-filter:blur(4px);
border-radius:6px;
}
.video-player .cp-top-right .cp-btn svg { width:16px; height:16px; }
.video-player .cp-top-right .cp-btn:hover {
background:rgba(255,255,255,0.15) !important;
}
/* ===== 菜单下拉 ===== */
.video-player .cp-menu-dropdown {
display:none; position:absolute; top:calc(100% + 4px); right:0;
background:rgba(28,28,30,0.92);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
border-radius:8px; padding:4px; min-width:40px;
border:1px solid rgba(255,255,255,0.06);
box-shadow:0 8px 32px rgba(0,0,0,0.5);
z-index:30; flex-direction:column; gap:1px;
}
.video-player .cp-menu-dropdown.show { display:flex; }
.video-player .cp-menu-item {
background:transparent; border:none; color:#ccc;
padding:4px; border-radius:4px; cursor:pointer;
font-size:13px; text-align:left; white-space:nowrap;
transition:all 0.15s; font-weight:500;
width:100%; display:flex; align-items:center;
justify-content:center; gap:0;
}
.video-player .cp-menu-item:hover {
background:rgba(255,255,255,0.06); color:#fff;
}
.video-player .cp-menu-item svg { width:16px; height:16px; fill:currentColor; }
/* ===== 右侧垂直按钮组 ===== */
.video-player .cp-side-controls {
position:absolute; right:10px; top:50%;
transform:translateY(-50%);
display:flex; flex-direction:column; gap:4px;
z-index:16; opacity:0; transition:opacity 0.3s;
pointer-events:none;
}
.video-player:hover .cp-side-controls,
.video-player.is-focused .cp-side-controls {
opacity:1; pointer-events:auto;
}
.video-player .cp-side-controls .cp-btn {
width:32px; height:32px; padding:0;
background:rgba(0,0,0,0.25);
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
border-radius:6px;
}
.video-player .cp-side-controls .cp-btn:hover {
background:rgba(255,255,255,0.12);
}
.video-player .cp-side-controls .cp-btn svg { width:16px; height:16px; }
/* ===== A-B循环按钮:仅全屏显示 ===== */
.video-player .cp-abloop-btn { display: none !important; }
.video-player.is-fullscreen .cp-abloop-btn { display: inline-flex !important; }
/* ===== 锁定状态 ===== */
.video-player.is-locked .cp-side-controls .cp-btn:not(.cp-lock-btn) {
display: none !important;
}
.video-player.is-locked .cp-side-controls {
opacity:1 !important; pointer-events:auto !important;
}
.video-player .cp-abloop-btn.active { color:var(--cp-theme,#6c5ce7); }
.video-player .cp-abloop-btn.set-a { color:#ffa500; }
.video-player .cp-abloop-btn.set-b { color:#00e676; }
.video-player.is-locked .cp-controls,
.video-player.is-locked .cp-big-play,
.video-player.is-locked .cp-poster,
.video-player.is-locked .cp-top-right {
display:none !important; pointer-events:none !important; opacity:0 !important;
}
.video-player.is-locked .cp-side-controls {
opacity:1 !important; pointer-events:auto !important;
}
/* ===== 调试面板 ===== */
.video-player .cp-debug-panel {
position:absolute; top:40px; left:12px; right:12px;
background:rgba(28,28,30,0.92);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
border-radius:8px; padding:14px;
border:1px solid rgba(255,255,255,0.06);
box-shadow:0 8px 32px rgba(0,0,0,0.5);
z-index:40; display:none;
}
.video-player .cp-debug-panel.show { display:block; }
.video-player .cp-debug-panel .cp-debug-header {
display:flex; justify-content:space-between; align-items:center;
margin-bottom:8px;
}
.video-player .cp-debug-panel .cp-debug-title {
font-size:13px; font-weight:600; color:#fff;
}
.video-player .cp-debug-panel .cp-debug-close {
background:none; border:none; color:#ccc; cursor:pointer;
padding:2px; display:inline-flex;
}
.video-player .cp-debug-panel .cp-debug-close svg {
width:18px; height:18px; fill:currentColor;
}
.video-player .cp-debug-panel .cp-debug-row {
display:flex; justify-content:space-between;
font-size:11px; color:#ccc; padding:3px 0;
}
.video-player .cp-debug-panel .cp-debug-value {
color:#fff; font-weight:500;
}
/* ===== 手势指示器 ===== */
.video-player .cp-gesture-indicator {
position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%); z-index:25;
background:rgba(0,0,0,0.5);
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
border-radius:8px; padding:8px 14px;
display:flex; align-items:center; gap:6px;
opacity:0; pointer-events:none;
transition:opacity 0.2s; color:#fff;
font-size:14px; font-weight:600; min-width:60px;
justify-content:center;
}
.video-player .cp-gesture-indicator.show { opacity:1; }
.video-player .cp-gesture-indicator svg {
width:20px; height:20px; fill:currentColor; flex-shrink:0;
}
.video-player .cp-gesture-indicator .cp-gesture-value {
white-space:nowrap;
}
/* ===== 设置面板(齿轮菜单) ===== */
.video-player .cp-settings-panel {
display:none;
position:absolute;
bottom:calc(100% + 8px);
right:0;
background:rgba(28,28,30,0.94);
backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px);
border-radius:10px;
padding:10px 12px;
min-width:160px;
border:1px solid rgba(255,255,255,0.06);
box-shadow:0 8px 40px rgba(0,0,0,0.6);
z-index:35;
flex-direction:column;
gap:6px;
}
.video-player .cp-settings-panel.show {
display:flex;
}
.video-player .cp-settings-panel .cp-settings-title {
font-size:12px;
font-weight:600;
color:rgba(255,255,255,0.5);
letter-spacing:0.5px;
text-transform:uppercase;
padding-bottom:4px;
border-bottom:1px solid rgba(255,255,255,0.06);
margin-bottom:2px;
}
.video-player .cp-settings-panel .cp-settings-item {
display:flex;
align-items:center;
justify-content:space-between;
padding:4px 0;
font-size:13px;
color:rgba(255,255,255,0.85);
}
.video-player .cp-settings-panel .cp-settings-item select,
.video-player .cp-settings-panel .cp-settings-item .cp-settings-value {
background:rgba(255,255,255,0.06);
border:1px solid rgba(255,255,255,0.08);
border-radius:4px;
color:#fff;
padding:2px 6px;
font-size:12px;
cursor:pointer;
outline:none;
font-family:inherit;
min-width:60px;
text-align:center;
}
.video-player .cp-settings-panel .cp-settings-item .cp-settings-value {
background:transparent;
border:none;
padding:0 4px;
font-weight:500;
color:var(--cp-theme,#6c5ce7);
cursor:default;
}
.video-player .cp-settings-panel .cp-settings-item select option {
background:#222;
}
/* 下载项单独样式 */
.video-player .cp-settings-panel .cp-settings-item[data-setting="download"] {
cursor:pointer;
transition:background 0.15s;
padding:4px 8px;
border-radius:4px;
margin:0 -4px;
}
.video-player .cp-settings-panel .cp-settings-item[data-setting="download"]:hover {
background:rgba(255,255,255,0.06);
}
.video-player .cp-settings-panel .cp-settings-item[data-setting="download"] .cp-settings-value {
color:var(--cp-theme,#6c5ce7);
font-weight:500;
}
/* 设置按钮特殊样式 */
.video-player .cp-settings-btn {
margin-left:2px;
}
.video-player .cp-settings-btn.active {
color:var(--cp-theme,#6c5ce7);
}
/* ===== 自定义倍速输入框 ===== */
.video-player .cp-settings-item .cp-custom-speed-wrap {
display:flex;
align-items:center;
gap:6px;
margin-top:4px;
width:100%;
justify-content:flex-end;
}
.video-player .cp-settings-item .cp-custom-speed-wrap input {
width:58px;
background:rgba(255,255,255,0.08);
border:1px solid rgba(255,255,255,0.15);
border-radius:4px;
color:#fff;
padding:2px 4px;
font-size:12px;
outline:none;
text-align:center;
}
.video-player .cp-settings-item .cp-custom-speed-wrap input:focus {
border-color:var(--cp-theme, #6c5ce7);
}
.video-player .cp-settings-item .cp-custom-speed-wrap .cp-speed-apply {
background:var(--cp-theme, #6c5ce7);
border:none;
border-radius:4px;
color:#fff;
padding:2px 10px;
font-size:12px;
cursor:pointer;
transition:opacity 0.15s;
}
.video-player .cp-settings-item .cp-custom-speed-wrap .cp-speed-apply:hover {
opacity:0.8;
}
.video-player .cp-settings-item .cp-custom-speed-wrap .cp-speed-label {
font-size:11px;
color:rgba(255,255,255,0.5);
margin-right:auto;
}
/* ===== 响应式 ===== */
@media (max-width:600px) {
.video-player .cp-controls { padding:6px 8px 8px; }
.video-player .cp-btn { width:32px; height:32px; }
.video-player .cp-btn svg { width:16px; height:16px; }
.video-player .cp-time { font-size:10px; min-width:50px; line-height:32px; }
.video-player .cp-top-right { top:6px; right:6px; gap:3px; }
.video-player .cp-top-right .cp-btn { width:24px; height:24px; }
.video-player .cp-top-right .cp-btn svg { width:14px; height:14px; }
.video-player .cp-menu-dropdown { min-width:36px; }
.video-player .cp-menu-item { padding:3px; }
.video-player .cp-menu-item svg { width:14px; height:14px; }
.video-player .cp-side-controls { right:6px; gap:3px; }
.video-player .cp-side-controls .cp-btn { width:28px; height:28px; }
.video-player .cp-side-controls .cp-btn svg { width:14px; height:14px; }
.video-player .cp-debug-panel { left:6px; right:6px; }
.video-player .cp-gesture-indicator { padding:6px 10px; font-size:12px; }
.video-player .cp-gesture-indicator svg { width:16px; height:16px; }
.video-player .cp-big-play { width:52px; height:52px; }
.video-player .cp-big-play svg { width:24px; height:24px; }
.video-player .cp-settings-panel {
right:0;
min-width:130px;
padding:8px 10px;
}
.video-player .cp-settings-panel .cp-settings-item {
font-size:12px;
}
.video-player .cp-settings-panel .cp-settings-item select {
font-size:11px;
min-width:50px;
}
}
`;
document.head.appendChild(style);
};
// ---------- 播放器主类 ----------
function VideoPlayer(options) {
if (!(this instanceof VideoPlayer)) return new VideoPlayer(options);
EventEmitter.call(this);
this.config = extend({}, DEFAULTS);
extend(this.config, options);
// +MOD+ 确保倍速列表包含 3.0 和 5.0
var userRates = this.config.playbackRates;
if (userRates && Array.isArray(userRates)) {
var defaultRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 5.0];
var merged = userRates.slice();
defaultRates.forEach(function(r) {
if (merged.indexOf(r) === -1) merged.push(r);
});
this.config.playbackRates = merged;
}
this.controlsList = this.config.controlsList || DEFAULTS.controlsList;
var video = this.config.video;
if (!video) throw new Error('VideoPlayer: 必须提供 video 选项');
if (typeof video === 'string') video = query(video);
if (!isElement(video)) throw new Error('VideoPlayer: video 元素无效');
if (video.tagName !== 'VIDEO') throw new Error('VideoPlayer: 传入的元素必须是 <video>');
this.video = video;
injectStyles();
this.controlsEl = null;
this.progressEl = null;
this.progressFilled = null;
this.progressBuffered = null;
this.playBtn = null;
this.timeDisplay = null;
this.volumeBtn = null;
this.subtitleSelect = null;
this.fullscreenBtn = null;
this.abLoopBtn = null;
this.menuBtn = null;
this.menuDropdown = null;
this.lockBtn = null;
this.posterEl = null;
this.bigPlayBtn = null;
this.loadingEl = null;
this.sideControlsEl = null;
this.orientationBtn = null;
this.wrapper = null;
this.debugPanelEl = null;
this.debugCloseBtn = null;
this.gestureIndicatorEl = null;
this.gestureIndicatorIconEl = null;
this.gestureIndicatorValueEl = null;
this.settingsBtn = null;
this.settingsPanel = null;
this.abMarkA = null;
this.abMarkB = null;
this._blobUrls = [];
this._sourceCleanup = null;
this.volumeSlider = null;
this.volumeFilled = null;
this.isDragging = false;
this.isFullscreen = false;
this.isPip = false;
this.isMuted = this.config.muted;
this.volume = this.config.muted ? 0 : 1;
this.playbackRate = 1.0;
this._destroyed = false;
this._hls = null;
this._trackElements = [];
this._tracks = this.config.tracks || [];
this._stallRetries = 0;
this._maxStallRetries = 3;
this._isVolumeDragging = false;
this._orientation = 'auto';
this._abState = 'off';
this._abA = 0;
this._abB = 0;
this._locked = false;
this._controlsVisible = false;
this._hideControlsTimer = null;
this.touch = 'ontouchstart' in window;
this._currentQualityIndex = -1;
this._audioTracks = [];
this._currentAudioTrackIndex = -1;
this._subtitleTracks = [];
this._currentSubtitleTrack = null;
this._sourceIndex = 0;
this._playlist = this.config.playlist || [];
this._playlistIndex = 0;
this._chapters = this.config.chapters || [];
this._audioContext = null;
this._gainNode = null;
this._eqNodes = [];
this._mediaSource = null;
this._wakeLock = null;
this._wakeLockSupported = 'wakeLock' in navigator;
this._bufferInterval = null;
this._qualityInterval = null;
this._gesture = { startX:0, startY:0, isGesture:false, type:null, longPressTimer:null, tempRate:null, isLongPress:false, startBrightnessPercent:100, startVolume:1 };
this._subtitleDelay = this.config.subtitleDelay || 0;
this._prefsKey = this.config.storageKey + ':prefs';
this._progressKey = this.config.storageKey + ':progress';
this._lastChapterIndex = -1;
this._originalControlsAttr = null;
this._isDebugVisible = false;
this._gestureIndicatorTimer = null;
this._originalVideoStyle = null;
this._currentSource = null;
this._bindMethods();
this._wrapAndBuild();
this._bindEvents();
this._initSubtitles();
var self = this;
var loadInitialSources = function() {
if (self.config.sources && self.config.sources.length) {
self._loadSource(self.config.sources);
} else if (self._playlist && self._playlist.length) {
self._loadPlaylistItem(0);
} else {
// 如果 video 内已有 source 标签,调用 load 来触发事件
if (self.video.querySelector('source')) {
self.video.load();
}
}
};
this._initHLS(loadInitialSources);
if (this.config.poster) {
this.video.poster = this.config.poster;
if (this.posterEl) this.posterEl.style.backgroundImage = 'url(' + this.config.poster + ')';
}
if (this.config.autoplay) this.video.autoplay = true;
if (this.config.loop) this.video.loop = true;
if (this.config.muted) {
this.video.muted = true;
this.video.volume = 0;
}
if (this.config.subtitleStyle) {
this._applySubtitleStyle(this.config.subtitleStyle);
}
if (this.config.enablePreferenceMemory) {
this._loadPreferences();
}
this._startMonitoring();
if (this.config.wakeLock && this._wakeLockSupported) {
this._requestWakeLock();
}
if (this.config.sources && this.config.sources.length && this.wrapper) {
this.wrapper.classList.add('is-loading');
}
VideoPlayer._instances.push(this);
if (typeof this.config.onReady === 'function') this.config.onReady.call(this, this);
this.emit('ready', this);
}
VideoPlayer._instances = [];
VideoPlayer.getInstances = function () { return VideoPlayer._instances.slice(); };
VideoPlayer.destroyAll = function () {
VideoPlayer._instances.forEach(function (player) { player.destroy(); });
VideoPlayer._instances = [];
};
VideoPlayer.prototype = Object.create(EventEmitter.prototype);
VideoPlayer.prototype.constructor = VideoPlayer;
// ---------- 方法绑定 ----------
VideoPlayer.prototype._bindMethods = function () {
this.play = this.play.bind(this);
this.pause = this.pause.bind(this);
this.toggle = this.toggle.bind(this);
this.seek = this.seek.bind(this);
this.setVolume = this.setVolume.bind(this);
this.toggleMute = this.toggleMute.bind(this);
this.setPlaybackRate = this.setPlaybackRate.bind(this);
this.toggleFullscreen = this.toggleFullscreen.bind(this);
this.togglePip = this.togglePip.bind(this);
this.load = this.load.bind(this);
this.destroy = this.destroy.bind(this);
this.setOrientation = this.setOrientation.bind(this);
this.toggleOrientation = this.toggleOrientation.bind(this);
this.requestCast = this.requestCast.bind(this);
this._onPlay = this._onPlay.bind(this);
this._onPause = this._onPause.bind(this);
this._onEnded = this._onEnded.bind(this);
this._onTimeUpdate = this._onTimeUpdate.bind(this);
this._onLoadedMetadata = this._onLoadedMetadata.bind(this);
this._onWaiting = this._onWaiting.bind(this);
this._onCanPlay = this._onCanPlay.bind(this);
this._onError = this._onError.bind(this);
this._onVolumeChange = this._onVolumeChange.bind(this);
this._onPipChange = this._onPipChange.bind(this);
this._onStalled = this._onStalled.bind(this);
this._onPlaying = this._onPlaying.bind(this);
this._onProgressDown = this._onProgressDown.bind(this);
this._onProgressMove = this._onProgressMove.bind(this);
this._onProgressUp = this._onProgressUp.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onFullscreenChange = this._onFullscreenChange.bind(this);
this._onResize = this._onResize.bind(this);
this._toggleMenu = this._toggleMenu.bind(this);
this._closeMenu = this._closeMenu.bind(this);
this._onOutsideClick = this._onOutsideClick.bind(this);
this._toggleABLoop = this._toggleABLoop.bind(this);
this._toggleLock = this._toggleLock.bind(this);
this._toggleSettingsPanel = this._toggleSettingsPanel.bind(this);
this._closeSettingsPanel = this._closeSettingsPanel.bind(this);
this._downloadCurrentVideo = this._downloadCurrentVideo.bind(this);
this._onVideoProgress = this._onVideoProgress.bind(this);
this._onHlsLevelSwitched = this._onHlsLevelSwitched.bind(this);
this._onHlsAudioTrackSwitched = this._onHlsAudioTrackSwitched.bind(this);
this._onHlsError = this._onHlsError.bind(this);
this._onGestureStart = this._onGestureStart.bind(this);
this._onGestureMove = this._onGestureMove.bind(this);
this._onGestureEnd = this._onGestureEnd.bind(this);
this._onLongPressTimer = this._onLongPressTimer.bind(this);
this._onVisibilityChange = this._onVisibilityChange.bind(this);
this._toggleDebugPanel = this._toggleDebugPanel.bind(this);
this._closeDebugPanel = this._closeDebugPanel.bind(this);
this._updateDebugPanel = this._updateDebugPanel.bind(this);
this._updateABMarks = this._updateABMarks.bind(this);
this._updateSettingsPanel = this._updateSettingsPanel.bind(this);
};
// ---------- 构建 DOM ----------
VideoPlayer.prototype._wrapAndBuild = function () {
var video = this.video;
this._originalControlsAttr = video.getAttribute('controls');
this._originalVideoStyle = video.getAttribute('style');
video.removeAttribute('controls');
var parent = video.parentNode;
var wrapper = document.createElement('div');
wrapper.className = 'video-player';
wrapper.style.setProperty('--cp-theme', this.config.themeColor || '#6c5ce7');
parent.insertBefore(wrapper, video);
wrapper.appendChild(video);
this.wrapper = wrapper;
if (typeof this.config.aspectRatio === 'string' && this.config.aspectRatio) {
wrapper.style.aspectRatio = this.config.aspectRatio;
wrapper.style.height = 'auto';
video.style.position = 'absolute';
video.style.top = '0';
video.style.right = '0';
video.style.bottom = '0';
video.style.left = '0';
video.style.width = '100%';
video.style.height = '100%';
video.style.objectFit = this.config.videoFit !== undefined ? this.config.videoFit : 'contain';
} else {
video.style.position = '';
video.style.top = '';
video.style.right = '';
video.style.bottom = '';
video.style.left = '';
video.style.width = '100%';
video.style.height = 'auto';
video.style.objectFit = '';
}
if (this.config.poster) {
var poster = document.createElement('div');
poster.className = 'cp-poster';
poster.style.backgroundImage = 'url(' + this.config.poster + ')';
wrapper.appendChild(poster);
this.posterEl = poster;
}
var bigPlay = document.createElement('div');
bigPlay.className = 'cp-big-play show';
bigPlay.setAttribute('aria-hidden', 'true');
bigPlay.innerHTML = ICONS.play;
wrapper.appendChild(bigPlay);
this.bigPlayBtn = bigPlay;
var loading = document.createElement('div');
loading.className = 'cp-loading';
wrapper.appendChild(loading);
this.loadingEl = loading;
var gestureIndicator = document.createElement('div');
gestureIndicator.className = 'cp-gesture-indicator';
gestureIndicator.innerHTML = '<span class="cp-gesture-icon"></span><span class="cp-gesture-value">100%</span>';
wrapper.appendChild(gestureIndicator);
this.gestureIndicatorEl = gestureIndicator;
this.gestureIndicatorIconEl = gestureIndicator.querySelector('.cp-gesture-icon');
this.gestureIndicatorValueEl = gestureIndicator.querySelector('.cp-gesture-value');
var self = this;
var topRight = document.createElement('div');
topRight.className = 'cp-top-right';
var menuWrap = document.createElement('div');
menuWrap.className = 'cp-menu-wrap';
var menuBtn = document.createElement('button');
menuBtn.className = 'cp-btn cp-menu-btn';
menuBtn.setAttribute('aria-label', '更多');
menuBtn.innerHTML = ICONS.menu;
this.menuBtn = menuBtn;
menuWrap.appendChild(menuBtn);
var dropdown = document.createElement('div');
dropdown.className = 'cp-menu-dropdown';
// 方向切换
var orientItem = document.createElement('button');
orientItem.className = 'cp-menu-item';
orientItem.setAttribute('aria-label', '横竖屏切换');
orientItem.innerHTML = ICONS.orientationAuto;
orientItem.addEventListener('click', function (e) {
e.stopPropagation();
self.toggleOrientation();
self._closeMenu();
});
this.orientationBtn = orientItem;
dropdown.appendChild(orientItem);
// 投屏
var castItem = document.createElement('button');
castItem.className = 'cp-menu-item';
castItem.setAttribute('aria-label', '投屏');
castItem.innerHTML = ICONS.cast;
castItem.addEventListener('click', function (e) {
e.stopPropagation();
self.requestCast();
self._closeMenu();
});
dropdown.appendChild(castItem);
// 调试
var debugItem = document.createElement('button');
debugItem.className = 'cp-menu-item';
debugItem.setAttribute('aria-label', '调试信息');
debugItem.innerHTML = ICONS.debug;
debugItem.addEventListener('click', function (e) {
e.stopPropagation();
self._toggleDebugPanel();
self._closeMenu();
});
dropdown.appendChild(debugItem);
this.menuDropdown = dropdown;
menuWrap.appendChild(dropdown);
menuBtn.addEventListener('click', function (e) {
e.stopPropagation();
self._toggleMenu();
});
topRight.appendChild(menuWrap);
wrapper.appendChild(topRight);
var debugPanel = document.createElement('div');
debugPanel.className = 'cp-debug-panel';
debugPanel.innerHTML = `
<div class="cp-debug-header">
<span class="cp-debug-title">播放调试信息</span>
<button class="cp-debug-close" aria-label="关闭">${ICONS.close}</button>
</div>
<div class="cp-debug-body">
<div class="cp-debug-row"><span>分辨率</span><span class="cp-debug-value" id="cp-debug-resolution">--</span></div>
<div class="cp-debug-row"><span>码率</span><span class="cp-debug-value" id="cp-debug-bitrate">--</span></div>
<div class="cp-debug-row"><span>缓冲进度</span><span class="cp-debug-value" id="cp-debug-buffer">--</span></div>
<div class="cp-debug-row"><span>帧丢弃</span><span class="cp-debug-value" id="cp-debug-dropped">--</span></div>
<div class="cp-debug-row"><span>当前时间</span><span class="cp-debug-value" id="cp-debug-time">--</span></div>
</div>
`;
this.debugPanelEl = debugPanel;
this.debugCloseBtn = debugPanel.querySelector('.cp-debug-close');
this.debugCloseBtn.addEventListener('click', function (e) {
e.stopPropagation();
self._closeDebugPanel();
});
wrapper.appendChild(debugPanel);
var sideControls = document.createElement('div');
sideControls.className = 'cp-side-controls';
this.sideControlsEl = sideControls;
// A-B 循环按钮(仅全屏显示)
var abBtn = document.createElement('button');
abBtn.className = 'cp-btn cp-abloop-btn';
abBtn.setAttribute('aria-label', 'A-B循环');
abBtn.innerHTML = ICONS.abLoop;
this.abLoopBtn = abBtn;
abBtn.addEventListener('click', this._toggleABLoop);
sideControls.appendChild(abBtn);
var lockBtn = document.createElement('button');
lockBtn.className = 'cp-btn cp-lock-btn';
lockBtn.setAttribute('aria-label', '锁定控件');
lockBtn.innerHTML = ICONS.unlock;
this.lockBtn = lockBtn;
lockBtn.addEventListener('click', function (e) {
e.stopPropagation();
self._toggleLock();
});
sideControls.appendChild(lockBtn);
wrapper.appendChild(sideControls);
if (!this.config.controls) {
topRight.style.display = 'none';
sideControls.style.display = 'none';
if (this.bigPlayBtn) this.bigPlayBtn.classList.remove('show');
}
var controls = document.createElement('div');
controls.className = 'cp-controls';
if (!this.config.controls) controls.style.display = 'none';
var top = document.createElement('div');
top.className = 'cp-controls-top';
if (this.controlsList.includes('time')) {
var timeSpan = document.createElement('span');
timeSpan.className = 'cp-time';
timeSpan.textContent = '00:00 / 00:00';
this.timeDisplay = timeSpan;
top.appendChild(timeSpan);
}
// 字幕下拉框(保留)
if (this.controlsList.includes('subtitles')) {
var subSelect = document.createElement('select');
subSelect.className = 'cp-subtitle-select';
var offOpt = document.createElement('option');
offOpt.value = 'off';
offOpt.textContent = '字幕: 关闭';
subSelect.appendChild(offOpt);
this.subtitleSelect = subSelect;
top.appendChild(subSelect);
}
controls.appendChild(top);
if (this.controlsList.includes('progress')) {
var progress = document.createElement('div');
progress.className = 'cp-progress';
progress.setAttribute('role', 'slider');
progress.setAttribute('aria-label', '播放进度');
progress.setAttribute('aria-valuemin', '0');
progress.setAttribute('aria-valuemax', '0');
progress.setAttribute('aria-valuenow', '0');
var buffered = document.createElement('div');
buffered.className = 'cp-progress-buffered';
progress.appendChild(buffered);
this.progressBuffered = buffered;
var filled = document.createElement('div');
filled.className = 'cp-progress-filled';
progress.appendChild(filled);
this.progressFilled = filled;
var markA = document.createElement('div');
markA.className = 'cp-ab-mark-a';
markA.style.cssText = 'display:none;position:absolute;top:-6px;width:2px;height:16px;background:var(--cp-theme);z-index:4;border-radius:1px;pointer-events:none;';
markA.innerHTML = '<span style="position:absolute;top:-18px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--cp-theme);font-weight:bold;white-space:nowrap;">A</span>';
progress.appendChild(markA);
var markB = document.createElement('div');
markB.className = 'cp-ab-mark-b';
markB.style.cssText = 'display:none;position:absolute;top:-6px;width:2px;height:16px;background:var(--cp-theme);z-index:4;border-radius:1px;pointer-events:none;';
markB.innerHTML = '<span style="position:absolute;top:-18px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--cp-theme);font-weight:bold;white-space:nowrap;">B</span>';
progress.appendChild(markB);
this.progressEl = progress;
this.abMarkA = markA;
this.abMarkB = markB;
controls.appendChild(progress);
}
var bottom = document.createElement('div');
bottom.className = 'cp-controls-bottom';
// 播放按钮(左侧)
if (this.controlsList.includes('play')) {
var playBtn = document.createElement('button');
playBtn.className = 'cp-btn cp-play-btn';
playBtn.setAttribute('aria-label', '播放');
playBtn.setAttribute('aria-pressed', 'false');
playBtn.innerHTML = ICONS.play;
this.playBtn = playBtn;
bottom.appendChild(playBtn);
}
// 弹性空白,将右侧按钮推至右侧
var spacer = document.createElement('div');
spacer.style.flex = '1';
bottom.appendChild(spacer);
// 音量按钮(右侧组)
if (this.controlsList.includes('volume')) {
var volWrap = document.createElement('div');
volWrap.className = 'cp-volume-wrap';
var volBtn = document.createElement('button');
volBtn.className = 'cp-btn cp-vol-btn';
volBtn.setAttribute('aria-label', '静音');
volBtn.setAttribute('aria-pressed', this.isMuted ? 'true' : 'false');
volBtn.innerHTML = this.isMuted ? ICONS.volumeMuted : ICONS.volumeHigh;
this.volumeBtn = volBtn;
volWrap.appendChild(volBtn);
bottom.appendChild(volWrap);
}
// 设置按钮(右侧组)
var settingsBtn = document.createElement('button');
settingsBtn.className = 'cp-btn cp-settings-btn';
settingsBtn.setAttribute('aria-label', '设置');
settingsBtn.innerHTML = ICONS.settings;
this.settingsBtn = settingsBtn;
bottom.appendChild(settingsBtn);
// 设置面板(绝对定位,不影响布局)
var settingsPanel = document.createElement('div');
settingsPanel.className = 'cp-settings-panel';
settingsPanel.innerHTML = `
<div class="cp-settings-title">设置</div>
<div class="cp-settings-item" data-setting="speed">
<span>倍速</span>
<select id="cp-settings-speed-select"></select>
</div>
<div class="cp-settings-item" data-setting="subtitles">
<span>字幕</span>
<select id="cp-settings-subtitles"></select>
</div>
<div class="cp-settings-item" data-setting="quality">
<span>清晰度</span>
<select id="cp-settings-quality"></select>
</div>
<div class="cp-settings-item" data-setting="download" style="display:none;">
<span>下载</span>
<span class="cp-settings-value" id="cp-settings-download">⬇ 下载视频</span>
</div>
`;
this.settingsPanel = settingsPanel;
controls.appendChild(settingsPanel);
// 全屏按钮(右侧组,最右)
if (this.controlsList.includes('fullscreen')) {
var fsBtn = document.createElement('button');
fsBtn.className = 'cp-btn cp-fullscreen-btn';
fsBtn.setAttribute('aria-label', '全屏');
fsBtn.setAttribute('aria-pressed', 'false');
fsBtn.innerHTML = ICONS.fullscreen;
this.fullscreenBtn = fsBtn;
bottom.appendChild(fsBtn);
}
controls.appendChild(bottom);
wrapper.appendChild(controls);
this.controlsEl = controls;
this.video.volume = this.isMuted ? 0 : (this.volume || 1);
this.video.muted = this.isMuted;
// 绑定设置面板下载点击事件
var downloadItem = this.settingsPanel.querySelector('[data-setting="download"]');
if (downloadItem) {
downloadItem.addEventListener('click', function(e) {
e.stopPropagation();
self._downloadCurrentVideo();
});
}
// 初始化设置面板选项
this._updateSettingsPanel();
// 绑定设置面板中速度选择的 change 事件(一次性)
var speedSelect = this.settingsPanel.querySelector('#cp-settings-speed-select');
if (speedSelect) {
speedSelect.addEventListener('change', function (e) {
var rate = parseFloat(this.value);
if (!isNaN(rate)) {
self.setPlaybackRate(rate);
}
});
}
};
// ---------- 更新设置面板内容 ----------
VideoPlayer.prototype._updateSettingsPanel = function () {
var panel = this.settingsPanel;
if (!panel) return;
// ---- 倍速下拉 ----
var speedSelect = panel.querySelector('#cp-settings-speed-select');
if (speedSelect) {
var currentRate = this.playbackRate || 1;
while (speedSelect.options.length) speedSelect.remove(0);
var rates = this.config.playbackRates || [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 5.0];
rates.forEach(function (r) {
var opt = document.createElement('option');
opt.value = r;
opt.textContent = (r === 1 ? '1×' : r + '×');
if (Math.abs(r - currentRate) < 0.001) opt.selected = true;
speedSelect.appendChild(opt);
});
// 若当前值不在列表中,补一个自定义项
var found = false;
for (var i = 0; i < speedSelect.options.length; i++) {
if (Math.abs(parseFloat(speedSelect.options[i].value) - currentRate) < 0.001) {
found = true;
speedSelect.selectedIndex = i;
break;
}
}
if (!found) {
var customOpt = document.createElement('option');
customOpt.value = currentRate;
customOpt.textContent = currentRate + '×';
customOpt.selected = true;
speedSelect.appendChild(customOpt);
}
}
// ---- 自定义倍速输入框(仅添加一次) ----
var speedItem = panel.querySelector('[data-setting="speed"]');
if (speedItem && !speedItem.querySelector('.cp-custom-speed-wrap')) {
var wrap = document.createElement('div');
wrap.className = 'cp-custom-speed-wrap';
var label = document.createElement('span');
label.className = 'cp-speed-label';
label.textContent = '自定义';
wrap.appendChild(label);
var input = document.createElement('input');
input.type = 'number';
input.step = '0.25';
input.min = '0.1';
input.max = '16';
input.placeholder = '倍速';
input.value = this.playbackRate || 1;
wrap.appendChild(input);
var applyBtn = document.createElement('button');
applyBtn.className = 'cp-speed-apply';
applyBtn.textContent = '应用';
wrap.appendChild(applyBtn);
var self = this;
var applySpeed = function() {
var val = parseFloat(input.value);
if (!isNaN(val) && val > 0) {
self.setPlaybackRate(val);
// 同步下拉选中
if (speedSelect) {
var found = false;
for (var i = 0; i < speedSelect.options.length; i++) {
if (Math.abs(parseFloat(speedSelect.options[i].value) - val) < 0.001) {
speedSelect.selectedIndex = i;
found = true;
break;
}
}
if (!found) {
var opt = document.createElement('option');
opt.value = val;
opt.textContent = val + '×';
opt.selected = true;
speedSelect.appendChild(opt);
}
}
input.value = val;
} else {
input.style.borderColor = '#ff4444';
setTimeout(function() { input.style.borderColor = ''; }, 800);
}
};
applyBtn.addEventListener('click', applySpeed);
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') applySpeed(); });
input.addEventListener('blur', function() {
var val = parseFloat(input.value);
if (isNaN(val) || val <= 0) input.value = self.playbackRate || 1;
});
// 监听外部倍速变化
this.on('playbackRateChange', function(rate) { input.value = rate; });
speedItem.appendChild(wrap);
} else if (speedItem) {
var existingInput = speedItem.querySelector('.cp-custom-speed-wrap input');
if (existingInput) existingInput.value = this.playbackRate || 1;
}
// ---- 字幕 ----
var subSelect = panel.querySelector('#cp-settings-subtitles');
if (subSelect) {
while (subSelect.options.length) subSelect.remove(0);
var offOpt = document.createElement('option');
offOpt.value = 'off';
offOpt.textContent = '关闭';
subSelect.appendChild(offOpt);
var self = this;
this._trackElements.forEach(function (track, index) {
var opt = document.createElement('option');
opt.value = track.srclang;
opt.textContent = track.label || track.srclang || ('字幕' + (index+1));
if (self._currentSubtitleTrack && self._currentSubtitleTrack.srclang === track.srclang) {
opt.selected = true;
}
subSelect.appendChild(opt);
});
if (this._currentSubtitleTrack) subSelect.value = this._currentSubtitleTrack.srclang;
else subSelect.value = 'off';
subSelect.onchange = function () {
var val = this.value;
if (val === 'off') {
self._currentSubtitleTrack = null;
self._trackElements.forEach(function (t) { t.mode = 'hidden'; });
} else {
var selected = null;
self._trackElements.forEach(function (t) {
if (t.srclang === val) { t.mode = 'showing'; selected = t; }
else t.mode = 'hidden';
});
self._currentSubtitleTrack = selected;
}
if (self.subtitleSelect) self.subtitleSelect.value = val;
self.emit('subtitleTrackChange', val);
if (typeof self.config.onSubtitleTrackChange === 'function') {
self.config.onSubtitleTrackChange.call(self, val);
}
};
}
// ---- 清晰度 ----
var qualitySelect = panel.querySelector('#cp-settings-quality');
if (qualitySelect) {
while (qualitySelect.options.length) qualitySelect.remove(0);
var qualities = this.getQualities();
if (qualities.length === 0) {
qualitySelect.parentNode.style.display = 'none';
} else {
qualitySelect.parentNode.style.display = 'flex';
var self = this;
qualities.forEach(function (q) {
var opt = document.createElement('option');
opt.value = q.index;
opt.textContent = q.label || (q.height ? q.height + 'p' : 'Auto');
if (self._currentQualityIndex === q.index) opt.selected = true;
qualitySelect.appendChild(opt);
});
qualitySelect.onchange = function () {
var idx = parseInt(this.value, 10);
self.setQuality(idx);
};
}
}
// ---- 下载项显示条件 ----
var downloadItem = panel.querySelector('[data-setting="download"]');
if (downloadItem) {
var isM3U8 = this._isM3U8Source();
if (isM3U8 || !this._currentSource) {
downloadItem.style.display = 'none';
} else {
downloadItem.style.display = 'flex';
}
}
};
// ---------- 判断当前是否为 m3u8 ----------
VideoPlayer.prototype._isM3U8Source = function () {
if (this._hls) return true; // HLS 已初始化视为 m3u8
var src = this.video.currentSrc || this.video.src || '';
if (/\.m3u8/i.test(src)) return true;
if (this._currentSource && this._currentSource.type === 'application/x-mpegURL') return true;
return false;
};
// ---------- 下载当前视频(直接触发下载,无任何判断) ----------
VideoPlayer.prototype._downloadCurrentVideo = function () {
var src = this.video.currentSrc || this.video.src;
if (!src) {
console.warn('[VideoPlayer] 无视频源可下载');
return;
}
this._triggerDownload(src, 'video.mp4');
};
VideoPlayer.prototype._triggerDownload = function (url, filename) {
var a = document.createElement('a');
a.href = url;
a.download = filename || 'video.mp4';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ---------- HLS 初始化(已修改:支持从 video 内部 source 标签提取) ----------
VideoPlayer.prototype._initHLS = function (callback) {
var sources = this.config.sources || [];
// 如果 config.sources 为空,尝试从 video 内部已有的 source 标签提取
if (!sources.length) {
var sourceEls = this.video.querySelectorAll('source');
for (var i = 0; i < sourceEls.length; i++) {
var src = sourceEls[i].getAttribute('src');
var type = sourceEls[i].getAttribute('type') || '';
if (src) {
sources.push({ src: src, type: type });
}
}
// 将提取的 sources 也保存到 config 中,供后续使用
if (sources.length) this.config.sources = sources;
}
var hasHLS = sources.some(function (s) {
return (typeof s === 'string' ? false : s.type === 'application/x-mpegURL');
});
if (!hasHLS) {
if (callback) callback();
return;
}
var self = this;
var done = function() {
self._setupHls();
if (callback) callback();
};
if (global.Hls) {
done();
} else {
loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js')
.then(done)
.catch(function (err) {
console.warn('[VideoPlayer] hls.js 加载失败', err);
if (typeof self.config.onError === 'function') {
self.config.onError({ message: 'hls.js 加载失败,无法播放 HLS' });
}
if (callback) callback();
});
}
};
VideoPlayer.prototype._setupHls = function () {
var video = this.video;
var hlsConfig = extend({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 30
}, this.config.hlsConfig || {});
var hls = new global.Hls(hlsConfig);
this._hls = hls;
hls.on(global.Hls.Events.ERROR, this._onHlsError);
hls.on(global.Hls.Events.LEVEL_SWITCHED, this._onHlsLevelSwitched);
hls.on(global.Hls.Events.AUDIO_TRACK_SWITCHED, this._onHlsAudioTrackSwitched);
};
VideoPlayer.prototype._onHlsError = function (event, data) {
if (!this._hls) return;
if (data.fatal) {
switch (data.type) {
case global.Hls.ErrorTypes.NETWORK_ERROR:
this._hls.startLoad();
break;
case global.Hls.ErrorTypes.MEDIA_ERROR:
this._hls.recoverMediaError();
break;
default:
console.error('[VideoPlayer] HLS 致命错误', data);
this._onError({ message: 'HLS 播放失败' });
break;
}
}
};
VideoPlayer.prototype._onHlsLevelSwitched = function (event, data) {
this._currentQualityIndex = data.level;
this.emit('qualityChange', { level: data.level, auto: data.auto });
if (typeof this.config.onQualityChange === 'function') this.config.onQualityChange.call(this, data);
this._savePreferencesIfNeeded();
if (this._isDebugVisible) this._updateDebugPanel();
this._updateSettingsPanel();
};
VideoPlayer.prototype._onHlsAudioTrackSwitched = function (event, data) {
this._currentAudioTrackIndex = data.id;
this.emit('audioTrackChange', { id: data.id });
if (typeof this.config.onAudioTrackChange === 'function') this.config.onAudioTrackChange.call(this, data);
};
// ---------- 源加载(已修改:HLS 时直接使用 hls.js,不创建 source 标签) ----------
VideoPlayer.prototype._loadSource = function (sources, sourceIndex) {
if (!sources || !sources.length) return;
if (sourceIndex === undefined) sourceIndex = 0;
if (this._sourceCleanup) {
this._sourceCleanup();
this._sourceCleanup = null;
}
this._sourceIndex = sourceIndex;
var self = this;
var video = this.video;
// 移除已有的 source 标签(如果有)
video.querySelectorAll('source').forEach(function (s) { s.remove(); });
var source = sources[sourceIndex];
if (!source) {
this._onError({ message: '所有源加载失败' });
return;
}
if (typeof source === 'string') source = { src: source };
this._currentSource = source;
if (this._hls && source.type === 'application/x-mpegURL') {
// 使用 hls.js 加载
if (this._hls.media !== video) {
this._hls.attachMedia(video);
}
this._hls.loadSource(source.src);
} else {
// 非 HLS,创建 source 并加载
var sourceEl = document.createElement('source');
sourceEl.src = source.src;
if (source.type) sourceEl.type = source.type;
video.appendChild(sourceEl);
video.load();
}
var cleanup = function () {
video.removeEventListener('error', onError);
video.removeEventListener('canplay', onCanPlay);
if (self._sourceCleanup === cleanup) {
self._sourceCleanup = null;
}
};
var onError = function () {
cleanup();
if (self._sourceIndex < sources.length - 1) {
self._loadSource(sources, self._sourceIndex + 1);
self.emit('sourceChange', { index: self._sourceIndex });
if (typeof self.config.onSourceChange === 'function') {
self.config.onSourceChange.call(self, self._sourceIndex);
}
} else {
self._onError({ message: '视频加载失败,无备用源' });
}
};
var onCanPlay = function () {
cleanup();
self._updateSettingsPanel();
};
video.addEventListener('error', onError);
video.addEventListener('canplay', onCanPlay, { once: true });
this._sourceCleanup = cleanup;
};
// ---------- 字幕初始化 ----------
VideoPlayer.prototype._initSubtitles = function () {
this._clearSubtitles();
var tracks = this._tracks;
if (!tracks || !tracks.length) {
return;
}
var video = this.video;
var self = this;
var defaultLang = navigator.language || navigator.userLanguage;
tracks.forEach(function (track, index) {
var src = track.src;
var isSrt = /\.srt$/i.test(src);
if (isSrt) {
fetch(src)
.then(function (res) { return res.text(); })
.then(function (text) {
var vttUrl = self._srtToVttBlobUrl(text);
self._blobUrls.push(vttUrl);
self._addTrackElement(track, index, vttUrl, defaultLang);
})
.catch(function (err) {
console.warn('[VideoPlayer] SRT 转换失败', err);
self._addTrackElement(track, index, src, defaultLang);
});
} else {
self._addTrackElement(track, index, src, defaultLang);
}
});
};
VideoPlayer.prototype._clearSubtitles = function () {
var video = this.video;
if (!video) return;
video.querySelectorAll('track').forEach(function (track) {
track.parentNode.removeChild(track);
});
this._trackElements = [];
this._currentSubtitleTrack = null;
if (this.subtitleSelect) {
while (this.subtitleSelect.options.length > 1) {
this.subtitleSelect.remove(1);
}
this.subtitleSelect.value = 'off';
this.subtitleSelect.style.display = 'none';
}
this._updateSettingsPanel();
};
VideoPlayer.prototype._addTrackElement = function (track, index, src, defaultLang) {
var video = this.video;
var trackEl = document.createElement('track');
trackEl.kind = 'subtitles';
trackEl.srclang = track.srclang || ('sub' + index);
trackEl.label = track.label || track.srclang || ('字幕' + (index + 1));
trackEl.src = src;
trackEl.default = track.default || false;
video.appendChild(trackEl);
this._trackElements.push(trackEl);
if (this._currentSubtitleTrack === null && (track.default || (defaultLang && defaultLang.indexOf(track.srclang) !== -1))) {
trackEl.mode = 'showing';
this._currentSubtitleTrack = trackEl;
}
if (this.subtitleSelect) {
this.subtitleSelect.style.display = '';
var opt = document.createElement('option');
opt.value = trackEl.srclang;
opt.textContent = trackEl.label;
if (trackEl.mode === 'showing') opt.selected = true;
this.subtitleSelect.appendChild(opt);
}
this._updateSettingsPanel();
};
VideoPlayer.prototype._srtToVttBlobUrl = function (srtText) {
var lines = srtText.replace(/\r+/g, '').split('\n');
var vttLines = ['WEBVTT', ''];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.indexOf('-->') !== -1) {
if (/\d+:\d{2}:\d{2}[,.]\d{3}/.test(line)) {
line = line.replace(/(\d+):(\d{2}):(\d{2})[,.](\d{3})/g, function(match, h, m, s, ms) {
var hours = parseInt(h, 10);
var hh = (hours < 10 ? '0' : '') + hours;
return hh + ':' + m + ':' + s + '.' + ms;
});
} else {
line = line.replace(/(\d{1,2}):(\d{2})[,.](\d{3})/g, function(match, m, s, ms) {
return '00:' + (m.length === 1 ? '0' + m : m) + ':' + s + '.' + ms;
});
}
}
vttLines.push(line);
}
return URL.createObjectURL(new Blob([vttLines.join('\n')], { type: 'text/vtt' }));
};
VideoPlayer.prototype._applySubtitleStyle = function (style) {
if (!this.wrapper) return;
var mapping = {
fontSize: '--cp-subtitle-size',
color: '--cp-subtitle-color',
backgroundColor: '--cp-subtitle-bg',
fontFamily: '--cp-subtitle-font',
textShadow: '--cp-subtitle-shadow'
};
for (var key in mapping) {
if (style[key]) this.wrapper.style.setProperty(mapping[key], style[key]);
}
};
VideoPlayer.prototype.setSubtitleDelay = function (seconds) {
this._subtitleDelay = seconds;
this.emit('subtitleDelayChange', seconds);
};
// ---------- 清晰度、音轨 ----------
VideoPlayer.prototype.getQualities = function () {
if (this._hls && this._hls.levels) {
return this._hls.levels.map(function (level, index) {
return { index: index, height: level.height, bitrate: level.bitrate, label: (level.height ? level.height + 'p' : 'Auto') };
});
}
return [];
};
VideoPlayer.prototype.setQuality = function (index) {
if (!this._hls) return;
if (index === -1) {
this._hls.currentLevel = -1;
} else if (index >= 0 && index < this._hls.levels.length) {
this._hls.currentLevel = index;
}
this._currentQualityIndex = index;
this._savePreferencesIfNeeded();
if (this._isDebugVisible) this._updateDebugPanel();
this._updateSettingsPanel();
};
VideoPlayer.prototype.getAudioTracks = function () {
if (this._hls && this._hls.audioTracks) {
return this._hls.audioTracks.map(function (track, index) {
return { index: index, lang: track.lang, name: track.name, default: track.default };
});
}
if (this.video.audioTracks) {
var tracks = [];
for (var i = 0; i < this.video.audioTracks.length; i++) {
tracks.push({ index: i, lang: this.video.audioTracks[i].language, name: this.video.audioTracks[i].label });
}
return tracks;
}
return [];
};
VideoPlayer.prototype.setAudioTrack = function (index) {
if (this._hls && this._hls.audioTracks) {
this._hls.audioTrack = index;
this._currentAudioTrackIndex = index;
this._savePreferencesIfNeeded();
} else if (this.video.audioTracks && this.video.audioTracks[index]) {
for (var i = 0; i < this.video.audioTracks.length; i++) {
this.video.audioTracks[i].enabled = (i === index);
}
}
};
// ---------- 播放列表 ----------
VideoPlayer.prototype.setPlaylist = function (playlist) {
this._playlist = playlist || [];
this._playlistIndex = 0;
};
VideoPlayer.prototype.playNext = function () {
if (this._playlist.length === 0) return;
this._playlistIndex = (this._playlistIndex + 1) % this._playlist.length;
this._loadPlaylistItem(this._playlistIndex);
};
VideoPlayer.prototype.playPrevious = function () {
if (this._playlist.length === 0) return;
this._playlistIndex = (this._playlistIndex - 1 + this._playlist.length) % this._playlist.length;
this._loadPlaylistItem(this._playlistIndex);
};
VideoPlayer.prototype._loadPlaylistItem = function (index) {
if (!this._playlist[index]) return;
var item = this._playlist[index];
this._playlistIndex = index;
this.config.sources = [{ src: item.src, type: item.type }];
this.load(this.config.sources);
if (item.poster && this.posterEl) {
this.posterEl.style.backgroundImage = 'url(' + item.poster + ')';
}
if (item.title) {
this.emit('titleChange', item.title);
}
};
// ---------- 快照 ----------
VideoPlayer.prototype.captureSnapshot = function (type, quality) {
type = type || 'image/png';
quality = quality || 0.8;
var video = this.video;
var canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
var ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL(type, quality);
};
// ---------- 音频增强 ----------
VideoPlayer.prototype._ensureAudioContext = function () {
if (!this._audioContext) {
var AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return null;
try {
this._audioContext = new AudioContext();
var source = this._audioContext.createMediaElementSource(this.video);
this._gainNode = this._audioContext.createGain();
this._gainNode.gain.value = this.config.audioGain || 1;
source.connect(this._gainNode);
if (this.config.downmixToStereo) {
this._gainNode.channelCount = 2;
this._gainNode.channelCountMode = 'explicit';
this._gainNode.channelInterpretation = 'speakers';
}
if (this.config.eqSettings) {
this._setupEQ(this.config.eqSettings);
} else {
this._gainNode.connect(this._audioContext.destination);
}
} catch (err) {
console.warn('[VideoPlayer] AudioContext 创建失败', err);
this._audioContext = null;
this._gainNode = null;
return null;
}
}
if (this._audioContext && this._audioContext.state === 'suspended') {
this._audioContext.resume().catch(function(e) {
console.warn('[VideoPlayer] AudioContext resume failed', e);
});
}
return this._audioContext;
};
VideoPlayer.prototype._suspendAudioContext = function () {
if (this._audioContext && this._audioContext.state === 'running') {
this._audioContext.suspend().catch(function(e) {
console.warn('[VideoPlayer] AudioContext suspend failed', e);
});
}
};
VideoPlayer.prototype._resumeAudioContext = function () {
if (this._audioContext && this._audioContext.state === 'suspended') {
this._audioContext.resume().catch(function(e) {
console.warn('[VideoPlayer] AudioContext resume failed', e);
});
}
};
VideoPlayer.prototype._setupEQ = function (eq) {
if (!this._audioContext || !this._gainNode) return;
this._gainNode.disconnect();
this._eqNodes.forEach(function (node) { node.disconnect(); });
this._eqNodes = [];
var frequencies = { bass: 100, mid: 1000, treble: 5000 };
var self = this;
var lastNode = this._gainNode;
var hasFilter = false;
['bass', 'mid', 'treble'].forEach(function (band) {
if (typeof eq[band] !== 'number') return;
hasFilter = true;
var filter = self._audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = frequencies[band];
filter.gain.value = eq[band];
filter.Q.value = 1.0;
lastNode.connect(filter);
lastNode = filter;
self._eqNodes.push(filter);
});
if (hasFilter) {
lastNode.connect(self._audioContext.destination);
} else {
this._gainNode.connect(this._audioContext.destination);
}
};
VideoPlayer.prototype.setAudioGain = function (gain) {
this.config.audioGain = gain;
if (this._gainNode) {
this._gainNode.gain.value = gain;
}
};
VideoPlayer.prototype.setEQ = function (eq) {
this.config.eqSettings = eq;
if (this._audioContext) {
this._setupEQ(eq);
}
};
VideoPlayer.prototype.setDownmixToStereo = function (bool) {
this.config.downmixToStereo = bool;
if (this._audioContext && this._gainNode) {
if (bool) {
this._gainNode.channelCount = 2;
this._gainNode.channelCountMode = 'explicit';
this._gainNode.channelInterpretation = 'speakers';
} else {
this._gainNode.channelCountMode = 'max';
}
}
};
// ---------- 视频处理 ----------
VideoPlayer.prototype.setVideoFilter = function (filter) {
if (!filter) filter = {};
var filterStr = '';
if (filter.brightness !== undefined) filterStr += 'brightness(' + filter.brightness + ') ';
if (filter.contrast !== undefined) filterStr += 'contrast(' + filter.contrast + ') ';
if (filter.saturate !== undefined) filterStr += 'saturate(' + filter.saturate + ') ';
if (filter.grayscale !== undefined) filterStr += 'grayscale(' + filter.grayscale + ') ';
if (filter.blur !== undefined) filterStr += 'blur(' + filter.blur + 'px) ';
if (filter.hueRotate !== undefined) filterStr += 'hue-rotate(' + filter.hueRotate + 'deg) ';
if (filter.sepia !== undefined) filterStr += 'sepia(' + filter.sepia + ') ';
this.video.style.filter = filterStr.trim();
};
VideoPlayer.prototype.setVideoZoom = function (scale) {
this.config.videoZoom = scale;
this._applyVideoTransform();
};
VideoPlayer.prototype.setVideoPan = function (x, y) {
this.config.videoPan = { x: x, y: y };
this._applyVideoTransform();
};
VideoPlayer.prototype.setRotation = function (deg) {
this.config.rotation = deg;
this._applyVideoTransform();
};
VideoPlayer.prototype._applyVideoTransform = function () {
var transform = '';
if (this.config.rotation) transform += 'rotate(' + this.config.rotation + 'deg) ';
if (this.config.videoZoom !== 1) transform += 'scale(' + this.config.videoZoom + ') ';
if (this.config.videoPan.x || this.config.videoPan.y) {
transform += 'translate(' + this.config.videoPan.x + 'px,' + this.config.videoPan.y + 'px) ';
}
this.video.style.transform = transform.trim();
};
// ---------- 进度记忆与偏好 ----------
VideoPlayer.prototype._loadPreferences = function () {
var prefs = localStorage.getItem(this._prefsKey);
if (prefs) {
try {
var data = JSON.parse(prefs);
if (data.volume !== undefined) this.setVolume(data.volume);
if (data.muted !== undefined && data.muted) this.toggleMute();
if (data.playbackRate) this.setPlaybackRate(data.playbackRate);
if (data.quality !== undefined) this.setQuality(data.quality);
if (data.audioTrack !== undefined) this.setAudioTrack(data.audioTrack);
} catch (e) {}
}
if (this.config.enableProgressMemory) {
var progress = localStorage.getItem(this._progressKey);
if (progress) {
var time = parseFloat(progress);
if (!isNaN(time)) this.video.currentTime = time;
}
}
};
VideoPlayer.prototype._savePreferencesIfNeeded = function () {
if (!this.config.enablePreferenceMemory) return;
var prefs = {
volume: this.volume,
muted: this.isMuted,
playbackRate: this.playbackRate,
quality: this._currentQualityIndex,
audioTrack: this._currentAudioTrackIndex
};
localStorage.setItem(this._prefsKey, JSON.stringify(prefs));
};
VideoPlayer.prototype.saveProgress = function () {
if (!this.config.enableProgressMemory) return;
localStorage.setItem(this._progressKey, this.video.currentTime);
};
// ---------- 监控 ----------
VideoPlayer.prototype._startMonitoring = function () {
var self = this;
this._bufferInterval = setInterval(function () {
self._emitBufferInfo();
self._emitPlaybackQuality();
if (self._isDebugVisible) self._updateDebugPanel();
}, 1000);
};
VideoPlayer.prototype._emitBufferInfo = function () {
var video = this.video;
var buffered = video.buffered;
var currentTime = video.currentTime;
var bufferedEnd = 0;
for (var i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) {
bufferedEnd = buffered.end(i);
break;
}
}
var percent = video.duration ? (bufferedEnd / video.duration * 100) : 0;
if (this.progressBuffered) {
this.progressBuffered.style.width = percent + '%';
}
this.emit('bufferProgress', { percent: percent, bufferedEnd: bufferedEnd, duration: video.duration });
if (typeof this.config.onBufferProgress === 'function') this.config.onBufferProgress.call(this, { percent: percent, bufferedEnd: bufferedEnd });
};
VideoPlayer.prototype._emitPlaybackQuality = function () {
if (this.video.getVideoPlaybackQuality) {
var stats = this.video.getVideoPlaybackQuality();
this.emit('playbackQuality', stats);
if (typeof this.config.onPlaybackQuality === 'function') this.config.onPlaybackQuality.call(this, stats);
}
};
// ---------- 调试面板 ----------
VideoPlayer.prototype._toggleDebugPanel = function () {
if (this._isDebugVisible) {
this._closeDebugPanel();
} else {
this._showDebugPanel();
}
};
VideoPlayer.prototype._showDebugPanel = function () {
if (!this.debugPanelEl) return;
this.debugPanelEl.classList.add('show');
this._isDebugVisible = true;
this._updateDebugPanel();
};
VideoPlayer.prototype._closeDebugPanel = function () {
if (!this.debugPanelEl) return;
this.debugPanelEl.classList.remove('show');
this._isDebugVisible = false;
};
VideoPlayer.prototype._updateDebugPanel = function () {
if (!this.debugPanelEl || !this._isDebugVisible) return;
var video = this.video;
var resolutionText = video.videoWidth ? video.videoWidth + ' x ' + video.videoHeight : '--';
var resolutionEl = this.debugPanelEl.querySelector('#cp-debug-resolution');
if (resolutionEl) resolutionEl.textContent = resolutionText;
var bitrateText = '--';
if (this._hls && this._hls.levels && this._hls.levels.length > 0) {
var currentLevel = this._hls.currentLevel;
if (currentLevel === -1) {
bitrateText = 'Auto';
} else if (currentLevel >= 0 && currentLevel < this._hls.levels.length) {
var level = this._hls.levels[currentLevel];
bitrateText = level.bitrate ? (level.bitrate / 1000).toFixed(1) + ' kbps' : '--';
}
}
var bitrateEl = this.debugPanelEl.querySelector('#cp-debug-bitrate');
if (bitrateEl) bitrateEl.textContent = bitrateText;
var buffered = video.buffered;
var currentTime = video.currentTime;
var bufferedEnd = 0;
for (var i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) {
bufferedEnd = buffered.end(i);
break;
}
}
var bufferPercent = video.duration ? (bufferedEnd / video.duration * 100) : 0;
var bufferEl = this.debugPanelEl.querySelector('#cp-debug-buffer');
if (bufferEl) bufferEl.textContent = bufferPercent.toFixed(1) + '% (' + bufferedEnd.toFixed(1) + 's)';
var droppedText = '--';
if (video.getVideoPlaybackQuality) {
var stats = video.getVideoPlaybackQuality();
droppedText = stats.droppedVideoFrames + ' / ' + stats.totalVideoFrames;
}
var droppedEl = this.debugPanelEl.querySelector('#cp-debug-dropped');
if (droppedEl) droppedEl.textContent = droppedText;
var timeEl = this.debugPanelEl.querySelector('#cp-debug-time');
if (timeEl) timeEl.textContent = this._formatTime(video.currentTime) + ' / ' + this._formatTime(video.duration);
};
VideoPlayer.prototype._formatTime = function (t) {
t = Math.floor(t || 0);
var h = Math.floor(t / 3600);
var m = Math.floor((t % 3600) / 60);
var s = t % 60;
return (h > 0 ? h + ':' : '') + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
};
// ---------- 移动端手势 ----------
VideoPlayer.prototype._showGestureIndicator = function (type) {
if (!this.gestureIndicatorEl) return;
if (type === 'brightness') {
this.gestureIndicatorIconEl.innerHTML = ICONS.brightness;
} else if (type === 'volume') {
this.gestureIndicatorIconEl.innerHTML = ICONS.volumeHigh;
}
this.gestureIndicatorEl.classList.add('show');
};
VideoPlayer.prototype._updateGestureIndicator = function (type, percent) {
if (!this.gestureIndicatorEl) return;
if (type === 'brightness') {
this.gestureIndicatorIconEl.innerHTML = ICONS.brightness;
} else if (type === 'volume') {
this.gestureIndicatorIconEl.innerHTML = (percent <= 0) ? ICONS.volumeMuted : ICONS.volumeHigh;
}
this.gestureIndicatorValueEl.textContent = Math.round(percent) + '%';
};
VideoPlayer.prototype._hideGestureIndicators = function () {
if (!this.gestureIndicatorEl) return;
if (this._gestureIndicatorTimer) clearTimeout(this._gestureIndicatorTimer);
this._gestureIndicatorTimer = setTimeout(function () {
if (this.gestureIndicatorEl) this.gestureIndicatorEl.classList.remove('show');
}.bind(this), 500);
};
VideoPlayer.prototype._onGestureStart = function (e) {
if (this._locked) return;
var touch = e.touches[0];
this._gesture.startX = touch.clientX;
this._gesture.startY = touch.clientY;
this._gesture.isGesture = true;
this._gesture.type = null;
this._gesture.longPressTimer = setTimeout(this._onLongPressTimer, 500);
if (this._gestureIndicatorTimer) clearTimeout(this._gestureIndicatorTimer);
var currentFilter = this.video.style.filter;
var brightness = 1;
if (currentFilter) {
var match = currentFilter.match(/brightness\(([^)]+)\)/);
if (match) brightness = parseFloat(match[1]);
}
this._gesture.startBrightnessPercent = Math.min(100, Math.max(0, Math.round(brightness * 100)));
this._gesture.startVolume = this.volume;
};
VideoPlayer.prototype._onGestureMove = function (e) {
if (!this._gesture.isGesture) return;
var touch = e.touches[0];
var dx = touch.clientX - this._gesture.startX;
var dy = touch.clientY - this._gesture.startY;
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
if (this._gesture.longPressTimer) {
clearTimeout(this._gesture.longPressTimer);
this._gesture.longPressTimer = null;
}
if (!this._gesture.type) {
if (Math.abs(dx) > Math.abs(dy)) {
this._gesture.type = 'seek';
} else {
var rect = this.video.getBoundingClientRect();
if (touch.clientX < rect.left + rect.width / 2) {
this._gesture.type = 'brightness';
} else {
this._gesture.type = 'volume';
}
this._showGestureIndicator(this._gesture.type);
}
}
if (this._gesture.type === 'seek') {
var seekStep = dx > 0 ? 10 : -10;
this.seek(this.video.currentTime + seekStep);
this._gesture.startX = touch.clientX;
this._gesture.startY = touch.clientY;
} else if (this._gesture.type === 'volume') {
var totalDyVolume = touch.clientY - this._gesture.startY;
var volDelta = -totalDyVolume / 200 * 0.8;
var newVolume = Math.min(1, Math.max(0, this._gesture.startVolume + volDelta));
this.setVolume(newVolume);
this._updateGestureIndicator('volume', newVolume * 100);
} else if (this._gesture.type === 'brightness') {
var totalDyBrightness = touch.clientY - this._gesture.startY;
var brightnessDelta = -totalDyBrightness / 200 * 100 * 0.8;
var base = Math.min(100, Math.max(0, this._gesture.startBrightnessPercent));
var newBrightnessPercent = Math.min(100, Math.max(0, base + brightnessDelta));
var newBrightness = newBrightnessPercent / 100;
var currentFilter = this.video.style.filter || '';
var newFilter = currentFilter.replace(/brightness\([^)]*\)/g, '').trim();
newFilter = (newFilter ? newFilter + ' ' : '') + 'brightness(' + newBrightness + ')';
this.video.style.filter = newFilter;
this._updateGestureIndicator('brightness', newBrightnessPercent);
}
};
VideoPlayer.prototype._onGestureEnd = function () {
if (this._gesture.longPressTimer) {
clearTimeout(this._gesture.longPressTimer);
this._gesture.longPressTimer = null;
}
this._gesture.isGesture = false;
this._hideGestureIndicators();
};
VideoPlayer.prototype._onLongPressTimer = function () {
this._gesture.tempRate = this.playbackRate;
this.setPlaybackRate(3.0);
this._gesture.isLongPress = true;
var self = this;
var onLongPressEnd = function () {
self.setPlaybackRate(self._gesture.tempRate || 1);
self._gesture.isLongPress = false;
document.removeEventListener('touchend', onLongPressEnd);
};
document.addEventListener('touchend', onLongPressEnd);
};
// ---------- 唤醒锁 ----------
VideoPlayer.prototype._requestWakeLock = async function () {
if (!this._wakeLockSupported) return;
try {
this._wakeLock = await navigator.wakeLock.request('screen');
this._wakeLock.addEventListener('release', function () { this._wakeLock = null; }.bind(this));
} catch (err) {
console.warn('[VideoPlayer] 唤醒锁定失败', err);
}
};
VideoPlayer.prototype._releaseWakeLock = function () {
if (this._wakeLock) {
this._wakeLock.release().catch(function () {});
this._wakeLock = null;
}
};
VideoPlayer.prototype._onVisibilityChange = function () {
if (document.hidden) {
this._suspendAudioContext();
if (this._wakeLock) {
this._releaseWakeLock();
}
} else if (!document.hidden) {
if (this.config.wakeLock && !this._wakeLock && !this.video.paused) {
this._requestWakeLock();
}
if (!this.video.paused) {
this._resumeAudioContext();
}
}
};
// ---------- 键盘快捷键 ----------
VideoPlayer.prototype._onKeydown = function (e) {
var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var shortcuts = this.config.keyboardShortcuts;
if (shortcuts === false) return;
var key = e.key.toLowerCase();
var handled = false;
if (shortcuts === true) {
if (key === ' ' || e.key === 'Space') { e.preventDefault(); this.toggle(); handled = true; }
else if (key === 'arrowright') { e.preventDefault(); this.seek(this.video.currentTime + 5); handled = true; }
else if (key === 'arrowleft') { e.preventDefault(); this.seek(this.video.currentTime - 5); handled = true; }
else if (key === 'f') { this.toggleFullscreen(); handled = true; }
else if (key === 'm') { this.toggleMute(); handled = true; }
else if (key === 'p') { this.togglePip(); handled = true; }
else if (key === 'o') { this.toggleOrientation(); handled = true; }
} else if (typeof shortcuts === 'object') {
if (shortcuts.play && key === shortcuts.play) { e.preventDefault(); this.toggle(); handled = true; }
else if (shortcuts.forward && key === shortcuts.forward) { e.preventDefault(); this.seek(this.video.currentTime + 5); handled = true; }
else if (shortcuts.backward && key === shortcuts.backward) { e.preventDefault(); this.seek(this.video.currentTime - 5); handled = true; }
else if (shortcuts.fullscreen && key === shortcuts.fullscreen) { this.toggleFullscreen(); handled = true; }
else if (shortcuts.mute && key === shortcuts.mute) { this.toggleMute(); handled = true; }
else if (shortcuts.pip && key === shortcuts.pip) { this.togglePip(); handled = true; }
}
if (handled) e.preventDefault();
};
// ---------- 控制栏显隐 ----------
VideoPlayer.prototype.toggleControls = function(show) {
if (!this.controlsEl || this._locked) return;
var visible = (typeof show === 'boolean') ? show : !this._controlsVisible;
this._controlsVisible = visible;
this.controlsEl.classList.toggle('cp-controls-visible', visible);
this.wrapper.classList.toggle('is-focused', visible);
};
// ---------- 设置面板 ----------
VideoPlayer.prototype._toggleSettingsPanel = function(e) {
e.stopPropagation();
if (this._locked) return;
var panel = this.settingsPanel;
if (!panel) return;
panel.classList.toggle('show');
this.settingsBtn.classList.toggle('active');
this._closeMenu();
if (this._isDebugVisible) this._closeDebugPanel();
if (panel.classList.contains('show')) {
this._updateSettingsPanel();
}
};
VideoPlayer.prototype._closeSettingsPanel = function() {
if (this.settingsPanel) {
this.settingsPanel.classList.remove('show');
if (this.settingsBtn) this.settingsBtn.classList.remove('active');
}
};
// ---------- 事件绑定 ----------
VideoPlayer.prototype._bindEvents = function () {
var video = this.video;
var self = this;
video.addEventListener('play', this._onPlay);
video.addEventListener('pause', this._onPause);
video.addEventListener('ended', this._onEnded);
video.addEventListener('timeupdate', this._onTimeUpdate);
video.addEventListener('loadedmetadata', this._onLoadedMetadata);
video.addEventListener('waiting', this._onWaiting);
video.addEventListener('canplay', this._onCanPlay);
video.addEventListener('error', this._onError);
video.addEventListener('volumechange', this._onVolumeChange);
video.addEventListener('enterpictureinpicture', this._onPipChange);
video.addEventListener('leavepictureinpicture', this._onPipChange);
video.addEventListener('stalled', this._onStalled);
video.addEventListener('playing', this._onPlaying);
video.addEventListener('progress', this._onVideoProgress);
video.addEventListener('dblclick', this.toggle);
video.addEventListener('touchstart', this._onGestureStart, { passive: false });
video.addEventListener('touchmove', this._onGestureMove, { passive: false });
video.addEventListener('touchend', this._onGestureEnd);
video.addEventListener('touchcancel', this._onGestureEnd);
if (this.progressEl) {
this.progressEl.addEventListener('mousedown', this._onProgressDown);
this.progressEl.addEventListener('touchstart', this._onProgressDown, { passive: false });
}
document.addEventListener('mousemove', this._onProgressMove);
document.addEventListener('mouseup', this._onProgressUp);
document.addEventListener('touchmove', this._onProgressMove, { passive: false });
document.addEventListener('touchend', this._onProgressUp);
if (this.playBtn) this.playBtn.addEventListener('click', this.toggle);
if (this.bigPlayBtn) this.bigPlayBtn.addEventListener('click', this.toggle);
if (this.volumeBtn) this.volumeBtn.addEventListener('click', this.toggleMute);
if (this.fullscreenBtn) this.fullscreenBtn.addEventListener('click', this.toggleFullscreen);
if (this.settingsBtn) this.settingsBtn.addEventListener('click', this._toggleSettingsPanel);
if (this.subtitleSelect) {
this.subtitleSelect.addEventListener('change', function () {
var val = self.subtitleSelect.value;
var selectedTrack = null;
self._trackElements.forEach(function (track) {
if (track.srclang === val) {
track.mode = 'showing';
selectedTrack = track;
} else {
track.mode = 'hidden';
}
});
self._currentSubtitleTrack = selectedTrack;
self.emit('subtitleTrackChange', val);
if (typeof self.config.onSubtitleTrackChange === 'function') {
self.config.onSubtitleTrackChange.call(self, val);
}
self._updateSettingsPanel();
});
}
if (this.config.keyboardShortcuts !== false) {
document.addEventListener('keydown', this._onKeydown);
}
document.addEventListener('fullscreenchange', this._onFullscreenChange);
document.addEventListener('webkitfullscreenchange', this._onFullscreenChange);
document.addEventListener('mozfullscreenchange', this._onFullscreenChange);
document.addEventListener('MSFullscreenChange', this._onFullscreenChange);
document.addEventListener('click', this._onOutsideClick);
window.addEventListener('resize', this._onResize);
document.addEventListener('visibilitychange', this._onVisibilityChange);
video.addEventListener('ended', function () {
if (self.config.autoNext && self._playlist.length > 0) {
self.playNext();
}
});
// ---- 控制栏交互 ----
var container = this.wrapper;
var showAndReset = function() {
if (self._locked) return;
self.toggleControls(true);
clearTimeout(self._hideControlsTimer);
var baseDelay = self.touch ? 3000 : 2000;
var delay = self.isFullscreen ? 4000 : baseDelay;
self._hideControlsTimer = setTimeout(function() {
if (!self.video.paused && !self.video.seeking) {
self.toggleControls(false);
}
}, delay);
};
container.addEventListener('touchstart', showAndReset, { passive: true });
container.addEventListener('touchmove', showAndReset, { passive: true });
container.addEventListener('mousemove', showAndReset);
container.addEventListener('mouseenter', showAndReset);
container.addEventListener('mouseleave', function() {
if (self.video.paused) {
clearTimeout(self._hideControlsTimer);
self.toggleControls(false);
}
});
video.addEventListener('play', function() {
self.toggleControls(true);
clearTimeout(self._hideControlsTimer);
});
video.addEventListener('pause', function() {
self.toggleControls(true);
clearTimeout(self._hideControlsTimer);
});
if (this.config.controlsInitiallyVisible) {
this.toggleControls(true);
setTimeout(function() {
if (!self.video.paused && !self.video.seeking) {
self.toggleControls(false);
}
}, 3000);
}
// ---- 音量滚轮微调 ----
if (this.volumeBtn) {
var volWrap = this.volumeBtn.parentNode;
if (volWrap) {
volWrap.addEventListener('wheel', function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.05 : 0.05;
var newVol = Math.min(1, Math.max(0, self.volume + delta));
self.setVolume(newVol);
}, { passive: false });
}
}
// ---- 点击外部关闭设置面板 ----
document.addEventListener('click', function(e) {
if (self.settingsPanel && self.settingsBtn) {
if (!self.settingsBtn.contains(e.target) && !self.settingsPanel.contains(e.target)) {
self._closeSettingsPanel();
}
}
});
};
// ---------- 事件处理 ----------
VideoPlayer.prototype._onPlay = function () {
if (this.playBtn) {
this.playBtn.innerHTML = ICONS.pause;
this.playBtn.setAttribute('aria-pressed', 'true');
}
if (this.bigPlayBtn) this.bigPlayBtn.classList.remove('show');
if (this.posterEl) this.posterEl.classList.add('hidden');
if (typeof this.config.onPlay === 'function') this.config.onPlay.call(this);
this.emit('play');
if (this.config.wakeLock) this._requestWakeLock();
this._resumeAudioContext();
};
VideoPlayer.prototype._onPause = function () {
if (this.playBtn) {
this.playBtn.innerHTML = ICONS.play;
this.playBtn.setAttribute('aria-pressed', 'false');
}
if (!this.video.ended && this.bigPlayBtn) this.bigPlayBtn.classList.add('show');
if (typeof this.config.onPause === 'function') this.config.onPause.call(this);
this.emit('pause');
this._releaseWakeLock();
this._suspendAudioContext();
this.toggleControls(true);
clearTimeout(this._hideControlsTimer);
};
VideoPlayer.prototype._onEnded = function () {
if (this.playBtn) {
this.playBtn.innerHTML = ICONS.play;
this.playBtn.setAttribute('aria-pressed', 'false');
}
if (this.bigPlayBtn) this.bigPlayBtn.classList.add('show');
if (typeof this.config.onEnded === 'function') this.config.onEnded.call(this);
this.emit('ended');
this.saveProgress();
};
VideoPlayer.prototype._onTimeUpdate = function () {
if (this.isDragging) return;
var current = this.video.currentTime || 0;
var duration = this.video.duration || 0;
var percent = duration ? (current / duration * 100) : 0;
if (this.progressFilled) this.progressFilled.style.width = percent + '%';
if (this.progressEl) {
this.progressEl.setAttribute('aria-valuenow', current);
this.progressEl.setAttribute('aria-valuemax', duration);
}
this._updateTimeDisplay(current, duration);
if (typeof this.config.onTimeUpdate === 'function') this.config.onTimeUpdate.call(this, current, duration);
this.emit('timeupdate', { currentTime: current, duration: duration });
if (this._abState === 'looping' && this._abA < this._abB) {
if (current >= this._abB) this.seek(this._abA);
}
if (this._chapters.length > 0) {
for (var i = this._chapters.length - 1; i >= 0; i--) {
if (current >= this._chapters[i].time) {
if (this._lastChapterIndex !== i) {
this._lastChapterIndex = i;
this.emit('chapter', this._chapters[i]);
if (typeof this.config.onChapter === 'function') this.config.onChapter.call(this, this._chapters[i]);
}
break;
}
}
}
if (this._abState !== 'off') {
this._updateABMarks();
}
};
VideoPlayer.prototype._onLoadedMetadata = function () {
var duration = this.video.duration || 0;
if (this.progressEl) this.progressEl.setAttribute('aria-valuemax', duration);
this._updateTimeDisplay(0, duration);
if (this.config.autoplay) this.play().catch(function () {});
if (this.config.enableProgressMemory) {
var saved = parseFloat(localStorage.getItem(this._progressKey));
if (!isNaN(saved)) this.video.currentTime = saved;
}
this._updateABMarks();
this._updateSettingsPanel();
};
VideoPlayer.prototype._onWaiting = function () {
if (this.wrapper) this.wrapper.classList.add('is-loading');
this.emit('waiting');
};
VideoPlayer.prototype._onCanPlay = function () {
if (this.wrapper) this.wrapper.classList.remove('is-loading');
this._stallRetries = 0;
this.emit('canplay');
this._updateSettingsPanel();
};
VideoPlayer.prototype._onError = function (e) {
if (this.wrapper) this.wrapper.classList.remove('is-loading');
if (typeof this.config.onError === 'function') this.config.onError.call(this, e);
this.emit('error', e);
};
VideoPlayer.prototype._onVolumeChange = function () {
var muted = this.video.muted;
var vol = this.video.volume;
this.isMuted = muted;
this.volume = muted ? 0 : vol;
if (this.volumeBtn) {
this.volumeBtn.innerHTML = muted ? ICONS.volumeMuted : ICONS.volumeHigh;
this.volumeBtn.setAttribute('aria-pressed', muted ? 'true' : 'false');
}
this._savePreferencesIfNeeded();
};
VideoPlayer.prototype._onPipChange = function () {
var isPip = !!document.pictureInPictureElement;
this.isPip = isPip;
this.emit('pipChange', isPip);
};
VideoPlayer.prototype._onStalled = function () {
if (this._stallRetries >= this._maxStallRetries) {
if (typeof this.config.onError === 'function') this.config.onError({ message: '视频加载停滞次数过多,放弃重试' });
return;
}
this._stallRetries++;
console.warn('[VideoPlayer] 检测到 stalled,执行第 ' + this._stallRetries + ' 次重试...');
var wasPaused = this.video.paused;
this.video.load();
if (!wasPaused) this.video.play().catch(function (err) { console.warn(err); });
};
VideoPlayer.prototype._onPlaying = function () {
this._stallRetries = 0;
this.emit('playing');
};
VideoPlayer.prototype._onVideoProgress = function () {
this.emit('loadProgress');
if (typeof this.config.onLoadProgress === 'function') this.config.onLoadProgress.call(this);
};
VideoPlayer.prototype._onFullscreenChange = function () {
var isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
this.isFullscreen = isFull;
if (this.wrapper) {
if (isFull) this.wrapper.classList.add('is-fullscreen');
else this.wrapper.classList.remove('is-fullscreen');
}
if (this.fullscreenBtn) {
this.fullscreenBtn.innerHTML = isFull ? ICONS.fullscreenExit : ICONS.fullscreen;
this.fullscreenBtn.setAttribute('aria-pressed', isFull ? 'true' : 'false');
}
this.emit('fullscreenChange', isFull);
};
VideoPlayer.prototype._onResize = function () {};
// ---------- 其他交互 ----------
VideoPlayer.prototype._toggleMenu = function () {
if (this.menuDropdown) {
this.menuDropdown.classList.toggle('show');
if (this._isDebugVisible) this._closeDebugPanel();
this._closeSettingsPanel();
}
};
VideoPlayer.prototype._closeMenu = function () {
if (this.menuDropdown) this.menuDropdown.classList.remove('show');
};
VideoPlayer.prototype._onOutsideClick = function (e) {
if (this.menuDropdown && this.menuBtn) {
if (!this.menuBtn.contains(e.target) && !this.menuDropdown.contains(e.target)) this._closeMenu();
}
if (this._isDebugVisible && !this.debugPanelEl.contains(e.target)) {
this._closeDebugPanel();
}
};
VideoPlayer.prototype._toggleABLoop = function () {
if (!this.abLoopBtn) return;
var video = this.video;
var current = video.currentTime || 0;
var duration = video.duration || 0;
if (duration === 0) return;
this.abLoopBtn.classList.remove('set-a', 'set-b', 'looping', 'active');
switch (this._abState) {
case 'off':
this._abA = current;
this._abState = 'setA';
this.abLoopBtn.classList.add('set-a');
this.abLoopBtn.title = '标记 B 点';
break;
case 'setA':
if (current <= this._abA) {
console.warn('[VideoPlayer] B点必须大于A点');
this._abA = current;
this.abLoopBtn.classList.add('set-a');
this.abLoopBtn.title = '标记 B 点';
break;
}
this._abB = current;
this._abState = 'looping';
this.abLoopBtn.classList.add('looping', 'active');
this.abLoopBtn.title = '取消循环';
this.seek(this._abA);
break;
case 'looping':
this._abState = 'off';
this._abA = 0;
this._abB = 0;
this.abLoopBtn.title = 'A-B循环';
break;
default:
this._abState = 'off';
this.abLoopBtn.title = 'A-B循环';
}
this._updateABMarks();
};
VideoPlayer.prototype._toggleLock = function () {
if (!this.wrapper || !this.lockBtn) return;
this._locked = !this._locked;
if (this._locked) {
this.wrapper.classList.add('is-locked');
this.lockBtn.innerHTML = ICONS.lock;
this.lockBtn.setAttribute('aria-label', '解锁控件');
this._closeMenu();
this._closeSettingsPanel();
if (this._isDebugVisible) this._closeDebugPanel();
this.toggleControls(false);
} else {
this.wrapper.classList.remove('is-locked');
this.lockBtn.innerHTML = ICONS.unlock;
this.lockBtn.setAttribute('aria-label', '锁定控件');
}
};
VideoPlayer.prototype._getClientX = function (e) {
if (typeof e.clientX === 'number') return e.clientX;
if (e.touches && e.touches[0] && typeof e.touches[0].clientX === 'number') return e.touches[0].clientX;
if (e.changedTouches && e.changedTouches[0] && typeof e.changedTouches[0].clientX === 'number') return e.changedTouches[0].clientX;
return null;
};
VideoPlayer.prototype._onProgressDown = function (e) {
e.preventDefault();
this.isDragging = true;
if (this.wrapper) this.wrapper.classList.add('is-focused');
this._updateProgressFromEvent(e);
};
VideoPlayer.prototype._onProgressMove = function (e) {
if (!this.isDragging) return;
e.preventDefault();
this._updateProgressFromEvent(e);
};
VideoPlayer.prototype._onProgressUp = function (e) {
if (!this.isDragging) return;
this.isDragging = false;
if (this.wrapper) this.wrapper.classList.remove('is-focused');
this._updateProgressFromEvent(e, true);
};
VideoPlayer.prototype._updateProgressFromEvent = function (e, seek) {
if (!this.progressEl) return;
var clientX = this._getClientX(e);
if (clientX === null) return;
var rect = this.progressEl.getBoundingClientRect();
var x = clientX - rect.left;
var percent = Math.min(1, Math.max(0, x / rect.width));
var duration = this.video.duration || 0;
var time = percent * duration;
if (duration && isFinite(time)) {
if (this.progressFilled) this.progressFilled.style.width = (percent * 100) + '%';
this.progressEl.setAttribute('aria-valuenow', time);
this._updateTimeDisplay(time, duration);
if (seek) this.seek(time);
}
};
VideoPlayer.prototype._updateABMarks = function () {
if (!this.progressEl || !this.abMarkA || !this.abMarkB) return;
var duration = this.video.duration || 0;
if (duration === 0) {
this.abMarkA.style.display = 'none';
this.abMarkB.style.display = 'none';
return;
}
var showA = (this._abState === 'setA' || this._abState === 'looping');
var showB = (this._abState === 'looping');
if (showA && typeof this._abA === 'number' && this._abA >= 0) {
this.abMarkA.style.display = 'block';
this.abMarkA.style.left = Math.min(100, Math.max(0, (this._abA / duration) * 100)) + '%';
} else {
this.abMarkA.style.display = 'none';
}
if (showB && typeof this._abB === 'number' && this._abB > this._abA) {
this.abMarkB.style.display = 'block';
this.abMarkB.style.left = Math.min(100, Math.max(0, (this._abB / duration) * 100)) + '%';
} else {
this.abMarkB.style.display = 'none';
}
};
// ---------- 公共 API ----------
VideoPlayer.prototype.play = function () {
this._ensureAudioContext();
return this.video.play();
};
VideoPlayer.prototype.pause = function () { this.video.pause(); };
VideoPlayer.prototype.toggle = function () {
if (this.video.paused) this.play();
else this.pause();
};
VideoPlayer.prototype.seek = function (time) {
if (!isFinite(time)) return;
var duration = this.video.duration || 0;
var target = Math.min(duration, Math.max(0, time));
this.video.currentTime = target;
var percent = duration ? (target / duration * 100) : 0;
if (this.progressFilled) this.progressFilled.style.width = percent + '%';
if (this.progressEl) this.progressEl.setAttribute('aria-valuenow', target);
this._updateTimeDisplay(target, duration);
this.saveProgress();
};
VideoPlayer.prototype.setVolume = function (vol) {
if (!isFinite(vol)) return;
vol = Math.min(1, Math.max(0, vol));
this.video.volume = vol;
this.video.muted = (vol === 0);
this.isMuted = (vol === 0);
this.volume = vol;
if (this.volumeBtn) {
this.volumeBtn.innerHTML = (vol === 0) ? ICONS.volumeMuted : ICONS.volumeHigh;
this.volumeBtn.setAttribute('aria-pressed', (vol === 0) ? 'true' : 'false');
}
this._savePreferencesIfNeeded();
};
VideoPlayer.prototype.toggleMute = function () {
if (this.video.muted) {
this.video.muted = false;
this.video.volume = this.volume || 0.5;
} else {
this.video.muted = true;
}
};
VideoPlayer.prototype.setPlaybackRate = function (rate) {
this.video.playbackRate = rate;
this.playbackRate = rate;
this._updateSettingsPanel();
this._savePreferencesIfNeeded();
this.emit('playbackRateChange', rate);
};
VideoPlayer.prototype.toggleFullscreen = function () {
var el = this.wrapper;
if (!el) return;
if (!this.isFullscreen) {
if (el.requestFullscreen) el.requestFullscreen();
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
else if (el.mozRequestFullScreen) el.mozRequestFullScreen();
else if (el.msRequestFullscreen) el.msRequestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
else if (document.mozCancelFullScreen) document.mozCancelFullScreen();
else if (document.msExitFullscreen) document.msExitFullscreen();
}
};
VideoPlayer.prototype.togglePip = function () {
var video = this.video;
if (document.pictureInPictureElement === video) {
document.exitPictureInPicture().catch(function () {});
} else if (document.pictureInPictureEnabled) {
video.requestPictureInPicture().catch(function (err) {
console.warn('[VideoPlayer] PiP 不支持:', err);
});
}
};
VideoPlayer.prototype.load = function (sources) {
if (!sources || !sources.length) return;
this.video.pause();
if (this._hls) {
this._hls.destroy();
this._hls = null;
}
this.config.sources = sources;
var self = this;
this._initHLS(function() {
self._loadSource(sources);
if (!self.video.paused) self.play().catch(function () {});
});
};
VideoPlayer.prototype.setOrientation = function (mode) {
if (['auto', 'landscape', 'portrait'].indexOf(mode) === -1) return;
this._orientation = mode;
if (this.orientationBtn) {
if (mode === 'auto') {
this.orientationBtn.innerHTML = ICONS.orientationAuto;
this.orientationBtn.setAttribute('aria-label', '自动方位');
} else if (mode === 'landscape') {
this.orientationBtn.innerHTML = ICONS.orientationLandscape;
this.orientationBtn.setAttribute('aria-label', '横屏方位');
} else if (mode === 'portrait') {
this.orientationBtn.innerHTML = ICONS.orientationPortrait;
this.orientationBtn.setAttribute('aria-label', '竖屏方位');
}
}
if (this.wrapper) {
this.wrapper.classList.remove('cp-orientation-auto', 'cp-orientation-landscape', 'cp-orientation-portrait');
this.wrapper.classList.add('cp-orientation-' + mode);
}
if (typeof this.config.onOrientationChange === 'function') this.config.onOrientationChange.call(this, mode);
this.emit('orientationChange', mode);
};
VideoPlayer.prototype.toggleOrientation = function () {
var modes = ['auto', 'landscape', 'portrait'];
var idx = modes.indexOf(this._orientation);
idx = (idx + 1) % modes.length;
this.setOrientation(modes[idx]);
};
VideoPlayer.prototype.requestCast = function () {
if (navigator.presentation && navigator.presentation.requestSession) {
navigator.presentation.requestSession().catch(function (err) {
console.warn('[VideoPlayer] 投屏请求失败:', err);
if (typeof this.config.onError === 'function') this.config.onError({ message: '投屏请求失败: ' + err.message });
}.bind(this));
} else if (window.chrome && window.chrome.cast && window.chrome.cast.isAvailable) {
console.warn('[VideoPlayer] 请使用 Cast API 集成');
if (typeof this.config.onError === 'function') this.config.onError({ message: '请使用 Cast API 集成' });
} else {
console.warn('[VideoPlayer] 当前浏览器不支持投屏');
if (typeof this.config.onError === 'function') this.config.onError({ message: '当前浏览器不支持投屏' });
}
};
VideoPlayer.prototype.setTracks = function (tracks) {
this._tracks = tracks || [];
this._clearSubtitles();
this._initSubtitles();
this._updateSettingsPanel();
};
// ---------- 销毁 ----------
VideoPlayer.prototype.destroy = function () {
if (this._destroyed) return;
this._destroyed = true;
this.video.pause();
if (this._hls) {
this._hls.destroy();
this._hls = null;
}
if (this._audioContext) {
this._audioContext.close();
this._audioContext = null;
}
this._releaseWakeLock();
clearInterval(this._bufferInterval);
this._blobUrls.forEach(function (url) {
URL.revokeObjectURL(url);
});
this._blobUrls = [];
var video = this.video;
video.removeEventListener('play', this._onPlay);
video.removeEventListener('pause', this._onPause);
video.removeEventListener('ended', this._onEnded);
video.removeEventListener('timeupdate', this._onTimeUpdate);
video.removeEventListener('loadedmetadata', this._onLoadedMetadata);
video.removeEventListener('waiting', this._onWaiting);
video.removeEventListener('canplay', this._onCanPlay);
video.removeEventListener('error', this._onError);
video.removeEventListener('volumechange', this._onVolumeChange);
video.removeEventListener('enterpictureinpicture', this._onPipChange);
video.removeEventListener('leavepictureinpicture', this._onPipChange);
video.removeEventListener('stalled', this._onStalled);
video.removeEventListener('playing', this._onPlaying);
video.removeEventListener('progress', this._onVideoProgress);
video.removeEventListener('dblclick', this.toggle);
video.removeEventListener('touchstart', this._onGestureStart);
video.removeEventListener('touchmove', this._onGestureMove);
video.removeEventListener('touchend', this._onGestureEnd);
video.removeEventListener('touchcancel', this._onGestureEnd);
document.removeEventListener('mousemove', this._onProgressMove);
document.removeEventListener('mouseup', this._onProgressUp);
document.removeEventListener('touchmove', this._onProgressMove);
document.removeEventListener('touchend', this._onProgressUp);
document.removeEventListener('fullscreenchange', this._onFullscreenChange);
document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange);
document.removeEventListener('mozfullscreenchange', this._onFullscreenChange);
document.removeEventListener('MSFullscreenChange', this._onFullscreenChange);
document.removeEventListener('keydown', this._onKeydown);
document.removeEventListener('click', this._onOutsideClick);
document.removeEventListener('visibilitychange', this._onVisibilityChange);
window.removeEventListener('resize', this._onResize);
if (this.wrapper && this.wrapper.parentNode) {
var parent = this.wrapper.parentNode;
parent.insertBefore(this.video, this.wrapper);
this.wrapper.remove();
}
if (this._originalControlsAttr !== null) {
this.video.setAttribute('controls', this._originalControlsAttr);
} else {
this.video.removeAttribute('controls');
}
if (this._originalVideoStyle !== undefined) {
this.video.setAttribute('style', this._originalVideoStyle);
} else {
this.video.removeAttribute('style');
}
this.wrapper = null;
this.controlsEl = null;
this.progressEl = null;
this.progressFilled = null;
this.progressBuffered = null;
this.playBtn = null;
this.timeDisplay = null;
this.volumeBtn = null;
this.subtitleSelect = null;
this.fullscreenBtn = null;
this.abLoopBtn = null;
this.menuBtn = null;
this.menuDropdown = null;
this.lockBtn = null;
this.posterEl = null;
this.bigPlayBtn = null;
this.loadingEl = null;
this.sideControlsEl = null;
this.orientationBtn = null;
this.debugPanelEl = null;
this.debugCloseBtn = null;
this.gestureIndicatorEl = null;
this.gestureIndicatorIconEl = null;
this.gestureIndicatorValueEl = null;
this.settingsBtn = null;
this.settingsPanel = null;
this.abMarkA = null;
this.abMarkB = null;
this._sourceCleanup = null;
this.video = null;
var idx = VideoPlayer._instances.indexOf(this);
if (idx > -1) VideoPlayer._instances.splice(idx, 1);
};
VideoPlayer.prototype._updateTimeDisplay = function (current, duration) {
var fmt = function (t) {
t = Math.floor(t || 0);
var h = Math.floor(t / 3600);
var m = Math.floor((t % 3600) / 60);
var s = t % 60;
return (h > 0 ? h + ':' : '') + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
};
if (this.timeDisplay) {
this.timeDisplay.textContent = fmt(current) + ' / ' + fmt(duration);
}
};
global.VideoPlayer = VideoPlayer;
})(typeof window !== 'undefined' ? window : this);