feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)
- Closes #2556 - Closes #2450 **Key intended features:** - go2rtc compatible - 100% test coverage to significantly improve ability to test, maintain and work around browser weirdnesses (e.g. Safari). - Written from the ground up in the style of the rest of the project. **To use:** - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
@@ -1,43 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
|
||||
import {
|
||||
ImageMediaPlayerController,
|
||||
type ImageUpdateControl,
|
||||
} from '../../../src/components-lib/media-player/image';
|
||||
import { screenshotImage } from '../../../src/utils/screenshot';
|
||||
import { createLitElement } from '../../test-utils';
|
||||
|
||||
vi.mock('../../../src/utils/screenshot.js');
|
||||
|
||||
const STALL_SECONDS = 10;
|
||||
const STALL_MS = STALL_SECONDS * 1000;
|
||||
|
||||
const createImageMediaPlayerWithLiveness = (
|
||||
isFrameExpected: () => boolean,
|
||||
getImageCallback: () => HTMLImageElement | null,
|
||||
stallWindowSeconds = STALL_SECONDS,
|
||||
): ImageMediaPlayerController =>
|
||||
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
|
||||
livenessOptions: { isFrameExpected, stallWindowSeconds },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ImageMediaPlayerController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should ignore play', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.play();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore pause', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.pause();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore mute', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.mute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No audio, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should ignore unmute', async () => {
|
||||
@@ -46,7 +43,7 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
await controller.unmute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No audio, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should always report muted', () => {
|
||||
@@ -56,32 +53,16 @@ describe('ImageMediaPlayerController', () => {
|
||||
expect(controller.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.setControls(true);
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No playback controls, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should always report unpaused', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('should get screenshot URL', async () => {
|
||||
describe('should get screenshot URL', () => {
|
||||
it('should return screenshot URL with image', async () => {
|
||||
const url = 'data:image/png;base64,';
|
||||
vi.mocked(screenshotImage).mockReturnValue(url);
|
||||
@@ -98,18 +79,31 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBeNull();
|
||||
});
|
||||
|
||||
it('should use the screenshot provider when given', async () => {
|
||||
const url = 'data:image/png;base64,provider';
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => image,
|
||||
{ screenshotProvider: async () => url },
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBe(url);
|
||||
expect(screenshotImage).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get fullscreen element', async () => {
|
||||
it('should return fullscreen element with image', async () => {
|
||||
describe('should get fullscreen element', () => {
|
||||
it('should return fullscreen element with image', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(await controller.getFullscreenElement()).toBe(image);
|
||||
expect(controller.getFullscreenElement()).toBe(image);
|
||||
});
|
||||
|
||||
it('should return null without image', async () => {
|
||||
it('should return null without image', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.getFullscreenElement()).toBeNull();
|
||||
@@ -121,4 +115,209 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
|
||||
describe('playback', () => {
|
||||
it('should be absent without an update control', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () =>
|
||||
mock<HTMLImageElement>(),
|
||||
);
|
||||
|
||||
expect(controller.playback).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should start the update loop on play', async () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
await controller.playback?.play();
|
||||
|
||||
expect(updateControl.start).toBeCalled();
|
||||
});
|
||||
|
||||
it('should stop the update loop on pause', async () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
await controller.playback?.pause();
|
||||
|
||||
expect(updateControl.stop).toBeCalled();
|
||||
});
|
||||
|
||||
it('should report paused when the update loop is not running', () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
updateControl.isRunning.mockReturnValue(false);
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
expect(controller.playback?.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should report unpaused when the update loop is running', () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
updateControl.isRunning.mockReturnValue(true);
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
expect(controller.playback?.isPaused()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeLiveness', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should be absent without liveness options', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () =>
|
||||
document.createElement('img'),
|
||||
);
|
||||
|
||||
expect(controller.subscribeLiveness).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report a stall when frames stop arriving', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('should stay live while frames keep arriving', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should report no stall while frames are not expected', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => false,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report no stall without an image element', () => {
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => null,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop watching on unsubscribe', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should tolerate the image going away before unsubscribe', () => {
|
||||
let image: HTMLImageElement | null = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
image = null;
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should honor a custom stall window', () => {
|
||||
const shortSeconds = 3;
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
shortSeconds,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(shortSeconds * 1000);
|
||||
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(videoElement.play).toBeCalled();
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(videoElement.stop).toBeCalled();
|
||||
});
|
||||
@@ -140,18 +140,6 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const controller = new JSMPEGMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<JSMpeg.VideoElement>(),
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const controller = new JSMPEGMediaPlayerController(
|
||||
createLitElement(),
|
||||
@@ -175,7 +163,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when not paused', async () => {
|
||||
@@ -189,7 +177,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
expect(controller.playback.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when no video', () => {
|
||||
@@ -199,7 +187,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CachedValueController } from '../../../src/components-lib/cached-value-controller';
|
||||
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
|
||||
import { UpdatingImageMediaPlayerController } from '../../../src/components-lib/media-player/updating-image';
|
||||
import { createLitElement } from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('UpdatingImageMediaPlayerController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should play', async () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
await controller.play();
|
||||
|
||||
expect(cachedValueController.startTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pause', async () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
await controller.pause();
|
||||
|
||||
expect(cachedValueController.stopTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore mute', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.mute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore unmute', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.unmute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should always report muted', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.setControls(true);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should always report unpaused', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('should get paused state', () => {
|
||||
it('should return true when the cached value controller does not have a timer', () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.hasTimer.mockReturnValue(false);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when the cached value controller has a timer', () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.hasTimer.mockReturnValue(true);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true without cached value controller', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => null,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get screenshot URL', () => {
|
||||
it('should return screenshot URL with cached value controller', async () => {
|
||||
const url = 'data:image/png;base64,';
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.getValue.mockReturnValue(url);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBe(url);
|
||||
});
|
||||
|
||||
it('should return null without cached value controller', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => null,
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get fullscreen element', async () => {
|
||||
it('should return fullscreen element with image', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => image,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(await controller.getFullscreenElement()).toBe(image);
|
||||
});
|
||||
|
||||
it('should return null without image', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => null,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.getFullscreenElement()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for getPIPElement', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => null,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ const createVideo = (options?: {
|
||||
seeking?: boolean;
|
||||
ended?: boolean;
|
||||
rvfc?: boolean;
|
||||
poster?: string;
|
||||
currentSrc?: string;
|
||||
srcObject?: MediaStream | null;
|
||||
}): {
|
||||
video: HTMLVideoElement;
|
||||
deliverFrame: () => void;
|
||||
@@ -41,6 +44,9 @@ const createVideo = (options?: {
|
||||
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();
|
||||
@@ -72,7 +78,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalled();
|
||||
});
|
||||
@@ -84,7 +90,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(2);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -97,7 +103,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(1);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -110,7 +116,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(2);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -119,7 +125,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
it('should ignore calls without a video', async () => {
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
@@ -129,7 +135,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
const video = mock<HTMLVideoElement>();
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(video.pause).toBeCalled();
|
||||
});
|
||||
@@ -257,9 +263,9 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when not paused', async () => {
|
||||
@@ -268,15 +274,15 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
expect(controller.playback.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when no video', () => {
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,6 +392,58 @@ describe('VideoMediaPlayerController', () => {
|
||||
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 report no stall when requestVideoFrameCallback is unavailable', () => {
|
||||
const { video } = createVideo({ rvfc: false });
|
||||
expect('requestVideoFrameCallback' in video).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user