import type * as Pixi from 'pixi.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * When the video library lets a player go (#402): after 2 s nobody asked for it, but never while * the page is hidden. Chrome draws no frames for a hidden page and so nothing asks, while the * sweeper's timer goes on; a sprite still wearing the video lost it and Pixi warned about the * destroyed source on the way back. */ // Pixi's own video source reads the page as it is made; a plain source stands in for it. vi.mock('pixi.js', async (load) => { const pixi = await load(); class VideoSource extends pixi.TextureSource { constructor() { super({ width: 320, height: 180 }); } } return { ...pixi, VideoSource }; }); const KEY = `video:a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4#0~0~1~1`; let videosMade = 0; function fakeVideo() { videosMade += 1; return { muted: false, playsInline: false, preload: '', src: '', currentTime: 0, loop: false, paused: true, addEventListener: () => {}, requestVideoFrameCallback: () => 0, play: () => Promise.resolve(), pause: () => {}, removeAttribute: () => {}, load: () => {}, }; } const page = { hidden: false, listeners: [] as (() => void)[] }; function setHidden(hidden: boolean): void { page.hidden = hidden; for (const listener of page.listeners) listener(); } async function freshLibrary() { const { VideoLibrary } = await import('../../src/viewport/VideoLibrary'); return new VideoLibrary(); } describe('VideoLibrary sweeping', () => { beforeEach(() => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] }); page.hidden = false; page.listeners = []; vi.stubGlobal('document', { get hidden() { return page.hidden; }, addEventListener: (_type: string, listener: () => void) => page.listeners.push(listener), createElement: fakeVideo, }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('lets a player go that nobody asked for in 2 s', async () => { const videos = await freshLibrary(); videos.get(KEY); vi.advanceTimersByTime(4100); expect(videos.debugInfo()).toEqual([]); }); it('keeps it while the page is hidden, however long', async () => { const videos = await freshLibrary(); videos.get(KEY); setHidden(true); vi.advanceTimersByTime(60_000); expect(videos.debugInfo()).toHaveLength(1); }); it('does not count the time hidden as idle once the page is back', async () => { const videos = await freshLibrary(); videos.get(KEY); setHidden(true); vi.advanceTimersByTime(60_000); setHidden(false); vi.advanceTimersByTime(1900); expect(videos.debugInfo()).toHaveLength(1); vi.advanceTimersByTime(2200); expect(videos.debugInfo()).toEqual([]); }); it('keeps a driven player that is only worn, as by a sprite switched off', async () => { const videos = await freshLibrary(); const ref = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null }; const props = { videoClock: 'driven', videoTime: 1.2 }; videosMade = 0; for (let ms = 0; ms < 6000; ms += 100) { videos.wear(ref, props, 'view2:a', 'none'); vi.advanceTimersByTime(100); } // Let go and opened again would be a second video, loading while the sprite shows white. expect(videosMade).toBe(1); expect(videos.debugInfo().map(({ key }) => key)).toEqual([`${KEY}@view2:a`]); }); }); describe('VideoLibrary driven players (#404)', () => { beforeEach(() => { vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo }); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null }; it('leaves a sprite with a free clock on the shared player', async () => { const videos = await freshLibrary(); expect(videos.wear(REF, { videoTime: 1 }, 'view1:a', 'none')).toBe(REF); expect(videos.debugInfo()).toEqual([]); }); it('gives every wearer of a driven video a player of its own, opened at its time', async () => { const videos = await freshLibrary(); const a = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', 'none'); const b = videos.wear(REF, { videoClock: 'driven', videoTime: 1.25 }, 'view1:b', 'none'); expect([a.file, b.file]).toEqual([`${KEY}@view1:a`, `${KEY}@view1:b`]); expect(videos.debugInfo().map(({ key, time }) => [key, time])).toEqual([ [`${KEY}@view1:a`, 0.5], [`${KEY}@view1:b`, 1.25], ]); }); it('reads the speed by the time of the frame, not by when the frame got round to it', async () => { const frame = { at: 0 }; vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo, timeline: { get currentTime() { return frame.at; }, }, }); // The first frames are drawn 45 ms late, as on a busy page; the time runs at 1x by the frames. let late = 45; vi.spyOn(performance, 'now').mockImplementation(() => frame.at + late); const videos = await freshLibrary(); for (let i = 0; i <= 36; i++) { frame.at = (i * 1000) / 60; if (i === 7) late = 0; videos.wear(REF, { videoClock: 'driven', videoTime: i / 60 }, 'view1:a', 'none'); } expect(videos.debugInfo()[0]!.speed).toBeCloseTo(1, 6); }); it('lands a seek that Chrome hands over no frame for', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearInterval'] }); const events = new Map void)[]>(); let frames: (() => void)[] = []; const element = { ...fakeVideo(), seeking: false, ended: false, duration: 2, playbackRate: 1, readyState: 4, videoWidth: 320, videoHeight: 180, at: 0, get currentTime() { return this.at; }, set currentTime(time: number) { this.at = time; this.seeking = true; }, addEventListener: (type: string, listener: () => void) => events.set(type, [...(events.get(type) ?? []), listener]), requestVideoFrameCallback: (callback: () => void) => frames.push(callback), }; const fire = (type: string) => events.get(type)?.forEach((listener) => listener()); const seekDone = () => { element.seeking = false; fire('seeked'); }; vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element }); const videos = await freshLibrary(); const key = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', 'none').file; fire('loadeddata'); seekDone(); [frames] = [[], ...frames.map((frame) => frame())]; expect(videos.showsAsked(key)).toBe(true); // Switched on at 1.2 s: the seek is done, and no frame follows it. videos.wear(REF, { videoClock: 'driven', videoTime: 1.2 }, 'view1:a', 'none'); expect(videos.showsAsked(key)).toBe(false); seekDone(); vi.advanceTimersByTime(200); vi.useRealTimers(); expect(videos.showsAsked(key)).toBe(true); }); it('gives a player opened behind another tab its first picture, with no frame handed over (#423)', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); const page = { hidden: true }; const events = new Map void>(); const element = { ...fakeVideo(), seeking: false, ended: false, duration: 2, playbackRate: 1, readyState: 4, networkState: 1, addEventListener: (type: string, listener: () => void) => events.set(type, listener), // Behind another tab Chrome calls none of these. requestVideoFrameCallback: () => 0, }; vi.stubGlobal('document', { get hidden() { return page.hidden; }, addEventListener: () => {}, createElement: () => element, }); const videos = await freshLibrary(); const key = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', 'game').file; events.get('loadeddata')!(); vi.advanceTimersByTime(100); const behind = videos.hasPicture(key); // In front Chrome hands over the frame, and the player waits for it. page.hidden = false; const front = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:b', 'game').file; events.get('loadeddata')!(); vi.advanceTimersByTime(100); vi.useRealTimers(); expect(behind).toBe(true); expect(videos.hasPicture(front)).toBe(false); }); it('holds a driven player quiet as soon as its wearer is gone, as an ended composition item', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] }); const events = new Map void>(); let frames: (() => void)[] = []; const element = { ...fakeVideo(), ended: false, duration: 10, playbackRate: 1, seeking: false, readyState: 4, videoWidth: 320, videoHeight: 180, play() { this.paused = false; return Promise.resolve(); }, pause() { this.paused = true; }, addEventListener: (type: string, listener: () => void) => events.set(type, listener), requestVideoFrameCallback: (callback: () => void) => frames.push(callback), }; vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element }); const { soundLibrary } = await import('../../src/viewport/SoundLibrary'); vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null); const videos = await freshLibrary(); const props = { videoClock: 'driven', videoTime: 0 }; videos.wear(REF, props, 'view1:a', 'game'); events.get('loadeddata')!(); [frames] = [[], ...frames.map((frame) => frame())]; // Worn at 1x for a second, heard. for (let ms = 0; ms <= 1000; ms += 1000 / 60) { element.currentTime = ms / 1000; videos.wear(REF, { ...props, videoTime: ms / 1000 }, 'view1:a', 'game'); vi.advanceTimersByTime(1000 / 60); } expect([element.paused, element.muted]).toEqual([false, false]); // The item ends: nothing wears the player any more. Let go only after 2 s, it played on heard. vi.advanceTimersByTime(200); expect([element.paused, element.muted]).toEqual([true, true]); expect(videos.debugInfo()).toHaveLength(1); vi.useRealTimers(); }); it('holds a free player quiet once no sprite wears it, and plays it on when one does (#420)', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] }); const page = { hidden: false }; const events = new Map void>(); let frames: (() => void)[] = []; const element = { ...fakeVideo(), play() { this.paused = false; return Promise.resolve(); }, pause() { this.paused = true; }, addEventListener: (type: string, listener: () => void) => events.set(type, listener), requestVideoFrameCallback: (callback: () => void) => frames.push(callback), }; vi.stubGlobal('document', { get hidden() { return page.hidden; }, addEventListener: () => {}, createElement: () => element, }); const { soundLibrary } = await import('../../src/viewport/SoundLibrary'); vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null); const videos = await freshLibrary(); const drawFor = (ms: number) => { for (let at = 0; at < ms; at += 1000 / 60) { videos.get(KEY); vi.advanceTimersByTime(1000 / 60); } }; videos.get(KEY); events.get('loadeddata')!(); [frames] = [[], ...frames.map((frame) => frame())]; videos.startRun(); drawFor(1000); const state = () => [element.paused, element.muted]; expect(state()).toEqual([false, false]); // Its sprite is destroyed: held and quiet at once, not heard until it is let go 2-4 s later. vi.advanceTimersByTime(200); expect(state()).toEqual([true, true]); // A run started meanwhile takes it back to its start, still quiet. videos.startRun(); vi.advanceTimersByTime(200); expect(state()).toEqual([true, true]); // Worn again before it is let go: it plays on, heard, the same video. videosMade = 0; drawFor(500); expect([...state(), videosMade]).toEqual([false, false, 0]); // A hidden page draws nothing, and asks for nothing: it plays on all the same. page.hidden = true; vi.advanceTimersByTime(1000); expect(state()).toEqual([false, false]); vi.useRealTimers(); }); it('tells a view whether one of its driven players still waits for its first picture', async () => { const videos = await freshLibrary(); videos.get(KEY); expect(videos.loading('view12')).toBe(false); const worn = videos.wear(REF, { videoClock: 'driven', videoTime: 0 }, 'view12:a', 'none'); expect(videos.hasPicture(worn.file)).toBe(false); // A run waits only for its own view: view12 is neither view1 nor view2. expect([videos.loading('view12'), videos.loading('view1'), videos.loading('view2')]).toEqual([ true, false, false, ]); }); });