视频播放器
Αυτός ο κώδικας δεν πρέπει να εγκατασταθεί άμεσα. Είναι μια βιβλιοθήκη για άλλους κώδικες που περιλαμβάνεται μέσω της οδηγίας meta // @require https://update.greasyfork.org/scripts/591096/1901308/ClosurePlayer.js
/**
* ClosurePlayer v3.5 (弹窗倍速版)
* 基于 v3.4 修补:
* 1. 移除原生 <select> 下拉菜单,升级为毛玻璃弹窗式倍速面板
* 2. 点击外部自动关闭,交互对齐主流视频网站
* 3. 保留 controlsList 配置化、stalled 重试、destroy 内存泄漏修复
*/
(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>',
pip: '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19 11h-8v6h8v-6zm4 8V5c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 0H3V5h18v14z" 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],
controlsList: ['play', 'volume', 'progress', 'time', 'speed', 'subtitles', 'pip', 'fullscreen'],
onReady: null,
onPlay: null,
onPause: null,
onEnded: null,
onTimeUpdate: null,
onError: null
};
// ---------- 样式注入(含弹窗倍速新样式)----------
var injectStyles = function () {
if (document.getElementById('closure-player-styles')) return;
var style = document.createElement('style');
style.id = 'closure-player-styles';
style.textContent = `
.closure-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; }
.closure-player video { display:block; width:100%; height:auto; background:#000; outline:none; }
.closure-player .cp-controls { position:absolute; bottom:0; left:0; right:0; padding:12px 16px 14px; background:linear-gradient(transparent,rgba(0,0,0,0.75)); opacity:0; transition:opacity 0.3s; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
.closure-player:hover .cp-controls, .closure-player.is-focused .cp-controls { opacity:1; pointer-events:auto; }
.closure-player .cp-controls-top { display:flex; justify-content:space-between; align-items:center; font-size:13px; padding:0 2px; color:#ddd; gap:8px; flex-wrap:wrap; }
.closure-player .cp-progress { width:100%; height:4px; background:rgba(255,255,255,0.25); border-radius:4px; cursor:pointer; position:relative; touch-action:none; }
.closure-player .cp-progress .cp-progress-filled { height:100%; background:var(--cp-theme,#6c5ce7); border-radius:4px; width:0%; transition:width 0.05s linear; position:relative; }
.closure-player .cp-progress .cp-progress-filled::after { content:''; position:absolute; right:-6px; top:-4px; width:12px; height:12px; background:var(--cp-theme,#6c5ce7); border-radius:50%; opacity:0; transition:opacity 0.2s; box-shadow:0 0 6px rgba(0,0,0,0.4); }
.closure-player .cp-progress:hover .cp-progress-filled::after { opacity:1; }
.closure-player .cp-controls-bottom { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-height:44px; }
.closure-player .cp-btn { background:none; border:none; color:#fff; cursor:pointer; padding:6px; display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; border-radius:8px; transition:background 0.15s; background:transparent; flex-shrink:0; }
.closure-player .cp-btn:hover { background:rgba(255,255,255,0.15); }
.closure-player .cp-btn:focus-visible { outline:2px solid var(--cp-theme,#6c5ce7); outline-offset:2px; }
.closure-player .cp-btn svg { width:24px; height:24px; fill:currentColor; }
.closure-player .cp-time { font-size:13px; color:#ccc; min-width:80px; letter-spacing:0.3px; font-variant-numeric:tabular-nums; line-height:44px; padding:0 4px; }
.closure-player .cp-volume-wrap { display:flex; align-items:center; gap:6px; flex:1 1 auto; min-width:80px; }
.closure-player .cp-volume-slider { flex:1; height:3px; background:rgba(255,255,255,0.25); border-radius:4px; cursor:pointer; position:relative; min-width:30px; }
.closure-player .cp-volume-slider .cp-volume-filled { height:100%; background:var(--cp-theme,#6c5ce7); border-radius:4px; width:100%; }
/* 【升级】移除原生 select 样式,替换为弹窗倍速 */
.closure-player .cp-subtitle-select { background:rgba(0,0,0,0.5); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; font-size:12px; cursor:pointer; outline:none; font-family:inherit; height:32px; }
.closure-player .cp-subtitle-select option { background:#222; }
.closure-player .cp-fullscreen-btn { margin-left:auto; }
.closure-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; }
.closure-player .cp-poster.hidden { opacity:0; }
.closure-player .cp-big-play { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:72px; height:72px; border-radius:50%; background:rgba(0,0,0,0.5); border:2px solid rgba(255,255,255,0.3); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; z-index:2; transition:opacity 0.3s,transform 0.2s; backdrop-filter:blur(4px); opacity:0; pointer-events:none; }
.closure-player .cp-big-play.show { opacity:1; pointer-events:auto; }
.closure-player .cp-big-play:hover { transform:translate(-50%,-50%) scale(1.05); background:rgba(0,0,0,0.65); }
.closure-player .cp-big-play svg { width:40px; height:40px; fill:currentColor; }
.closure-player .cp-loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:48px; height:48px; border:4px solid rgba(255,255,255,0.15); border-top:4px 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); } }
.closure-player.is-loading .cp-loading { display:block; }
.closure-player .cp-subtitle-select { margin-left:4px; }
/* ===== 【升级】弹窗倍速专属样式 ===== */
.closure-player .cp-speed-wrap { position:relative; display:inline-flex; align-items:center; }
.closure-player .cp-speed-popup { display:none; position:absolute; bottom:calc(100% + 10px); left:50%; transform:translateX(-50%); background:rgba(28,28,30,0.92); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); border-radius:10px; padding:6px; min-width:64px; border:1px solid rgba(255,255,255,0.08); box-shadow:0 12px 40px rgba(0,0,0,0.6); z-index:20; flex-direction:column; gap:2px; }
.closure-player .cp-speed-popup.show { display:flex; }
.closure-player .cp-speed-opt { background:transparent; border:none; color:#ccc; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:14px; text-align:center; white-space:nowrap; transition:all 0.15s; font-weight:500; width:100%; }
.closure-player .cp-speed-opt:hover { background:rgba(255,255,255,0.08); color:#fff; }
.closure-player .cp-speed-opt.active { color:var(--cp-theme,#6c5ce7); background:rgba(108,92,231,0.12); font-weight:600; }
.closure-player .cp-speed-btn { min-width:44px; font-size:14px; font-weight:600; letter-spacing:0.3px; }
@media (max-width:600px) {
.closure-player .cp-controls { padding:8px 10px 10px; }
.closure-player .cp-btn { width:40px; height:40px; }
.closure-player .cp-volume-slider { min-width:20px; }
.closure-player .cp-time { font-size:11px; min-width:60px; }
.closure-player .cp-speed-popup { min-width:56px; }
.closure-player .cp-speed-opt { padding:6px 12px; font-size:13px; }
}
`;
document.head.appendChild(style);
};
// ---------- 播放器主类 ----------
function ClosurePlayer(options) {
if (!(this instanceof ClosurePlayer)) return new ClosurePlayer(options);
this.config = extend({}, DEFAULTS);
extend(this.config, options);
this.controlsList = this.config.controlsList || DEFAULTS.controlsList;
var video = this.config.video;
if (!video) throw new Error('ClosurePlayer: 必须提供 video 选项');
if (typeof video === 'string') video = query(video);
if (!isElement(video)) throw new Error('ClosurePlayer: video 元素无效');
if (video.tagName !== 'VIDEO') throw new Error('ClosurePlayer: 传入的元素必须是 <video>');
this.video = video;
injectStyles();
this.controlsEl = null;
this.progressEl = null;
this.progressFilled = null;
this.playBtn = null;
this.timeDisplay = null;
this.volumeSlider = null;
this.volumeFilled = null;
this.volumeBtn = null;
/* 【升级】移除 this.speedSelect,替换为以下两个 */
this.speedBtn = null;
this.speedPopup = null;
this.subtitleSelect = null;
this.fullscreenBtn = null;
this.pipBtn = null;
this.posterEl = null;
this.bigPlayBtn = null;
this.loadingEl = 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._bindMethods();
this._wrapAndBuild();
this._bindEvents();
this._initHLS();
this._initSubtitles();
if (this.config.sources && this.config.sources.length) {
this._loadSource(this.config.sources);
} else {
if (this.video.src || this.video.querySelector('source')) {
this.video.load();
}
}
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 (typeof this.config.onReady === 'function') this.config.onReady.call(this, this);
}
ClosurePlayer.prototype = {
constructor: ClosurePlayer,
_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._onProgressMove = this._onProgressMove.bind(this);
this._onProgressUp = this._onProgressUp.bind(this);
this._onVolumeChange = this._onVolumeChange.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onFullscreenChange = this._onFullscreenChange.bind(this);
this._onPipChange = this._onPipChange.bind(this);
this._onStalled = this._onStalled.bind(this);
this._onPlaying = this._onPlaying.bind(this);
/* 【升级】绑定弹窗控制方法 */
this._toggleSpeedPopup = this._toggleSpeedPopup.bind(this);
this._closeSpeedPopup = this._closeSpeedPopup.bind(this);
this._onOutsideClick = this._onOutsideClick.bind(this);
},
_wrapAndBuild: function () {
var video = this.video;
video.removeAttribute('controls');
var parent = video.parentNode;
var wrapper = document.createElement('div');
wrapper.className = 'closure-player';
wrapper.style.setProperty('--cp-theme', this.config.themeColor || '#6c5ce7');
parent.insertBefore(wrapper, video);
wrapper.appendChild(video);
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 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);
}
/* 【升级】倍速 UI:从下拉菜单改为弹窗面板 */
if (this.controlsList.includes('speed')) {
var speedWrap = document.createElement('div');
speedWrap.className = 'cp-speed-wrap';
var speedBtn = document.createElement('button');
speedBtn.className = 'cp-btn cp-speed-btn';
speedBtn.setAttribute('aria-label', '播放速度');
speedBtn.textContent = '1×';
this.speedBtn = speedBtn;
speedWrap.appendChild(speedBtn);
var popup = document.createElement('div');
popup.className = 'cp-speed-popup';
var rates = this.config.playbackRates || [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
var self = this;
rates.forEach(function (r) {
var opt = document.createElement('button');
opt.className = 'cp-speed-opt' + (r === 1.0 ? ' active' : '');
opt.dataset.rate = r;
opt.textContent = r + '×';
opt.addEventListener('click', function (e) {
e.stopPropagation();
self.setPlaybackRate(r);
self._closeSpeedPopup();
});
popup.appendChild(opt);
});
this.speedPopup = popup;
speedWrap.appendChild(popup);
speedBtn.addEventListener('click', function (e) {
e.stopPropagation();
self._toggleSpeedPopup();
});
top.appendChild(speedWrap);
}
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 filled = document.createElement('div');
filled.className = 'cp-progress-filled';
progress.appendChild(filled);
this.progressEl = progress;
this.progressFilled = filled;
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);
}
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);
var volSlider = document.createElement('div');
volSlider.className = 'cp-volume-slider';
volSlider.setAttribute('role', 'slider');
volSlider.setAttribute('aria-label', '音量');
volSlider.setAttribute('aria-valuemin', '0');
volSlider.setAttribute('aria-valuemax', '1');
volSlider.setAttribute('aria-valuenow', this.isMuted ? '0' : (this.volume || 1));
var volFilled = document.createElement('div');
volFilled.className = 'cp-volume-filled';
volSlider.appendChild(volFilled);
this.volumeSlider = volSlider;
this.volumeFilled = volFilled;
var initialVol = this.isMuted ? 0 : (this.volume || 1);
volFilled.style.width = (initialVol * 100) + '%';
volWrap.appendChild(volSlider);
bottom.appendChild(volWrap);
}
if (this.controlsList.includes('pip')) {
var pipBtn = document.createElement('button');
pipBtn.className = 'cp-btn cp-pip-btn';
pipBtn.setAttribute('aria-label', '画中画');
pipBtn.setAttribute('aria-pressed', 'false');
pipBtn.innerHTML = ICONS.pip;
this.pipBtn = pipBtn;
bottom.appendChild(pipBtn);
}
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.wrapper = wrapper;
this.video.volume = this.isMuted ? 0 : (this.volume || 1);
this.video.muted = this.isMuted;
},
_loadSource: function (sources) {
if (!sources || !sources.length) return;
var video = this.video;
video.innerHTML = '';
sources.forEach(function (s) {
var source = document.createElement('source');
source.src = s.src;
if (s.type) source.type = s.type;
video.appendChild(source);
});
if (this._hls) {
var hlsSrc = sources.find(function (s) { return s.type === 'application/x-mpegURL'; });
if (hlsSrc) {
this._hls.loadSource(hlsSrc.src);
this._hls.attachMedia(video);
return;
}
}
video.load();
},
// ---------- HLS ----------
_initHLS: function () {
var sources = this.config.sources || [];
var hasHLS = sources.some(function (s) { return s.type === 'application/x-mpegURL'; });
if (!hasHLS) return;
var self = this;
if (global.Hls) {
self._setupHls();
} else {
loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js')
.then(function () { self._setupHls(); })
.catch(function (err) {
console.warn('[ClosurePlayer] hls.js 加载失败', err);
if (typeof self.config.onError === 'function') {
self.config.onError({ message: 'hls.js 加载失败' });
}
});
}
},
_setupHls: function () {
var video = this.video;
var hls = new global.Hls({ enableWorker: true, lowLatencyMode: true });
this._hls = hls;
hls.on(global.Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
if (data.type === global.Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (data.type === global.Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
}
});
var sources = this.config.sources || [];
var hlsSrc = sources.find(function (s) { return s.type === 'application/x-mpegURL'; });
if (hlsSrc) {
hls.loadSource(hlsSrc.src);
hls.attachMedia(video);
}
},
// ---------- 字幕 ----------
_initSubtitles: function () {
var tracks = this._tracks;
if (!tracks || !tracks.length) {
if (this.subtitleSelect) this.subtitleSelect.style.display = 'none';
return;
}
var video = this.video;
var self = this;
tracks.forEach(function (track) {
var trackEl = document.createElement('track');
trackEl.kind = track.kind || 'subtitles';
trackEl.srclang = track.srclang || 'en';
trackEl.label = track.label || track.srclang || '字幕';
trackEl.src = track.src;
trackEl.default = track.default || false;
video.appendChild(trackEl);
self._trackElements.push(trackEl);
if (self.subtitleSelect) {
var opt = document.createElement('option');
opt.value = track.srclang || track.label;
opt.textContent = track.label || track.srclang;
if (track.default) opt.selected = true;
self.subtitleSelect.appendChild(opt);
}
});
if (this.subtitleSelect) {
this.subtitleSelect.addEventListener('change', function () {
var selected = this.value;
var textTracks = video.textTracks;
for (var i = 0; i < textTracks.length; i++) {
var tt = textTracks[i];
if (selected === 'off') tt.mode = 'disabled';
else if (tt.language === selected || tt.label === selected) tt.mode = 'showing';
else tt.mode = 'disabled';
}
});
}
},
// ---------- 事件绑定 ----------
_bindEvents: function () {
var video = this.video;
var wrapper = this.wrapper;
video.addEventListener('play', this._onPlay.bind(this));
video.addEventListener('pause', this._onPause.bind(this));
video.addEventListener('ended', this._onEnded.bind(this));
video.addEventListener('timeupdate', this._onTimeUpdate.bind(this));
video.addEventListener('loadedmetadata', this._onLoadedMetadata.bind(this));
video.addEventListener('waiting', this._onWaiting.bind(this));
video.addEventListener('canplay', this._onCanPlay.bind(this));
video.addEventListener('error', this._onError.bind(this));
video.addEventListener('volumechange', this._onVolumeChange.bind(this));
video.addEventListener('enterpictureinpicture', this._onPipChange.bind(this));
video.addEventListener('leavepictureinpicture', this._onPipChange.bind(this));
video.addEventListener('stalled', this._onStalled);
video.addEventListener('playing', this._onPlaying);
if (this.progressEl) {
this.progressEl.addEventListener('mousedown', this._onProgressDown.bind(this));
this.progressEl.addEventListener('touchstart', this._onProgressDown.bind(this), { 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.volumeSlider) {
this.volumeSlider.addEventListener('mousedown', this._onVolumeDown.bind(this));
this.volumeSlider.addEventListener('touchstart', this._onVolumeDown.bind(this), { passive: false });
}
document.addEventListener('mousemove', this._onVolumeMove.bind(this));
document.addEventListener('mouseup', this._onVolumeUp.bind(this));
document.addEventListener('touchmove', this._onVolumeMove.bind(this), { passive: false });
document.addEventListener('touchend', this._onVolumeUp.bind(this));
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.pipBtn) this.pipBtn.addEventListener('click', this.togglePip);
if (this.fullscreenBtn) this.fullscreenBtn.addEventListener('click', this.toggleFullscreen);
/* 【升级】移除 this.speedSelect 的事件绑定,因为现在由弹窗自身的点击事件处理 */
if (this.config.keyboardShortcuts) {
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);
video.addEventListener('click', this.toggle);
window.addEventListener('resize', this._onResize.bind(this));
/* 【升级】监听全局点击,用于关闭倍速弹窗 */
document.addEventListener('click', this._onOutsideClick);
},
// ----- 事件处理 -----
_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);
},
_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);
},
_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);
},
_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);
}
},
_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 () {});
},
_onWaiting: function () { if (this.wrapper) this.wrapper.classList.add('is-loading'); },
_onCanPlay: function () {
if (this.wrapper) this.wrapper.classList.remove('is-loading');
this._stallRetries = 0;
},
_onError: function (e) {
if (this.wrapper) this.wrapper.classList.remove('is-loading');
if (typeof this.config.onError === 'function') this.config.onError.call(this, e);
},
_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');
}
if (this.volumeFilled) this.volumeFilled.style.width = (muted ? 0 : vol * 100) + '%';
if (this.volumeSlider) this.volumeSlider.setAttribute('aria-valuenow', muted ? 0 : vol);
},
_onKeydown: function (e) {
var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var key = e.key;
if (key === ' ' || key === 'Space') { e.preventDefault(); this.toggle(); }
else if (key === 'ArrowRight') { e.preventDefault(); this.seek(this.video.currentTime + 5); }
else if (key === 'ArrowLeft') { e.preventDefault(); this.seek(this.video.currentTime - 5); }
else if (key === 'f' || key === 'F') { this.toggleFullscreen(); }
else if (key === 'm' || key === 'M') { this.toggleMute(); }
else if (key === 'p' || key === 'P') { this.togglePip(); }
},
_onFullscreenChange: function () {
var isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
this.isFullscreen = isFull;
if (this.fullscreenBtn) {
this.fullscreenBtn.innerHTML = isFull ? ICONS.fullscreenExit : ICONS.fullscreen;
this.fullscreenBtn.setAttribute('aria-pressed', isFull ? 'true' : 'false');
}
},
_onPipChange: function () {
var isPip = !!document.pictureInPictureElement;
this.isPip = isPip;
if (this.pipBtn) this.pipBtn.setAttribute('aria-pressed', isPip ? 'true' : 'false');
},
_onResize: function () {},
// 【新增】Stalled 自动重试逻辑
_onStalled: function (e) {
if (this._stallRetries >= this._maxStallRetries) {
if (typeof this.config.onError === 'function') {
this.config.onError({ message: '视频加载停滞次数过多,放弃重试' });
}
return;
}
this._stallRetries++;
console.warn('[ClosurePlayer] 检测到 stalled,执行第 ' + this._stallRetries + ' 次重试...');
var wasPaused = this.video.paused;
this.video.load();
if (!wasPaused) {
this.video.play().catch(function (err) {
console.warn('[ClosurePlayer] 重试后自动播放失败', err);
});
}
},
_onPlaying: function () {
this._stallRetries = 0;
},
/* 【升级】倍速弹窗控制方法 */
_toggleSpeedPopup: function () {
if (this.speedPopup) this.speedPopup.classList.toggle('show');
},
_closeSpeedPopup: function () {
if (this.speedPopup) this.speedPopup.classList.remove('show');
},
_onOutsideClick: function (e) {
if (this.speedPopup && this.speedBtn) {
if (!this.speedBtn.contains(e.target) && !this.speedPopup.contains(e.target)) {
this._closeSpeedPopup();
}
}
},
// 进度条拖拽
_onProgressDown: function (e) {
e.preventDefault();
this.isDragging = true;
if (this.wrapper) this.wrapper.classList.add('is-focused');
this._updateProgressFromEvent(e);
},
_onProgressMove: function (e) {
if (!this.isDragging) return;
e.preventDefault();
this._updateProgressFromEvent(e);
},
_onProgressUp: function (e) {
if (!this.isDragging) return;
this.isDragging = false;
if (this.wrapper) this.wrapper.classList.remove('is-focused');
this._updateProgressFromEvent(e, true);
},
_updateProgressFromEvent: function (e, seek) {
if (!this.progressEl) return;
var rect = this.progressEl.getBoundingClientRect();
var x = (e.clientX || (e.touches && e.touches[0].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) {
if (this.progressFilled) this.progressFilled.style.width = (percent * 100) + '%';
this.progressEl.setAttribute('aria-valuenow', time);
this._updateTimeDisplay(time, duration);
if (seek) this.seek(time);
}
},
// 音量拖拽
_onVolumeDown: function (e) {
e.preventDefault();
this._isVolumeDragging = true;
this._updateVolumeFromEvent(e);
},
_onVolumeMove: function (e) {
if (!this._isVolumeDragging) return;
e.preventDefault();
this._updateVolumeFromEvent(e);
},
_onVolumeUp: function (e) {
if (this._isVolumeDragging) {
this._isVolumeDragging = false;
this._updateVolumeFromEvent(e);
}
},
_updateVolumeFromEvent: function (e) {
if (!this.volumeSlider) return;
var rect = this.volumeSlider.getBoundingClientRect();
var x = (e.clientX || (e.touches && e.touches[0].clientX)) - rect.left;
var vol = Math.min(1, Math.max(0, x / rect.width));
this.setVolume(vol);
},
// ---------- 公共 API ----------
play: function () { return this.video.play(); },
pause: function () { this.video.pause(); },
toggle: function () {
if (this.video.paused) this.play();
else this.pause();
},
seek: function (time) {
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);
},
setVolume: function (vol) {
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');
}
if (this.volumeFilled) this.volumeFilled.style.width = (vol * 100) + '%';
if (this.volumeSlider) this.volumeSlider.setAttribute('aria-valuenow', vol);
},
toggleMute: function () {
if (this.video.muted) {
this.video.muted = false;
this.video.volume = this.volume || 0.5;
} else {
this.video.muted = true;
}
},
/* 【升级】重写 setPlaybackRate,同步更新弹窗 UI 和按钮文字 */
setPlaybackRate: function (rate) {
this.video.playbackRate = rate;
this.playbackRate = rate;
// 更新按钮文字
if (this.speedBtn) this.speedBtn.textContent = rate + '×';
// 更新弹窗内的高亮状态
if (this.speedPopup) {
var opts = this.speedPopup.querySelectorAll('.cp-speed-opt');
opts.forEach(function (opt) {
var val = parseFloat(opt.dataset.rate);
opt.classList.toggle('active', val === rate);
});
}
},
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();
}
},
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('[ClosurePlayer] PiP 不支持:', err);
});
}
},
load: function (sources) {
if (!sources || !sources.length) return;
this.video.pause();
if (this._hls) {
this._hls.destroy();
this._hls = null;
}
this._loadSource(sources);
if (!this.video.paused) {
this.play().catch(function () {});
}
},
/* 【升级】destroy 中增加解绑 document.click 监听 */
destroy: function () {
if (this._destroyed) return;
this._destroyed = true;
this.video.pause();
if (this._hls) {
this._hls.destroy();
this._hls = null;
}
document.removeEventListener('mousemove', this._onProgressMove);
document.removeEventListener('mouseup', this._onProgressUp);
document.removeEventListener('touchmove', this._onProgressMove);
document.removeEventListener('touchend', this._onProgressUp);
document.removeEventListener('mousemove', this._onVolumeMove);
document.removeEventListener('mouseup', this._onVolumeUp);
document.removeEventListener('touchmove', this._onVolumeMove);
document.removeEventListener('touchend', this._onVolumeUp);
document.removeEventListener('fullscreenchange', this._onFullscreenChange);
document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange);
document.removeEventListener('mozfullscreenchange', this._onFullscreenChange);
document.removeEventListener('MSFullscreenChange', this._onFullscreenChange);
if (this.config.keyboardShortcuts) {
document.removeEventListener('keydown', this._onKeydown);
}
window.removeEventListener('resize', this._onResize);
/* 【升级】移除倍速弹窗的全局点击监听 */
document.removeEventListener('click', this._onOutsideClick);
if (this.wrapper && this.wrapper.parentNode) {
var parent = this.wrapper.parentNode;
parent.insertBefore(this.video, this.wrapper);
this.wrapper.remove();
}
this.video.setAttribute('controls', '');
this.wrapper = null;
this.controlsEl = null;
this.progressEl = null;
this.progressFilled = null;
this.playBtn = null;
this.timeDisplay = null;
this.volumeSlider = null;
this.volumeFilled = null;
this.volumeBtn = null;
this.speedBtn = null;
this.speedPopup = null;
this.subtitleSelect = null;
this.fullscreenBtn = null;
this.pipBtn = null;
this.posterEl = null;
this.bigPlayBtn = null;
this.loadingEl = null;
this.video = null;
},
_updateTimeDisplay: function (current, duration) {
var fmt = function (t) {
t = Math.floor(t || 0);
var m = Math.floor(t / 60);
var s = t % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
};
if (this.timeDisplay) {
this.timeDisplay.textContent = fmt(current) + ' / ' + fmt(duration);
}
}
};
global.ClosurePlayer = ClosurePlayer;
})(typeof window !== 'undefined' ? window : this);