frontend/test/player.test.ts
Rama master · réplica de lectura del repositorio original.
/* 播放器模块(`src/player/`)里不依赖真 Video.js 的几块:片源判据、流会话取消、沉浸模式的每一格、观看上报、
* 加载读数、画中画的 Media Session 与外挂字幕。挂上真 Video.js 的那一面在 `e2e/stage.test.ts`。 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountPlayerMediaSession, mountPlayerQualityControl, mountPlayerSubtitles } from '../src/player/controls';
import { configurePlayer, type PlayerHost } from '../src/player/host';
import { averageBitrate, bufferedAhead, fmtLoadRate, pushPlayerStat } from '../src/player/stats';
import {
cancelDetailStream, detailStreamSource, detailStreamSession, directStreamSource, playableStreamSource,
} from '../src/player/stream';
import { mountPlayer } from '../src/player/detail-player';
import { beginPlayerGuidePreview } from '../src/player/guide-preview';
import { wireTelemetry } from '../src/player/telemetry';
import type { QualityLevel, QualityLevelList, VjsPlayer } from '../src/player/types';
import * as videojsLoader from '../src/player/videojs';
import { mountPlayerPipControl } from '../src/player/pip';
type FetchCall = { url: string; init: RequestInit | undefined };
function stubFetch(reply: (url: string) => unknown): FetchCall[] {
const calls: FetchCall[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return new Response(JSON.stringify(reply(url) ?? null), { status: 200, headers: { 'Content-Type': 'application/json' } });
}));
return calls;
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const host = (seekSeconds = 10): PlayerHost => ({
settings: () => ({ ambientMode: false, theaterMode: false, seekSeconds, miniplayer: true, detailAutoplay: false, javImage: 'cover' }),
saveSettings: () => {},
toast: () => {},
loadSourceStatus: async () => ({}),
offlineReason: () => '',
stage: () => null,
});
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
it('播放器导览抑制片源与自动播放,结束后沿用已保存的播放偏好', async () => {
const settings = { ...host().settings(), detailAutoplay: true };
const saveSettings = vi.fn();
configurePlayer({ ...host(), settings: () => settings, saveSettings });
const sources = vi.fn(), play = vi.fn().mockResolvedValue(undefined);
vi.spyOn(videojsLoader, 'ensureVideojs').mockResolvedValue(vi.fn((video: HTMLVideoElement) => {
const shell = document.createElement('div'); shell.append(video);
let disposed = false;
return {
el: () => shell, on: () => {}, one: () => {}, ready: () => {},
isDisposed: () => disposed, dispose: () => { disposed = true }, pause: () => {},
src: sources, play, duration: () => 0, currentSource: () => ({}),
} as unknown as VjsPlayer;
}));
const calls = stubFetch(() => ({ protocol: 'range', src: '/stream?id=42', mime_type: 'video/mp4' }));
const finishGuide = beginPlayerGuidePreview();
const releaseGuide = mountPlayer(document.createElement('video'), { kind: 'item', item: { id: 42 }, chrome: false });
try {
await flush(); await flush();
expect(sources).not.toHaveBeenCalled(); expect(play).not.toHaveBeenCalled();
expect(calls).toEqual([]);
} finally { releaseGuide(); finishGuide() }
const releaseNormal = mountPlayer(document.createElement('video'), { kind: 'item', item: { id: 42 }, chrome: false });
try {
await flush(); await flush();
expect(sources).toHaveBeenCalled(); expect(play).toHaveBeenCalledOnce();
expect(settings.detailAutoplay).toBe(true); expect(saveSettings).not.toHaveBeenCalled();
} finally { releaseNormal(); configurePlayer(host()) }
});
describe('片源判据', () => {
it('保存过的在线作品走关注条目的代理,不问 stream-plan', async () => {
const calls = stubFetch(() => null);
const source = await playableStreamSource({ id: 7, location: 'online', follow_item_id: 42 }, 's1');
expect(source).toEqual({ src: '/follow-stream?id=42', type: 'video/mp4' });
expect(calls).toEqual([]);
});
it('服务端要转码分片就给分片;计划取不到就带会话直读,webm 按扩展名给类型', async () => {
stubFetch(() => ({ protocol: 'hls', src: '/stream/hls/3/index.m3u8' }));
expect(await playableStreamSource({ id: 3 }, 's2'))
.toEqual({ src: '/stream/hls/3/index.m3u8', type: 'application/vnd.apple.mpegurl' });
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch') }));
expect(await playableStreamSource({ id: 3, name: 'a.WEBM' }, 's 2'))
.toEqual({ src: '/stream?id=3&session=s%202', type: 'video/webm' });
expect(directStreamSource({ id: 4 }, 'x').type).toBe('video/mp4');
});
it('详情这一路共用一个会话,取消时按会话通知服务端并清空', async () => {
const calls = stubFetch(() => ({ cancelled: 1 }));
await detailStreamSource({ id: 5 });
const session = detailStreamSession();
expect(session).not.toBe('');
expect(calls[0]!.url).toBe(`/api/stream-plan?id=5&session=${encodeURIComponent(session)}`);
cancelDetailStream();
expect(detailStreamSession()).toBe('');
const cancel = calls.at(-1)!;
expect(cancel.url).toBe(`/api/stream-cancel?session=${encodeURIComponent(session)}`);
expect(cancel.init).toMatchObject({ method: 'POST', keepalive: true });
await flush(); await flush();
expect(JSON.parse(document.documentElement.dataset.peachStreamCancel || 'null')).toEqual({ cancelled: 1 });
});
it('STRM 的文件类型使用服务端播放计划', async () => {
stubFetch(() => ({ protocol: 'range', src: '/stream?id=6', mime_type: 'video/webm' }));
expect(await playableStreamSource({ id: 6, name: 'ABC-123.strm' }, 'strm'))
.toEqual({ src: '/stream?id=6', type: 'video/webm' });
});
});
describe('观看上报', () => {
beforeEach(() => { vi.useFakeTimers(); });
it('离开详情时既不 pause 也不 ended:emptied 收尾停表', async () => {
const calls = stubFetch(() => ({ real_ratio: .5 }));
const video = document.createElement('video');
wireTelemetry({ id: 9, duration: -1 }, video);
video.onplay?.(new Event('play'));
video.dispatchEvent(new Event('seeking'));
vi.advanceTimersByTime(10_000);
expect(calls.filter((call) => call.url === '/api/activity')).toHaveLength(1);
video.dispatchEvent(new Event('emptied'));
const settled = calls.length;
vi.advanceTimersByTime(60_000);
expect(calls).toHaveLength(settled);
});
it('放完冲一次 ended,再交给调用方接着放下一条;-1 时长不画负比例', () => {
const calls = stubFetch(() => null);
const video = document.createElement('video');
const ratio = document.createElement('span');
ratio.id = 'ratioProbe';
document.body.append(ratio);
const onEnded = vi.fn();
wireTelemetry({ id: 11, duration: -1 }, video, { ratio: '#ratioProbe', onEnded });
expect(ratio.textContent).toBe('');
video.onended?.(new Event('ended'));
const body = JSON.parse(String(calls[0]!.init?.body));
expect(body).toMatchObject({ id: 11, ended: true, duration: 0 });
expect(onEnded).toHaveBeenCalledOnce();
ratio.remove();
});
});
describe('沉浸模式的每一格', () => {
/** 一个记账的 Video.js:记下挂了什么片源、`error` 一次性监听由用例触发。 */
function fakeVideojs() {
const made: { sources: unknown[]; disposed: boolean; error?: () => void }[] = [];
const factory = vi.fn((video: HTMLVideoElement) => {
const record: (typeof made)[number] = { sources: [], disposed: false };
made.push(record);
const shell = document.createElement('div');
const container = document.createElement('media-container'); container.append(video); shell.append(container);
Object.defineProperty(video, 'textTracks', { value: Object.assign([], new EventTarget(), {
addEventListener: () => {}, removeEventListener: () => {},
}) });
const disposing: (() => void)[] = [];
return {
on: (_event: string, handler: () => void) => { disposing.push(handler) },
addRemoteTextTrack: () => {},
src: (source: unknown) => { record.sources.push(source) },
one: (_event: string, handler: () => void) => { record.error = handler },
el: () => shell,
isDisposed: () => record.disposed,
pause: () => {},
playbackRate: (next?: number) => next === undefined ? video.playbackRate : (video.playbackRate = next),
dispose: () => { record.disposed = true; disposing.forEach(handler => handler()) },
} as unknown as VjsPlayer;
});
vi.spyOn(videojsLoader, 'ensureVideojs').mockResolvedValue(factory);
return { factory, made };
}
it('片源按 stream-plan 交给裸播放器,分片出错退回直读;拆这一格时按会话取消并拆播放器', async () => {
const calls = stubFetch((url) => url.startsWith('/api/player/memory') ? { selected: null, tracks: [] }
: url.startsWith('/api/stream-plan') ? { protocol: 'hls', src: '/stream/hls/8/index.m3u8' } : { cancelled: 1 });
const { factory, made } = fakeVideojs();
const video = document.createElement('video');
const onLoaded = vi.fn();
const dispose = mountPlayer(video, { kind: 'immerse', item: { id: 8 }, session: 'imm-1', onLoaded });
await flush(); await flush();
expect(factory).toHaveBeenCalledWith(video, expect.objectContaining({ controls: false, controlBar: false }));
expect(made[0]!.sources).toEqual([{ src: '/stream/hls/8/index.m3u8', type: 'application/vnd.apple.mpegurl' }]);
expect(onLoaded).toHaveBeenCalledOnce();
made[0]!.error!();
expect(made[0]!.sources.at(-1)).toEqual({ src: '/stream?id=8&session=imm-1', type: 'video/mp4' });
dispose();
dispose();
expect(made[0]!.disposed).toBe(true);
const cancels = calls.filter((call) => call.url.startsWith('/api/stream-cancel'));
expect(cancels.map((call) => call.url)).toEqual(['/api/stream-cancel?session=imm-1']);
});
it('片源还没解析出来就拆掉:不再挂播放器,也不回调', async () => {
stubFetch(() => ({ protocol: 'direct', src: '/stream?id=9' }));
const { factory } = fakeVideojs();
const onLoaded = vi.fn();
const dispose = mountPlayer(document.createElement('video'), { kind: 'immerse', item: { id: 9 }, session: 'imm-2', onLoaded });
dispose();
await flush(); await flush();
expect(factory).not.toHaveBeenCalled();
expect(onLoaded).not.toHaveBeenCalled();
});
});
describe('加载读数', () => {
const buffered = (ranges: [number, number][]) => ({
length: ranges.length, start: (i: number) => ranges[i]![0], end: (i: number) => ranges[i]![1],
});
it('缓冲健康只数播放位置所在那一段;码率要有大小和真时长才算得出', () => {
const video = { currentTime: 12, buffered: buffered([[0, 5], [10, 30]]) } as unknown as HTMLVideoElement;
expect(bufferedAhead(video)).toBe(18);
expect(averageBitrate(1_000_000, 8)).toBe(1_000_000);
expect(averageBitrate(1_000_000, -1)).toBe(0);
});
it('码率未知时报还能往前放多久,不报「× 实时」这类要换算的口径', () => {
expect(fmtLoadRate(0, 12.4)).toBe('已缓冲 12 秒');
expect(fmtLoadRate(8 * 2048, 0)).toBe('2 KB/s');
expect(fmtLoadRate(0, 0)).toBe('加载中…');
});
it('三条指标各留 24 个采样,坏值记成 0', () => {
const samples: number[] = [];
for (let i = 1; i <= 30; i++) pushPlayerStat(samples, i);
pushPlayerStat(samples, Number.NaN);
expect(samples).toHaveLength(24);
expect(samples[0]).toBe(8);
expect(samples.at(-1)).toBe(0);
});
});
function fakePlayer(state: { time: number; duration: number }) {
const handlers = new Map<string, (() => void)[]>();
const player = {
on(events: string | string[], fn: () => void) {
for (const event of [events].flat()) handlers.set(event, [...handlers.get(event) || [], fn]);
},
currentTime(value?: number) { if (value !== undefined) state.time = value; return state.time },
duration: () => state.duration,
playbackRate: () => 1,
play: () => Promise.resolve(),
pause: () => {},
isDisposed: () => false,
addRemoteTextTrack: vi.fn(),
};
const fire = (event: string) => handlers.get(event)?.forEach((fn) => fn());
return { player: player as unknown as VjsPlayer, raw: player, fire };
}
it('画中画入口保持占位,媒体就绪后启用,浏览器不支持时保留原因并禁用', async () => {
const root = document.createElement('div'), video = document.createElement('video'), button = document.createElement('button');
root.append(video, button);
let ready = 0;
Object.defineProperties(video, { readyState: { get: () => ready }, videoWidth: { value: 1280 },
webkitSetPresentationMode: { value: () => {} } });
const original = vi.fn(async () => { throw new DOMException('Picture-in-Picture is not available', 'NotSupportedError') });
const pip = Object.assign(button, { activate: original });
const { player, fire } = fakePlayer({ time: 0, duration: 10 });
Object.assign(player, { el: () => root });
const label = vi.fn(), failure = vi.fn();
mountPlayerPipControl(player, pip, label, failure);
expect(pip.hasAttribute('data-peach-pip-supported')).toBe(true);
expect(pip.disabled).toBe(true);
ready = 4; video.dispatchEvent(new Event('loadedmetadata'));
expect(pip.disabled).toBe(false);
await (pip.activate as (media: unknown) => Promise<unknown>)({});
expect(failure).toHaveBeenCalledWith('当前浏览器不支持画中画');
expect(pip.disabled).toBe(true);
expect(pip.getAttribute('label')).toBe('当前浏览器不支持画中画');
fire('dispose');
expect(pip.activate).toBe(original);
});
describe('播放器清晰度', () => {
function menu(entries: QualityLevel[], selectedIndex = 0, size: { width?: number; height?: number } = { width: 3840, height: 2160 }) {
configurePlayer(host());
const root = document.createElement('div'), bar = document.createElement('div'), video = document.createElement('video');
bar.className = 'vjs-control-bar';
bar.innerHTML = '<div class="vjs-subtitles-control vjs-control"><button type="button">CC</button></div>';
const container = document.createElement('media-container'); container.append(video, bar);
root.append(container); document.body.append(root);
const { player, fire } = fakePlayer({ time: 0, duration: 100 });
const events = new Map<string, () => void>();
const levels = Object.assign(entries, { selectedIndex, on: (names: string | string[], fn: () => void) => {
for (const name of [names].flat()) events.set(name, fn);
} }) as QualityLevelList;
Object.assign(player, { el: () => root, qualityLevels: () => levels,
paused: () => true, ended: () => false, isFullscreen: () => false, muted: () => false, volume: () => 1, trigger: () => {} });
mountPlayerQualityControl(player, video, size);
const open = () => {
bar.querySelector<HTMLButtonElement>('.vjs-peach-settings-toggle')!.click();
bar.querySelector<HTMLButtonElement>('[data-player-quality-view]')!.click();
};
const labels = () => [...bar.querySelectorAll('[data-player-quality-option]')].map((node) => node.textContent);
return { bar, video, levels, open, labels, change: () => events.get('change')?.(), notify: (name: string) => events.get(name)?.(),
close: () => { fire('dispose'); root.remove() } };
}
it('设置菜单选择四个方向并可恢复原始画面', () => {
const view = menu([]);
try {
Object.defineProperties(view.video, { videoWidth: { value: 1920 }, videoHeight: { value: 1080 },
clientWidth: { value: 640 }, clientHeight: { value: 360 } });
Object.defineProperties(view.video.closest('media-container')!, { clientWidth: { value: 640 }, clientHeight: { value: 360 } });
view.bar.querySelector<HTMLButtonElement>('.vjs-peach-settings-toggle')!.click();
for (const angle of [90, 180, 270, 0]) {
view.bar.querySelector<HTMLButtonElement>('[data-player-rotation]')!.click();
expect(view.bar.querySelectorAll('[data-player-rotation-option]')).toHaveLength(4);
view.bar.querySelector<HTMLButtonElement>(`[data-player-rotation-option="${angle}"]`)!.click();
expect(view.video.style.transform).toBe(angle % 180 ? `translate(-50%, -50%) rotate(${angle}deg)` : angle ? `rotate(${angle}deg)` : '');
expect(view.video.style.width).toBe(angle % 180 ? '360px' : '');
expect(view.video.style.height).toBe(angle % 180 ? '640px' : '');
}
} finally { view.close() }
expect(view.video.style.transform).toBe('');
});
it('单档 HLS 用实际视频尺寸命名,不展示轨道地址或虚构自动档', () => {
const view = menu([{ id: '0-https://example.test/index.m3u8?session=private', enabled: true }]);
try {
Object.defineProperties(view.video, { videoWidth: { value: 1920 }, videoHeight: { value: 1080 } });
view.video.dispatchEvent(new Event('loadedmetadata'));
view.open();
expect(view.labels()).toEqual(['1080p']);
expect(view.bar.querySelector('[data-player-quality-option]')?.getAttribute('aria-checked')).toBe('true');
expect(view.bar.querySelector('[data-player-quality-badge]')?.textContent).toBe('HD');
view.bar.querySelector<HTMLButtonElement>('[data-player-quality-option]')!.click();
expect(view.levels[0]!.enabled).toBe(true);
} finally { view.close() }
});
it('尺寸未取得的单档不拿源文件高度猜清晰度', () => {
const view = menu([{ id: 'private-stream-url', enabled: true }]);
try {
view.open();
expect(view.labels()).toEqual(['当前画质']);
expect(view.bar.querySelector<HTMLElement>('[data-player-quality-badge]')!.hidden).toBe(true);
} finally { view.close() }
});
it('轨道逐个加入时保留自动选择', () => {
const entries = [{ width: 640, height: 360, enabled: true }];
const view = menu(entries);
try {
view.open();
entries.push({ width: 1920, height: 1080, enabled: true });
view.notify('addqualitylevel');
expect(view.labels()).toEqual(['自动', '360p', '1080p']);
expect(view.bar.querySelector('[data-player-quality-option="auto"]')?.getAttribute('aria-checked')).toBe('true');
} finally { view.close() }
});
it('自动档的角标跟随正在播放的轨道,未知档位使用线路名', () => {
const view = menu([{ width: 640, height: 360, enabled: true }, { width: 3840, height: 2160, enabled: true },
{ id: 'private-url', enabled: true }]);
try {
view.open();
expect(view.labels()).toEqual(['自动', '360p', '2160p', '线路 3']);
const badge = view.bar.querySelector<HTMLElement>('[data-player-quality-badge]')!;
expect(badge.hidden).toBe(true);
view.levels.selectedIndex = 1; view.change();
expect(badge.textContent).toBe('4K');
expect(badge.hidden).toBe(false);
} finally { view.close() }
});
it('菜单开着时换轨与尺寸变化只就地更新清晰度面板,焦点留在原档', () => {
const entries = [{ width: 640, height: 360, enabled: true }, { width: 1920, height: 1080, enabled: true }];
const view = menu(entries);
try {
view.open();
view.bar.querySelector<HTMLElement>('[data-player-quality-option="1"]')!.focus();
view.levels.selectedIndex = 1; view.change();
view.video.dispatchEvent(new Event('resize'));
entries.push({ width: 3840, height: 2160, enabled: true });
view.notify('addqualitylevel');
const panel = view.bar.querySelector('.vjs-peach-settings-menu')!.lastElementChild!;
expect(panel.querySelector('strong')?.textContent).toBe('清晰度');
expect([...panel.querySelectorAll('[data-player-quality-option]')].map((node) => node.textContent))
.toEqual(['自动', '360p', '1080p', '2160p']);
expect((document.activeElement as HTMLElement).dataset.playerQualityOption).toBe('1');
} finally { view.close() }
});
it('视频还没读到尺寸时按账本宽高的短边写档位,竖屏不写成 1920p', () => {
const view = menu([], 0, { width: 1080, height: 1920 });
try {
view.open();
expect(view.labels()).toEqual(['1080p']);
} finally { view.close() }
});
it('点进清晰度时菜单按填好档位的面板高度过渡,不先缩成只剩标题栏', async () => {
const view = menu([{ width: 640, height: 360, enabled: true }, { width: 1920, height: 1080, enabled: true }]);
// 原型链上 Element 那一层的 scrollHeight 不动,这里只在 HTMLElement 上遮一层,用完删掉。一行 40px:标题栏一行,再加面板里此刻已有的每一档。
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get(this: HTMLElement) {
return 40 * (this.querySelectorAll('.vjs-peach-panel-header').length + this.querySelectorAll('[data-player-quality-option]').length);
} });
try {
view.open();
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(view.labels()).toEqual(['自动', '360p', '1080p']);
expect(view.bar.querySelector<HTMLElement>('.vjs-peach-settings-menu')!.style.height).toBe('160px');
} finally {
delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight;
view.close();
}
});
it('主菜单开着时尺寸变化只改清晰度那一行的读数', () => {
const view = menu([{ id: 'private-stream-url', enabled: true }]);
try {
view.bar.querySelector<HTMLButtonElement>('.vjs-peach-settings-toggle')!.click();
const row = view.bar.querySelector('[data-player-quality-view]')!;
expect(row.querySelector('b')?.textContent).toBe('当前画质');
Object.defineProperties(view.video, { videoWidth: { value: 1920 }, videoHeight: { value: 1080 } });
view.video.dispatchEvent(new Event('resize'));
expect(view.bar.querySelector('[data-player-quality-view]')).toBe(row);
expect(row.querySelector('b')?.textContent).toBe('1080p');
} finally { view.close() }
});
});
describe('画中画的快进快退', () => {
it('逐个登记动作,一个不认不带走其余;步长取设置;换片时整组摘掉', () => {
configurePlayer(host(15));
const handlers = new Map<string, MediaSessionActionHandler | null>();
const positions: unknown[] = [];
vi.stubGlobal('navigator', { mediaSession: {
setActionHandler(action: string, handler: MediaSessionActionHandler | null) {
if (action === 'seekto' && handler) throw new TypeError('unsupported');
handlers.set(action, handler);
},
setPositionState(state?: unknown) { positions.push(state) },
} });
const state = { time: 100, duration: 600 };
const { player, fire } = fakePlayer(state);
mountPlayerMediaSession(player, { id: 1, duration: 600 });
expect([...handlers.keys()]).toEqual(['play', 'pause', 'seekbackward', 'seekforward']);
handlers.get('seekforward')!({ action: 'seekforward' });
expect(state.time).toBe(115);
handlers.get('seekbackward')!({ action: 'seekbackward', seekOffset: 200 });
expect(state.time).toBe(0);
expect(positions[0]).toEqual({ duration: 600, position: 100, playbackRate: 1 });
state.time = 700; fire('timeupdate');
expect(positions).toHaveLength(1);
fire('dispose');
expect([...handlers.values()].every((handler) => handler === null)).toBe(true);
});
});
describe('外挂字幕', () => {
it('能播的轨全挂上,一条都不预先打开', async () => {
stubFetch(() => ({ subtitles: [
{ id: 'sidecar:1', playable: true, src: '/sub/1.vtt', language: 'zh', label: '简体' },
{ id: 'sidecar:2', playable: false, src: '/sub/2.ass', language: 'ja', label: '日语' },
{ id: 'sidecar:3', playable: true, src: '/sub/3.vtt', label: '繁体' },
] }));
const { player, raw } = fakePlayer({ time: 0, duration: 0 });
mountPlayerSubtitles(player, 21);
await flush(); await flush();
expect(raw.addRemoteTextTrack.mock.calls).toEqual([
[{ id: 'sidecar:1', kind: 'subtitles', src: '/sub/1.vtt', srclang: 'zh', label: '简体', default: false }, true],
[{ id: 'sidecar:3', kind: 'subtitles', src: '/sub/3.vtt', srclang: '', label: '繁体', default: false }, true],
]);
});
});