import Artplayer from 'artplayer';
import { BaseVideoPlayer } from './BaseVideoPlayer.ts';
import { logger } from './ConsoleLogger.ts';

interface VideoInfo {
    id: string;
    title: string;
    cover: string;
    url: string;
}

/**
 * 视频模块(视频详情页)
 */
class VideoPlayer extends BaseVideoPlayer {
    private readonly _containerSelector = '#video-player';
    private readonly _dataAttribute = 'data-video-config';

    public init(): void {
        const container = document.querySelector(this._containerSelector) as HTMLDivElement | null;
        if (!container) {
            return;
        }

        // 通过检查是否已经存在 Artplayer 实例来避免重复初始化
        if (container.classList.contains('artplayer-app')) {
            // 已经初始化
            return;
        }

        const configStr = container.getAttribute(this._dataAttribute);
        if (!configStr) {
            return;
        }

        try {
            const config = JSON.parse(configStr) as VideoInfo;
            this.setupPlayer(container, config);
        } catch (e) {
            logger.outPutError('Failed to parse video config', e);
        }
    }

    private setupPlayer(container: HTMLDivElement, config: VideoInfo): void {
        const { id, url, cover, title } = config;
        const videoId = id;
        const compactControls = this.shouldUseCompactControls();

        const art = new Artplayer({
            container: container,
            url: url,
            poster: cover,
            subtitle: { name: title },
            volume: 0.5,
            isLive: false,
            muted: false,
            autoplay: false,
            pip: !compactControls,
            autoMini: !compactControls,
            screenshot: !compactControls,
            setting: !compactControls,
            loop: false,
            flip: !compactControls,
            playbackRate: !compactControls,
            aspectRatio: !compactControls,
            fullscreen: true,
            fullscreenWeb: !compactControls,
            miniProgressBar: true,
            mutex: true,
            backdrop: true,
            playsInline: true,
            autoPlayback: true,
            airplay: true,
            theme: this.getThemeColor(),
            // 使用基类的通用配置
            ...this.getCommonArtplayerOptions(),
            plugins: [
                this.createDanmakuPlugin(videoId, () => this.incrementCounter('danmaku', '弹幕')),
            ],
        });

        // 使用基类的首次播放处理
        art.on(
            'video:play',
            this.createFirstPlayHandler(videoId, () => {
                this.incrementCounter('play', '播放');
            })
        );
    }

    private incrementCounter(type: 'play' | 'danmaku', label: string): void {
        const countEl = document.querySelector<HTMLElement>(`[data-video-counter="${type}"]`);
        if (!countEl) {
            return;
        }

        const count = this.getCounterValue(countEl) + 1;
        countEl.dataset.count = count.toString();
        countEl.innerText = `${count.toLocaleString()} ${label}`;
    }

    private getCounterValue(element: HTMLElement): number {
        const dataValue = Number.parseInt(element.dataset.count ?? '', 10);
        if (Number.isFinite(dataValue)) {
            return dataValue;
        }

        const textValue = Number.parseInt(element.innerText.replace(/[^\d]/g, ''), 10);
        return Number.isFinite(textValue) ? textValue : 0;
    }

    private getThemeColor(): string {
        const color = getComputedStyle(document.documentElement)
            .getPropertyValue('--color-primary')
            .trim();
        return color || '#2563eb';
    }

    private shouldUseCompactControls(): boolean {
        return window.matchMedia('(max-width: 640px)').matches;
    }
}

export class Video {
    constructor() {
        this.initCoverFallbacks();
        const videoPlayer = new VideoPlayer();
        videoPlayer.init();
    }

    private initCoverFallbacks(): void {
        const images = document.querySelectorAll<HTMLImageElement>(
            '.video-card__image, .video-watch__ambient img'
        );
        images.forEach((image) => {
            const onError = (): void => {
                const media = image.closest<HTMLElement>('.video-card__media');
                media?.classList.add('video-card__media--empty');
                image.remove();
            };

            if (image.complete && image.naturalWidth === 0) {
                onError();
                return;
            }

            image.addEventListener('error', onError, { once: true });
        });
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码是一个基于 Artplayer 播放器库的视频播放模块,专门用于视频详情页面。下面我从整体结构、关键类、初始化流程、功能细节几个方面来详细解释。


1. 总体概览

  • 导入外部播放器库 Artplayer
  • 继承自基础播放器类 BaseVideoPlayer(文件 ./BaseVideoPlayer.ts),并使用了其中的通用配置、弹幕插件生成器、首次播放处理等方法。
  • 使用 ConsoleLogger 输出错误日志。
  • 定义了两个对外可用的类:
    • VideoPlayer(内部类,默认不导出)——处理视频播放器初始化及交互。
    • Video(导出类)——页面入口,负责图片加载失败兜底以及调用 VideoPlayer

2. 数据结构

interface VideoInfo {
    id: string;
    title: string;
    cover: string;
    url: string;
}

这是从 DOM 元素 data-video-config 属性中解析出来的视频信息,包含唯一 ID、标题、封面地址和真实视频地址。


3. VideoPlayer

3.1 私有属性

private readonly _containerSelector = '#video-player';
private readonly _dataAttribute = 'data-video-config';
  • _containerSelector:播放器挂载点的 CSS 选择器,默认为 #video-player
  • _dataAttribute:存放视频配置信息的自定义属性名,即 data-video-config

3.2 init() 方法 —— 初始化入口

  • 查找 #video-player 容器,若不存在直接返回。
  • 判断容器是否已经有 artplayer-app 类,如果有说明已初始化过,避免重复创建播放器。
  • 读取 data-video-config 属性值(JSON 字符串),若无则返回。
  • JSON.parse 解析配置,如果解析失败,调用 logger.outPutError 输出错误。
  • 解析成功则调用 setupPlayer(container, config)

3.3 setupPlayer() 方法 —— 创建播放器

这是核心方法,负责实例化 Artplayer。它做了以下几件事:

  • 从配置中解构取得 idurlcovertitle

  • 根据屏幕宽度(通过 shouldUseCompactControls() 判断)决定是否启用精简控制栏(移动端更紧凑)。

  • 创建 Artplayer 实例,传入大量选项,例如:

    • container:挂载容器。
    • url / poster:视频地址和封面。
    • volume:默认音量 0.5。
    • isLive: false(非直播)。
    • autoplay: falsemuted: false
    • 根据 compactControls 动态控制 pip(画中画)、screenshot(截图)、setting(设置面板)、flip(镜像翻转)、playbackRate(倍速)、aspectRatio(画面比例)、fullscreenWeb(网页全屏)等功能的开启状态。
    • 始终启用 fullscreen(全屏)、miniProgressBar(迷你进度条)、mutex(互斥播放)、playsInline(内联播放)、airplay(隔空播放)等。
    • theme:从 CSS 变量 --color-primary 获取主题色,若为空则使用默认蓝色 #2563eb
    • ...this.getCommonArtplayerOptions():展开基类提供的通用选项。
    • plugins:使用基类的 createDanmakuPlugin(videoId, () => this.incrementCounter(...)) 创建弹幕插件。
  • 监听播放器的 video:play 事件,调用基类的 createFirstPlayHandler 方法,确保只在第一次播放时执行点击数更新逻辑(调用 incrementCounter('play', '播放'))。这样可以避免用户重复播放时计数重复增加。

3.4 计数相关方法

incrementCounter(type, label) 用于更新播放次数或弹幕数:

  • 通过 data-video-counter 属性(值为 'play''danmaku')找到对应的 DOM 元素。
  • 从元素属性 data-count 或文本内容中读取数字。
  • 加 1 后更新 data-countinnerText,文本格式为“数字 标签”(例如 1,234 播放)。

getCounterValue(element) 是一个辅助方法,优先读取 data-count,其次从 innerText 中剥离非数字字符得到数值,最后返回 0(当无法解析时)。

3.5 辅助方法

  • getThemeColor():读取 CSS 变量 --color-primary,如果没设置则返回 #2563eb
  • shouldUseCompactControls():通过 window.matchMedia('(max-width: 640px)') 判断是否为小屏设备(如手机),返回布尔值。

4. BaseVideoPlayer 的作用

虽然代码中未展示 BaseVideoPlayer 的内容,但从调用方式可知它至少提供了:

  • getCommonArtplayerOptions():返回一组通用播放器配置,可能包含公共事件绑定、自定义控制按钮等。
  • createDanmakuPlugin(videoId, callback):生成一个弹幕插件,初始化或渲染弹幕,并在某种时机(如发送弹幕)调用回调函数用于计数。
  • createFirstPlayHandler(videoId, callback):返回一个处理函数,确保只在首次播放时执行给定的 callback(可能还会处理 localStorage 记录观看历史等)。

这些通用逻辑被抽出到基类中,提高了代码复用性。


5. Video 类(导出)

这是页面级的入口类,构造时执行两件事:

  1. 初始化视频播放器
    创建 VideoPlayer 实例并调用其 init() 方法。

  2. 处理封面图加载失败
    initCoverFallbacks() 查找所有 .video-card__image 以及 .video-watch__ambient img 的图片元素,并监听 error 事件。如果图片加载失败(或用自然宽度判断为破图),则:

    • 找到最近的 .video-card__media 容器,给它添加 video-card__media--empty 类,用于显示空状态样式。
    • 移除该 img 元素,避免显示破碎图标。
    • 如果图片已经加载完成且宽度为 0(说明破图),则立即处理,不再等待事件。

6. 整体设计特点

  • 防御式编程:处处检查元素是否存在,避免报错。
  • 幂等性:通过 artplayer-app 类避免重复初始化。
  • 配置集中化:使用 data-* 属性传入数据,便于后端动态渲染。
  • 移动端适配:小屏幕下自动隐藏部分功能,只保留核心操作。
  • 可扩展性:继承基类 + 插件机制,使弹幕、统计等逻辑与播放器本身解耦。
  • 用户体验:自动处理封面加载失败;记录播放/弹幕次数并实时显示。

7. 使用场景示例

假设 HTML 中有如下结构:

<div id="video-player"
     data-video-config='{"id":"abc123","title":"示例视频","cover":"https://.../cover.jpg","url":"https://.../video.mp4"}'>
</div>

<div class="video-watch__ambient">
    <img src="https://.../bg.jpg" alt="背景图">
</div>

<span data-video-counter="play">0 播放</span>
<span data-video-counter="danmaku">0 弹幕</span>

new Video() 执行后:

  • 自动初始化播放器并启用弹幕功能。
  • 用户首次点击播放后,播放数加 1。
  • 用户发送弹幕后,弹幕数加 1。
  • 背景图加载失败则自动移除并应用空样式。

8. 总结

这是一个结构清晰、面向对象设计的视频播放器模块,它把播放器初始化、配置解析、友好的移动端体验、统计上报、错误处理以及对图片加载失败的兼容都集中在一个类族中。通过继承和支持插件,它能在不同页面和需求中灵活复用。如果在开发中遇到具体方法或基类实现,可以进一步阅读 BaseVideoPlayer.ts 来理解完整的逻辑。

评论加载中...