Files
shaka-player/test/util/video_frame_callback_handler_unit.js
T
Álvaro Velad Galván 406729873b chore: Add shaka.util.VideoFrameCallbackHandler helper (#9950)
It encapsulates and simplifies the use of requestVideoFrameCallback and
cancelVideoFrameCallback

---------

Co-authored-by: Wojciech Tyczyński <tykus160@gmail.com>
2026-04-09 14:28:40 +02:00

230 lines
7.0 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
describe('VideoFrameCallbackHandler', () => {
const VideoFrameCallbackHandler = shaka.util.VideoFrameCallbackHandler;
/** @type {!HTMLVideoElement} */
let mockVideo;
/** @type {shaka.util.VideoFrameCallbackHandler} */
let handler;
/**
* Holds every VideoFrameRequestCallback registered via
* requestVideoFrameCallback on the current mockVideo. Populated by
* createMockVideoWithRVFC so that tests can trigger callbacks without
* accessing spy internals.
*
* @type {!Array<!VideoFrameRequestCallback>}
*/
let registeredCallbacks;
beforeEach(() => {
registeredCallbacks = [];
});
afterEach(() => {
handler?.release();
handler = null;
});
/**
* Creates a mock video element that supports requestVideoFrameCallback
* and cancelVideoFrameCallback.
*
* @return {!HTMLVideoElement}
*/
function createMockVideoWithRVFC() {
const video = jasmine.createSpyObj('video', [
'requestVideoFrameCallback',
'cancelVideoFrameCallback',
]);
let handleCounter = 1;
video.requestVideoFrameCallback.and.callFake(
(/** !VideoFrameRequestCallback */ cb) => {
registeredCallbacks.push(cb);
return handleCounter++;
});
return /** @type {!HTMLVideoElement} */ (video);
}
/**
* Creates a mock video element that does NOT support
* requestVideoFrameCallback, simulating older devices.
*
* @return {!HTMLVideoElement}
*/
function createMockVideoWithoutRVFC() {
return /** @type {!HTMLVideoElement} */ ({});
}
/**
* Fires the most recently registered video frame callback.
*
* @param {number=} now
* @param {?VideoFrameMetadata=} metadata
*/
function fireLastRegisteredCallback(now = 100, metadata = null) {
registeredCallbacks[registeredCallbacks.length - 1](now, metadata);
}
describe('start', () => {
it('returns false if requestVideoFrameCallback is not supported', () => {
handler = new VideoFrameCallbackHandler(createMockVideoWithoutRVFC());
expect(handler.start(() => {})).toBe(false);
});
it('returns true if requestVideoFrameCallback is supported', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
expect(handler.start(() => {})).toBe(true);
});
it('registers a video frame callback on the video element', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
expect(mockVideo.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
});
it('cancels the pending callback before registering a new one', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
handler.start(() => {});
expect(mockVideo.cancelVideoFrameCallback).toHaveBeenCalledTimes(1);
expect(mockVideo.requestVideoFrameCallback).toHaveBeenCalledTimes(2);
});
it('does not call cancelVideoFrameCallback on the first start', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
expect(mockVideo.cancelVideoFrameCallback).not.toHaveBeenCalled();
});
it('invokes the user callback when a video frame fires', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
const callbackSpy = jasmine.createSpy('userCallback');
handler.start(shaka.test.Util.spyFunc(callbackSpy));
fireLastRegisteredCallback(200, null);
expect(callbackSpy).toHaveBeenCalledOnceWith(200, null);
});
it('re-registers the callback after each frame to loop continuously',
() => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
fireLastRegisteredCallback();
fireLastRegisteredCallback();
// 1 initial + 2 re-registrations after each fired frame
expect(mockVideo.requestVideoFrameCallback)
.toHaveBeenCalledTimes(3);
});
it('does not re-register if start is called again mid-loop', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
const firstCallbackSpy = jasmine.createSpy('firstCallback');
const secondCallbackSpy = jasmine.createSpy('secondCallback');
handler.start(shaka.test.Util.spyFunc(firstCallbackSpy));
// Capture the first registered callback before it fires
const firstRegisteredCallback = registeredCallbacks[0];
// Replace with a new callback
handler.start(shaka.test.Util.spyFunc(secondCallbackSpy));
// The stale first callback fires (e.g. already queued by the browser)
firstRegisteredCallback(100, null);
expect(firstCallbackSpy).not.toHaveBeenCalled();
expect(secondCallbackSpy).not.toHaveBeenCalled();
});
});
describe('release', () => {
it('cancels a pending callback', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
handler.release();
expect(mockVideo.cancelVideoFrameCallback).toHaveBeenCalledTimes(1);
});
it('does not throw if called before start', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
expect(() => handler.release()).not.toThrow();
});
it('can be called multiple times without throwing', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
expect(() => {
handler.release();
handler.release();
}).not.toThrow();
});
it('stops the user callback from being invoked after release', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
const callbackSpy = jasmine.createSpy('userCallback');
handler.start(shaka.test.Util.spyFunc(callbackSpy));
// Capture the registered callback before release
const registeredCallback = registeredCallbacks[0];
handler.release();
// Simulate a queued frame firing after release
registeredCallback(100, null);
expect(callbackSpy).not.toHaveBeenCalled();
});
it('stops re-registration of callback after release', () => {
mockVideo = createMockVideoWithRVFC();
handler = new VideoFrameCallbackHandler(mockVideo);
handler.start(() => {});
const registeredCallback = registeredCallbacks[0];
handler.release();
registeredCallback(100, null);
// requestVideoFrameCallback should only have been called once (on start),
// not again after the stale frame fires post-release.
expect(mockVideo.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
});
});
});