import Hls from 'hls.js';
import Artplayer from 'artplayer';
import artplayerPluginDanmuku from 'artplayer-plugin-danmuku';

/**
 * 视频播放器基类
 */
export abstract class BaseVideoPlayer {
    protected _hasPlayed: boolean = false;
    private _isUpdatingPlayCount: boolean = false;

    /**
     * 处理 m3u8 视频播放
     */
    protected playM3u8(video: HTMLVideoElement, url: string, art: Artplayer): void {
        // 初始化阶段不预加载视频内容,降低未点击播放时的 CDN 流量消耗。
        video.preload = 'none';

        // 懒加载场景下,尚未触发播放时主动隐藏 loading,避免一直展示等待状态。
        const loadingHideTimer = window.setTimeout(() => {
            art.loading.show = false;
        }, 100);

        let sourceLoaded = false;
        let pendingUserPlay = false;
        let playRequestPending = false;

        const requestPlayback = (): void => {
            if (!pendingUserPlay || playRequestPending) {
                return;
            }

            playRequestPending = true;
            void video
                .play()
                .then(() => {
                    pendingUserPlay = false;
                })
                .catch(() => {
                    // 源还没准备好或被浏览器策略拦截时,等待后续就绪事件再重试。
                })
                .finally(() => {
                    playRequestPending = false;
                });
        };

        const retryPendingPlayback = (): void => {
            if (!pendingUserPlay) {
                return;
            }

            requestPlayback();
        };

        const clearPendingPlayback = (): void => {
            pendingUserPlay = false;
        };

        video.addEventListener('play', clearPendingPlayback);

        const loadSource = (): void => {
            if (sourceLoaded) {
                return;
            }

            sourceLoaded = true;

            if (Hls.isSupported()) {
                // 销毁旧的 hls 实例
                if (art.hls) {
                    (art.hls as Hls)?.destroy();
                }
                const hls = new Hls();
                hls.on(Hls.Events.MANIFEST_PARSED, retryPendingPlayback);
                hls.loadSource(url);
                hls.attachMedia(video);
                art.hls = hls;
                art.on('destroy', () => hls.destroy());
                return;
            }

            if (video.canPlayType('application/vnd.apple.mpegurl')) {
                // Safari 原生支持
                video.addEventListener('loadedmetadata', retryPendingPlayback, { once: true });
                video.addEventListener('canplay', retryPendingPlayback, { once: true });
                video.src = url;
                video.load();
                return;
            }

            art.notice.show = '不支持播放该视频';
        };

        const triggerLoadAndPlay = (): void => {
            pendingUserPlay = true;
            loadSource();
            requestPlayback();
            removeLazyLoadTriggers();
        };

        const onPointerDown = (): void => {
            triggerLoadAndPlay();
        };

        const onKeyDown = (event: KeyboardEvent): void => {
            if (event.key !== 'Enter' && event.key !== ' ') {
                return;
            }
            triggerLoadAndPlay();
        };

        const playerElement = art.template.$player;

        const removeLazyLoadTriggers = (): void => {
            playerElement.removeEventListener('pointerdown', onPointerDown, true);
            playerElement.removeEventListener('keydown', onKeyDown, true);
        };

        // 在首次用户交互时才真正挂载媒体源并开始播放。
        playerElement.addEventListener('pointerdown', onPointerDown, true);
        playerElement.addEventListener('keydown', onKeyDown, true);

        art.on('destroy', () => {
            window.clearTimeout(loadingHideTimer);
            video.removeEventListener('play', clearPendingPlayback);
            removeLazyLoadTriggers();
        });
    }

    /**
     * 创建弹幕插件配置
     * @param videoId 视频 ID
     * @param onDanmakuSent 弹幕发送成功后的回调(可选)
     */
    protected createDanmakuPlugin(
        videoId: string,
        onDanmakuSent?: () => void
    ): ReturnType<typeof artplayerPluginDanmuku> {
        return artplayerPluginDanmuku({
            danmuku: async () => {
                try {
                    const response = await fetch(`/history/danmaku/v2/${videoId}`);
                    return await response.json();
                } catch (e) {
                    console.error('Failed to load danmaku', e);
                    return [];
                }
            },
            async beforeEmit(danmaku) {
                if (!danmaku.text || danmaku.text.trim() === '') {
                    return false;
                }

                (danmaku as unknown as Record<string, string>)['id'] = videoId;
                try {
                    await fetch(`/send/danmaku/v2`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json;charset=utf-8',
                        },
                        body: JSON.stringify(danmaku),
                    });

                    // 执行回调(如更新弹幕计数)
                    onDanmakuSent?.();
                    return true;
                } catch (e) {
                    console.error('Failed to send danmaku', e);
                    return false;
                }
            },
        });
    }

    /**
     * 处理首次播放事件(更新播放计数)
     * @param videoId 视频 ID
     * @param onFirstPlay 首次播放计数更新成功后的回调(可选)
     * @returns 播放事件处理器
     */
    protected createFirstPlayHandler(
        videoId: string,
        onFirstPlay?: () => void
    ): () => Promise<void> {
        return async () => {
            if (this._hasPlayed || this._isUpdatingPlayCount) {
                return;
            }

            this._isUpdatingPlayCount = true;
            try {
                const response = await fetch(`/play/${videoId}`, { method: 'PATCH' });
                if (!response.ok) {
                    throw new Error(`Play count update failed with status ${response.status}`);
                }

                this._hasPlayed = true;
                onFirstPlay?.();
            } catch (e) {
                console.error('Failed to update play count', e);
            } finally {
                this._isUpdatingPlayCount = false;
            }
        };
    }

    /**
     * 获取通用的 Artplayer 配置选项(可被子类覆盖或扩展)
     */
    protected getCommonArtplayerOptions(): Partial<Artplayer['option']> {
        return {
            type: 'm3u8',
            customType: {
                m3u8: this.playM3u8.bind(this),
            },
            // 避免浏览器在未点击播放时提前下载
            moreVideoAttr: {
                preload: 'none',
            },
            // 是否在移动端添加长按视频快进功能
            fastForward: true,
            // 是否在移动端的网页全屏时,根据视频尺寸和视口尺寸,旋转播放器
            autoOrientation: true,
        };
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个使用 TypeScript 编写的视频播放器基类,基于 Artplayer 播放器和 HLS.js 库,提供 m3u8 视频播放、弹幕功能和播放计数等核心能力。


核心功能模块

1. playM3u8 方法 - m3u8 视频播放核心逻辑

这是整个类的核心方法,实现了懒加载 + HLS 流媒体播放

关键设计点:

  • 流量优化

    video.preload = 'none'; // 不预加载视频
    

    在用户未主动播放前不加载视频内容,节省 CDN 流量。

  • UI 体验优化

    const loadingHideTimer = window.setTimeout(() => {
        art.loading.show = false;
    }, 100);
    

    在懒加载场景下,隐藏 loading 动画避免误导用户。

  • 播放状态管理

    • sourceLoaded: 视频源是否已加载
    • pendingUserPlay: 用户是否触发了播放意图
    • playRequestPending: 是否有播放请求正在处理
  • HLS 播放实现

    if (Hls.isSupported()) {
        const hls = new Hls();
        hls.on(Hls.Events.MANIFEST_PARSED, retryPendingPlayback);
        hls.loadSource(url);
        hls.attachMedia(video);
    }
    

    使用 hls.js 在支持 MSE 的浏览器中播放 m3u8。

  • Safari 兼容

    if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = url;
        video.load();
    }
    

    Safari 原生支持 HLS,直接设置 src。

  • 懒加载触发机制

    playerElement.addEventListener('pointerdown', onPointerDown, true);
    playerElement.addEventListener('keydown', onKeyDown, true);
    

    在用户首次点击或按键(Enter/空格)时才加载并播放视频。


2. createDanmakuPlugin 方法 - 弹幕功能

创建 Artplayer 弹幕插件配置:

return artplayerPluginDanmuku({
    danmuku: async () => {
        // 从服务器获取历史弹幕
        const response = await fetch(`/history/danmaku/v2/${videoId}`);
        return await response.json();
    },
    async beforeEmit(danmaku) {
        // 发送弹幕到服务器
        await fetch(`/send/danmaku/v2`, {
            method: 'POST',
            body: JSON.stringify(danmaku),
        });
        onDanmakuSent?.(); // 触发回调(如更新弹幕计数)
        return true;
    },
});

关键特性

  • 异步加载历史弹幕
  • 发送新弹幕前校验(空内容拦截)
  • 支持发送成功后的回调通知

3. createFirstPlayHandler 方法 - 播放计数统计

处理首次播放时的播放计数更新:

protected createFirstPlayHandler(videoId: string, onFirstPlay?: () => void) {
    return async () => {
        if (this._hasPlayed || this._isUpdatingPlayCount) return;
        
        this._isUpdatingPlayCount = true;
        await fetch(`/play/${videoId}`, { method: 'PATCH' });
        this._hasPlayed = true;
        onFirstPlay?.();
    };
}

防抖机制

  • _hasPlayed: 确保每个视频只统计一次
  • _isUpdatingPlayCount: 防止并发请求重复统计

4. getCommonArtplayerOptions 方法 - 播放器通用配置

返回 Artplayer 的基础配置:

return {
    type: 'm3u8',
    customType: {
        m3u8: this.playM3u8.bind(this), // 自定义 m3u8 播放逻辑
    },
    moreVideoAttr: {
        preload: 'none', // 与懒加载策略一致
    },
    fastForward: true,        // 移动端长按快进
    autoOrientation: true,    // 移动端自动旋转
};

类设计特点

抽象基类模式

export abstract class BaseVideoPlayer

使用 abstract 关键字定义基类,子类需实现具体的播放器初始化逻辑。

受保护的方法

所有核心方法都使用 protected 修饰符,允许子类访问和复用,但外部无法直接调用。

可选回调机制

onDanmakuSent?: () => void
onFirstPlay?: () => void

通过可选回调参数,允许子类在关键事件发生时执行自定义逻辑(如更新 UI 计数)。


技术亮点

  1. 流量优化:懒加载 + preload='none' 组合策略
  2. 跨平台兼容:HLS.js(现代浏览器)+ 原生支持(Safari)
  3. 健壮性设计:防抖、状态管理、错误处理
  4. 扩展性强:基类提供核心能力,子类按需扩展
  5. 资源清理:监听 destroy 事件释放 HLS 实例和事件监听器

这个基类为构建完整的视频播放功能提供了坚实的基础架构。

评论加载中...