648 lines
21 KiB
TypeScript
648 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
|
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
|
|
import {
|
|
hideMediaControlsTemporarily,
|
|
setControlsOnVideo,
|
|
} from '../../../src/utils/controls';
|
|
import { screenshotVideo } from '../../../src/utils/screenshot';
|
|
import { createLitElement } from '../../test-utils';
|
|
|
|
vi.mock('../../../src/utils/controls.js');
|
|
vi.mock('../../../src/utils/screenshot.js');
|
|
|
|
class NotAllowedError extends Error {
|
|
name = 'NotAllowedError';
|
|
}
|
|
|
|
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
|
|
|
// A real jsdom <video> (so `'requestVideoFrameCallback' in video` is genuinely
|
|
// false unless we add it) with controllable properties and a driveable frame
|
|
// callback.
|
|
const createVideo = (options?: {
|
|
readyState?: number;
|
|
paused?: boolean;
|
|
seeking?: boolean;
|
|
ended?: boolean;
|
|
rvfc?: boolean;
|
|
poster?: string;
|
|
currentSrc?: string;
|
|
srcObject?: MediaStream | null;
|
|
}): {
|
|
video: HTMLVideoElement;
|
|
deliverFrame: () => void;
|
|
cancel: ReturnType<typeof vi.fn>;
|
|
} => {
|
|
const video = document.createElement('video');
|
|
const define = (prop: string, value: unknown): void => {
|
|
Object.defineProperty(video, prop, { value, configurable: true });
|
|
};
|
|
define('readyState', options?.readyState ?? HTMLMediaElement.HAVE_CURRENT_DATA);
|
|
define('paused', options?.paused ?? false);
|
|
define('seeking', options?.seeking ?? false);
|
|
define('ended', options?.ended ?? false);
|
|
define('poster', options?.poster ?? '');
|
|
define('currentSrc', options?.currentSrc ?? '');
|
|
define('srcObject', options?.srcObject ?? null);
|
|
|
|
let frameCallback: (() => void) | null = null;
|
|
const cancel = vi.fn();
|
|
if (options?.rvfc !== false) {
|
|
let handle = 0;
|
|
define(
|
|
'requestVideoFrameCallback',
|
|
vi.fn((cb: () => void) => {
|
|
frameCallback = cb;
|
|
return ++handle;
|
|
}),
|
|
);
|
|
define('cancelVideoFrameCallback', cancel);
|
|
}
|
|
|
|
return { video, deliverFrame: () => frameCallback?.(), cancel };
|
|
};
|
|
|
|
// @vitest-environment jsdom
|
|
describe('VideoMediaPlayerController', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('should play', () => {
|
|
it('should play when no error', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.play.mockResolvedValue();
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.play();
|
|
|
|
expect(video.play).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should mute if not allowed to play and unmuted', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.play.mockRejectedValueOnce(new NotAllowedError()).mockResolvedValueOnce();
|
|
video.muted = false;
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.play();
|
|
|
|
expect(video.play).toHaveBeenCalledTimes(2);
|
|
expect(video.muted).toBeTruthy();
|
|
});
|
|
|
|
it('should not mute if not allowed to play and already unmuted', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.play.mockRejectedValueOnce(new NotAllowedError());
|
|
video.muted = true;
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.play();
|
|
|
|
expect(video.play).toHaveBeenCalledTimes(1);
|
|
expect(video.muted).toBeTruthy();
|
|
});
|
|
|
|
it('should ignore exception if subsequent play call throws', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.play.mockRejectedValue(new NotAllowedError());
|
|
video.muted = false;
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.play();
|
|
|
|
expect(video.play).toHaveBeenCalledTimes(2);
|
|
expect(video.muted).toBeTruthy();
|
|
});
|
|
|
|
it('should ignore calls without a video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
await controller.playback.play();
|
|
|
|
// Currently no observable side effects.
|
|
});
|
|
});
|
|
|
|
it('should pause', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.pause();
|
|
|
|
expect(video.pause).toHaveBeenCalled();
|
|
});
|
|
|
|
describe('should mute', async () => {
|
|
it('should mute with video', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.muted = false;
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.mute();
|
|
|
|
expect(video.muted).toBeTruthy();
|
|
});
|
|
|
|
it('should ignore calls without a video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
await controller.mute();
|
|
|
|
// Currently no observable side effects.
|
|
});
|
|
});
|
|
|
|
describe('should unmute', async () => {
|
|
it('should unmute with video', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.muted = true;
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.unmute();
|
|
|
|
expect(video.muted).toBeFalsy();
|
|
});
|
|
|
|
it('should ignore calls without a video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
await controller.unmute();
|
|
|
|
// Currently no observable side effects.
|
|
});
|
|
});
|
|
|
|
describe('should return muted state', () => {
|
|
it('should return true when muted', () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.muted = true;
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
expect(controller.isMuted()).toBeTruthy();
|
|
});
|
|
|
|
it('should return false when not muted', () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
video.muted = false;
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
expect(controller.isMuted()).toBeFalsy();
|
|
});
|
|
|
|
it('should return true when no video', () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
expect(controller.isMuted()).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('should seek', () => {
|
|
it('should seek', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.seek(10);
|
|
|
|
expect(hideMediaControlsTemporarily).toHaveBeenCalled();
|
|
expect(video.currentTime).toBe(10);
|
|
});
|
|
|
|
it('should ignore calls without a video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
await controller.seek(10);
|
|
|
|
// Currently no observable side effects.
|
|
});
|
|
});
|
|
|
|
describe('should set controls', () => {
|
|
it('should set controls', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.setControls(true);
|
|
|
|
expect(setControlsOnVideo).toHaveBeenCalledWith(video, true);
|
|
});
|
|
|
|
it('should set controls to default', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => video,
|
|
() => true,
|
|
);
|
|
|
|
await controller.setControls();
|
|
|
|
expect(setControlsOnVideo).toHaveBeenCalledWith(video, true);
|
|
});
|
|
|
|
it('should ignore calls without a default or value', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
await controller.setControls(true);
|
|
|
|
expect(setControlsOnVideo).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('should return paused state', () => {
|
|
it('should return true when paused', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
Object.defineProperty(video, 'paused', { value: true });
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.pause();
|
|
|
|
expect(controller.playback.isPaused()).toBeTruthy();
|
|
});
|
|
|
|
it('should return false when not paused', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
Object.defineProperty(video, 'paused', { value: false });
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
await controller.playback.pause();
|
|
|
|
expect(controller.playback.isPaused()).toBeFalsy();
|
|
});
|
|
|
|
it('should return true when no video', () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
expect(controller.playback.isPaused()).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('should get screenshot URL', async () => {
|
|
it('should return screenshot URL with video', async () => {
|
|
const url = 'data:image/png;base64,';
|
|
vi.mocked(screenshotVideo).mockReturnValue(url);
|
|
|
|
const video = mock<HTMLVideoElement>();
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
expect(await controller.getScreenshotURL()).toBe(url);
|
|
});
|
|
|
|
it('should return null without video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
expect(await controller.getScreenshotURL()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('should get fullscreen element', async () => {
|
|
it('should return fullscreen element with video', async () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
expect(await controller.getFullscreenElement()).toBe(video);
|
|
});
|
|
|
|
it('should return null without video', async () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
expect(controller.getFullscreenElement()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('should get PIP element', () => {
|
|
it('should return video element when available', () => {
|
|
const video = mock<HTMLVideoElement>();
|
|
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
expect(controller.getPIPElement()).toBe(video);
|
|
});
|
|
|
|
it('should return null without video', () => {
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
|
|
|
expect(controller.getPIPElement()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('subscribeLiveness', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should report a stall when frames stop arriving', () => {
|
|
const { video, deliverFrame } = createVideo();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
deliverFrame();
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
// The first frame confirms live, then no further frame arrives -> stall.
|
|
expect(callback).toHaveBeenNthCalledWith(1, true);
|
|
expect(callback).toHaveBeenNthCalledWith(2, false);
|
|
});
|
|
|
|
it('should not report a stall while a paused video still holds a frame', () => {
|
|
// A genuine user pause: paused with a current frame is idle, never
|
|
// reported -- not even after the stall window.
|
|
const { video } = createVideo({ paused: true });
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should report a video stuck paused with no current frame', () => {
|
|
// The observed go2rtc failure: its internal reconnect leaves the <video>
|
|
// paused at readyState 0 (spinning). That is not a genuine pause -- there
|
|
// is no frame to pause on -- so playback is still expected and a missing
|
|
// frame is a stall.
|
|
const { video } = createVideo({
|
|
paused: true,
|
|
readyState: HTMLMediaElement.HAVE_NOTHING,
|
|
});
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
expect(callback).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should not report a stall for a poster shown with no media loaded', () => {
|
|
// A still-image surface (an MJPEG/MP4 poster slideshow): a poster with no
|
|
// media never presents video frames, so a missing frame is not a stall.
|
|
const { video } = createVideo({
|
|
poster: 'data:image/jpeg;base64,xxx',
|
|
paused: true,
|
|
readyState: HTMLMediaElement.HAVE_NOTHING,
|
|
});
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still watch a poster shown over media loaded from a src', () => {
|
|
// A loading placeholder over real media (e.g. an HLS player): playback is
|
|
// expected, so a missing frame is still a stall.
|
|
const { video } = createVideo({
|
|
poster: 'data:image/jpeg;base64,xxx',
|
|
currentSrc: 'blob:http://localhost/stream',
|
|
paused: true,
|
|
readyState: HTMLMediaElement.HAVE_NOTHING,
|
|
});
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should still watch a poster shown over a media stream', () => {
|
|
// A loading placeholder over a live stream (e.g. an HA WebRTC player).
|
|
const { video } = createVideo({
|
|
poster: 'data:image/jpeg;base64,xxx',
|
|
srcObject: mock<MediaStream>(),
|
|
paused: true,
|
|
readyState: HTMLMediaElement.HAVE_NOTHING,
|
|
});
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should not report a stall while a replacement video keeps delivering frames', () => {
|
|
const original = createVideo();
|
|
const replacement = createVideo();
|
|
let current = original;
|
|
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => current.video,
|
|
);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
original.deliverFrame();
|
|
|
|
current = replacement;
|
|
controller.hostUpdated();
|
|
|
|
// Twice the stall window, with the replacement delivering frames
|
|
// throughout.
|
|
for (let i = 0; i < 2 * FRAME_STALL_SECONDS; i++) {
|
|
replacement.deliverFrame();
|
|
vi.advanceTimersByTime(1000);
|
|
}
|
|
|
|
expect(callback).toHaveBeenCalledExactlyOnceWith(true);
|
|
});
|
|
|
|
it('should not give a replacement longer than the stall window', () => {
|
|
const original = createVideo();
|
|
const replacement = createVideo();
|
|
let current = original;
|
|
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => current.video,
|
|
);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
original.deliverFrame();
|
|
|
|
current = replacement;
|
|
controller.hostUpdated();
|
|
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).toHaveBeenNthCalledWith(1, true);
|
|
expect(callback).toHaveBeenNthCalledWith(2, false);
|
|
});
|
|
|
|
it('should keep watching the same video when a render changes nothing', () => {
|
|
const { video, deliverFrame, cancel } = createVideo();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
deliverFrame();
|
|
controller.hostUpdated();
|
|
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
expect(callback).toHaveBeenCalledExactlyOnceWith(true);
|
|
});
|
|
|
|
it('should keep watching the old video when a render offers no replacement', () => {
|
|
const { video, cancel } = createVideo();
|
|
let current: HTMLVideoElement | null = video;
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => current,
|
|
);
|
|
|
|
controller.subscribeLiveness(vi.fn());
|
|
current = null;
|
|
controller.hostUpdated();
|
|
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore a render before anything is being watched', () => {
|
|
const { video, cancel } = createVideo();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
|
|
controller.hostUpdated();
|
|
|
|
expect(vi.mocked(video.requestVideoFrameCallback)).not.toHaveBeenCalled();
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stop watching the replaced video, not its replacement', () => {
|
|
// A handle is only valid on the video it came from, so it is cancelled
|
|
// there rather than on whatever the player is showing now.
|
|
const original = createVideo();
|
|
const replacement = createVideo();
|
|
let current = original;
|
|
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => current.video,
|
|
);
|
|
|
|
const unsubscribe = controller.subscribeLiveness(vi.fn());
|
|
current = replacement;
|
|
unsubscribe();
|
|
|
|
expect(original.cancel).toHaveBeenCalled();
|
|
expect(replacement.cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should register itself with its host', () => {
|
|
const host = createLitElement();
|
|
new VideoMediaPlayerController(host, () => null);
|
|
|
|
expect(host.addController).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
|
|
const { video } = createVideo({ rvfc: false });
|
|
expect('requestVideoFrameCallback' in video).toBe(false);
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stop watching and cancel the frame callback on unsubscribe', () => {
|
|
const { video, cancel } = createVideo();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
const callback = vi.fn();
|
|
|
|
const unsubscribe = controller.subscribeLiveness(callback);
|
|
unsubscribe();
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
expect(cancel).toHaveBeenCalled();
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not re-arm when the last subscriber unsubscribes during recovery', () => {
|
|
const { video, deliverFrame, cancel } = createVideo();
|
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
|
let unsubscribe: () => void = () => {};
|
|
const callback = vi.fn((isLive: boolean) => {
|
|
if (isLive) {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
unsubscribe = controller.subscribeLiveness(callback);
|
|
|
|
// Stall -> callback(false)
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
|
|
// Recovery -> callback(true) -> unsubscribe -> source stopped
|
|
deliverFrame();
|
|
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip re-arming and report no stall when the video is gone between frames', () => {
|
|
const { video, deliverFrame } = createVideo();
|
|
let currentVideo: HTMLVideoElement | null = video;
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => currentVideo,
|
|
);
|
|
const callback = vi.fn();
|
|
|
|
controller.subscribeLiveness(callback);
|
|
deliverFrame();
|
|
|
|
// Remove video
|
|
currentVideo = null;
|
|
|
|
// Re-arm skipped: no video
|
|
deliverFrame();
|
|
|
|
// The first frame reported live; the stall timer then fires with no
|
|
// video, so no stall is ever reported.
|
|
vi.advanceTimersByTime(STALL_MS);
|
|
expect(callback).not.toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should stop watching the old video once the player has dropped it', () => {
|
|
const { video, cancel } = createVideo();
|
|
let currentVideo: HTMLVideoElement | null = video;
|
|
const controller = new VideoMediaPlayerController(
|
|
createLitElement(),
|
|
() => currentVideo,
|
|
);
|
|
|
|
const unsubscribe = controller.subscribeLiveness(vi.fn());
|
|
currentVideo = null;
|
|
unsubscribe();
|
|
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|