/* 播放器模块(`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 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)({}); 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 = '
'; 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 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('.vjs-peach-settings-toggle')!.click(); bar.querySelector('[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('.vjs-peach-settings-toggle')!.click(); for (const angle of [90, 180, 270, 0]) { view.bar.querySelector('[data-player-rotation]')!.click(); expect(view.bar.querySelectorAll('[data-player-rotation-option]')).toHaveLength(4); view.bar.querySelector(`[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('[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('[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('[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('[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('.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('.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(); 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], ]); }); });