ClosurePlayer

视频播放器

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyfork.org/scripts/591096/1901308/ClosurePlayer.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

/**
 * 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);