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 });
});
}
}
这段代码是一个基于 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。它做了以下几件事:
从配置中解构取得
id、url、cover、title。根据屏幕宽度(通过
shouldUseCompactControls()判断)决定是否启用精简控制栏(移动端更紧凑)。创建
Artplayer实例,传入大量选项,例如:container:挂载容器。url/poster:视频地址和封面。volume:默认音量 0.5。isLive: false(非直播)。autoplay: false、muted: 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-count和innerText,文本格式为“数字 标签”(例如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 类(导出)
这是页面级的入口类,构造时执行两件事:
初始化视频播放器
创建VideoPlayer实例并调用其init()方法。处理封面图加载失败
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 来理解完整的逻辑。