/* 字幕恢复与用户新选择的先后关系,不依赖浏览器解码器。 */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { SubtitleSession } from '../src/player/subtitle-memory'; import type { VjsPlayer } from '../src/player/types'; import { mountPlaybackRate, playerMemory } from '../src/player/memory'; import { mountPlayerRotation } from '../src/player/rotation'; import { mountCaptionStyle } from '../src/player/subtitle-style'; function mounted() { const video = document.createElement('video'), events = new EventTarget(); const tracks = Object.assign([] as TextTrack[], { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), }); Object.defineProperty(video, 'textTracks', { value: tracks }); const disposing: (() => void)[] = []; const root = document.createElement('div'); root.innerHTML = ''; let rate = 1; const player = { peachItem: { id: 1 }, on: (_event: string, callback: () => void) => disposing.push(callback), el: () => root, playbackRate: (next?: number) => next === undefined ? rate : (rate = next), isDisposed: () => false } as unknown as VjsPlayer; return { video, tracks, player, add: (id: string) => { const track = Object.assign(new EventTarget(), { id, kind: 'subtitles', label: id, language: 'zh', mode: 'disabled' }) as TextTrack; tracks.push(track); events.dispatchEvent(new Event('addtrack')); return track; }, dispose: () => disposing.forEach(callback => callback()) }; } afterEach(() => vi.unstubAllGlobals()); describe('统一播放文件记忆', () => { const observer = () => vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); const flush = () => new Promise(resolve => setTimeout(resolve, 0)); it('字幕、旋转、倍速与外观共用一次读取,关闭播放器后由新会话恢复', async () => { observer(); let saved = { selected: 'sidecar:2', tracks: [], rotation: 270, rate: 1.75, caption_style: { size: '150' } }; const fetcher = vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') saved = { ...saved, ...JSON.parse(String(init.body)) }; return new Response(JSON.stringify(saved)); }); vi.stubGlobal('fetch', fetcher); const view = mounted(), memory = playerMemory(view.player); const rotation = mountPlayerRotation(view.player, view.video); const setRate = mountPlaybackRate(view.player, view.video); const cleanup = new AbortController(); const style = mountCaptionStyle(view.player.el(), view.video, cleanup.signal, memory); const subtitles = new SubtitleSession(view.player, view.video); await subtitles.ready; expect(fetcher).toHaveBeenCalledOnce(); expect(rotation.angle()).toBe(270); expect(view.player.playbackRate()).toBe(1.75); expect(view.video.defaultPlaybackRate).toBe(1.75); expect(style.current().size).toBe('150'); expect(view.add('sidecar:2').mode).toBe('showing'); rotation.set(90); setRate.set(2); style.set('size', '200'); subtitles.select(null); view.video.playbackRate = view.video.defaultPlaybackRate; view.video.dispatchEvent(new Event('loadedmetadata')); expect(setRate.current()).toBe(2); expect(view.video.defaultPlaybackRate).toBe(2); await flush(); view.dispose(); cleanup.abort(); const reopened = mounted(); const restored = mountPlayerRotation(reopened.player, reopened.video); mountPlaybackRate(reopened.player, reopened.video); await playerMemory(reopened.player).load(); await flush(); expect(restored.angle()).toBe(90); expect(reopened.player.playbackRate()).toBe(2); expect(saved.selected).toBeNull(); expect(saved.caption_style.size).toBe('200'); reopened.dispose(); }); it('读取期间修改设置,迟到的记录只恢复尚未操作的字段', async () => { observer(); let loaded!: (response: Response) => void; const response = new Promise(resolve => { loaded = resolve }); const writes: unknown[] = []; vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') { writes.push(JSON.parse(String(init.body))); return new Response('{}') } return response; })); const view = mounted(), memory = playerMemory(view.player); const rotation = mountPlayerRotation(view.player, view.video), rate = mountPlaybackRate(view.player, view.video); const cleanup = new AbortController(); const style = mountCaptionStyle(view.player.el(), view.video, cleanup.signal, memory); await Promise.resolve(); rotation.set(180); rate.set(1.25); style.set('size', '200'); loaded(new Response(JSON.stringify({ selected: null, tracks: [], rotation: 90, rate: 2, caption_style: { size: '150', color: 'yellow' } }))); await memory.load(); await flush(); expect(rotation.angle()).toBe(180); expect(view.player.playbackRate()).toBe(1.25); expect(style.current()).toMatchObject({ size: '200', color: 'yellow' }); expect(writes).toEqual([{ rotation: 180 }, { rate: 1.25 }, { caption_style: { size: '200' } }]); view.dispose(); cleanup.abort(); }); it('失败的字段可重试,其他设置不会覆盖未保存的值', async () => { const writes: unknown[] = []; let fail = true; vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method !== 'PUT') return new Response('{"selected":null,"tracks":[]}'); writes.push(JSON.parse(String(init.body))); return fail ? new Response('{"error":"磁盘不可写"}', { status: 503 }) : new Response('{}'); })); const view = mounted(), memory = playerMemory(view.player); await expect(memory.save({ rotation: 90 })).rejects.toThrow('磁盘不可写'); fail = false; await memory.save({ rate: 2 }); expect(writes).toEqual([{ rotation: 90 }, { rotation: 90, rate: 2 }]); expect(memory.error).toBe(''); view.dispose(); }); }); describe('每部视频的字幕选择', () => { it('登记轨道的选择可通过字幕别名恢复', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ selected: 'sidecar:2', tracks: [] })))); const view = mounted(), session = new SubtitleSession(view.player, view.video); await session.ready; const track = view.add('sidecar:local:stable'); const node = document.createElement('track'); node.dataset.subtitleAlias = 'sidecar:2'; Object.defineProperty(node, 'track', { value: track }); view.video.append(node); view.video.dispatchEvent(new Event('loadedmetadata')); expect(track.mode).toBe('showing'); view.dispose(); }); it('按身份恢复晚到的旁挂轨道,列表顺序不参与选择', async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ selected: 'sidecar:2', tracks: [] }))); vi.stubGlobal('fetch', fetcher); const view = mounted(), session = new SubtitleSession(view.player, view.video); await session.ready; const second = view.add('sidecar:2'), first = view.add('sidecar:1'); expect(second.mode).toBe('showing'); expect(first.mode).toBe('disabled'); expect(fetcher).toHaveBeenCalledOnce(); view.dispose(); }); it('读取记忆期间选择关闭,迟到的响应和轨道都不能重新开启字幕', async () => { let loaded!: (response: Response) => void; const response = new Promise(resolve => { loaded = resolve }); const writes: unknown[] = []; vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') { writes.push(JSON.parse(String(init.body))); return new Response('{}') } return response; })); const view = mounted(), session = new SubtitleSession(view.player, view.video); await Promise.resolve(); session.select(null); loaded(new Response(JSON.stringify({ selected: 'sidecar:2', tracks: [] }))); await session.ready; const track = view.add('sidecar:2'); expect(track.mode).toBe('disabled'); expect(writes).toEqual([{ selected: null }]); view.dispose(); }); });