mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(voice): poll the current video element for a first frame (#2663)
This commit is contained in:
1 parent
baf2cbf3fd
commit
5da256df12
2 files changed
+167
-6
No files matched your search
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearVideoFrameWatchersForTests,
|
||||
getActiveVideoFrameWatcherCountForTests,
|
||||
watchVideoElementRenderedFrame,
|
||||
} from '@app/features/voice/components/VideoElementFrameState';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const POLL_TICK_MS = 125;
|
||||
|
||||
interface FakeVideoOptions {
|
||||
supportsFrameCallback: boolean;
|
||||
readyState?: number;
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
}
|
||||
|
||||
interface FakeVideo {
|
||||
element: HTMLVideoElement;
|
||||
presentFrame: () => void;
|
||||
cancelledFrameCallbacks: Array<number>;
|
||||
listenerCount: () => number;
|
||||
}
|
||||
|
||||
function createFakeVideo(options: FakeVideoOptions): FakeVideo {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
const frameCallbacks = new Map<number, () => void>();
|
||||
const cancelledFrameCallbacks: Array<number> = [];
|
||||
let nextFrameCallbackHandle = 1;
|
||||
|
||||
const element: Record<string, unknown> = {
|
||||
readyState: options.readyState ?? 0,
|
||||
videoWidth: options.videoWidth ?? 0,
|
||||
videoHeight: options.videoHeight ?? 0,
|
||||
addEventListener: (type: string, listener: () => void): void => {
|
||||
const existing = listeners.get(type);
|
||||
if (existing) {
|
||||
existing.add(listener);
|
||||
return;
|
||||
}
|
||||
listeners.set(type, new Set([listener]));
|
||||
},
|
||||
removeEventListener: (type: string, listener: () => void): void => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
};
|
||||
|
||||
if (options.supportsFrameCallback) {
|
||||
element.requestVideoFrameCallback = (callback: () => void): number => {
|
||||
const handle = nextFrameCallbackHandle;
|
||||
nextFrameCallbackHandle += 1;
|
||||
frameCallbacks.set(handle, callback);
|
||||
return handle;
|
||||
};
|
||||
element.cancelVideoFrameCallback = (handle: number): void => {
|
||||
cancelledFrameCallbacks.push(handle);
|
||||
frameCallbacks.delete(handle);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
element: element as unknown as HTMLVideoElement,
|
||||
presentFrame: (): void => {
|
||||
for (const [handle, callback] of frameCallbacks) {
|
||||
frameCallbacks.delete(handle);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
cancelledFrameCallbacks,
|
||||
listenerCount: (): number => {
|
||||
let total = 0;
|
||||
for (const registered of listeners.values()) {
|
||||
total += registered.size;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('watchVideoElementRenderedFrame', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('window', globalThis);
|
||||
clearVideoFrameWatchersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearVideoFrameWatchersForTests();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reports a frame from the element the ref switches to after watching starts', () => {
|
||||
const initial = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: initial.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
videoRef.current = createFakeVideo({
|
||||
supportsFrameCallback: true,
|
||||
readyState: 2,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
}).element;
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('reports one frame and drops the poll target when the frame callback fires first', () => {
|
||||
const video = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(1);
|
||||
|
||||
video.presentFrame();
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
expect(video.listenerCount()).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('reports nothing and leaves no interval running when disposed before any frame', () => {
|
||||
const video = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
dispose();
|
||||
|
||||
videoRef.current = createFakeVideo({
|
||||
supportsFrameCallback: false,
|
||||
readyState: 2,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
}).element;
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).not.toHaveBeenCalled();
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(video.cancelledFrameCallbacks).toHaveLength(1);
|
||||
expect(video.listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -117,18 +117,17 @@ export function watchVideoElementRenderedFrame({videoRef, onFrame}: VideoElement
|
||||
onFrame();
|
||||
return () => {};
|
||||
}
|
||||
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
|
||||
video?.addEventListener('loadeddata', markReadyIfPossible);
|
||||
video?.addEventListener('playing', markReadyIfPossible);
|
||||
video?.addEventListener('resize', markReadyIfPossible);
|
||||
if (video?.requestVideoFrameCallback) {
|
||||
frameCallbackHandle = video.requestVideoFrameCallback(() => {
|
||||
frameCallbackHandle = null;
|
||||
markPresentedFrame();
|
||||
});
|
||||
} else {
|
||||
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
|
||||
video?.addEventListener('loadeddata', markReadyIfPossible);
|
||||
video?.addEventListener('playing', markReadyIfPossible);
|
||||
video?.addEventListener('resize', markReadyIfPossible);
|
||||
markReadyIfPossible();
|
||||
}
|
||||
markReadyIfPossible();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearWatchers();
|
||||
|
||||
Reference in new issue
Block a user