fix: Increase reliability of media auto actions (e.g. play) (#1934)

Refactors media player handling entirely to make the code more
consistent and less boilerplate. Add testing for media player actions.

- Closes #1921
- Closes #1916
This commit is contained in:
Dermot Duffy
2025-03-03 20:59:01 -08:00
committed by GitHub
parent d4650eae8a
commit 3b77b11196
58 changed files with 1972 additions and 1165 deletions
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
import { createLitElement } from '../../test-utils';
import { screenshotImage } from '../../../src/utils/screenshot';
vi.mock('../../../src/utils/screenshot.js');
// @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.
});
it('should ignore unmute', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.unmute();
// Currently no observable side effects.
});
it('should always report muted', () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
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.
});
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 () => {
it('should return screenshot URL with image', async () => {
const url = 'data:image/png;base64,';
vi.mocked(screenshotImage).mockReturnValue(url);
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
expect(await controller.getScreenshotURL()).toBe(url);
});
it('should return null without image', async () => {
const controller = new ImageMediaPlayerController(createLitElement(), () => 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 ImageMediaPlayerController(createLitElement(), () => image);
expect(await controller.getFullscreenElement()).toBe(image);
});
it('should return null without image', async () => {
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
expect(controller.getFullscreenElement()).toBeNull();
});
});
});
@@ -0,0 +1,252 @@
import JSMpeg from '@cycjimmy/jsmpeg-player';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { JSMPEGMediaPlayerController } from '../../../src/components-lib/media-player/jsmpeg';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
describe('JSMPEGMediaPlayerController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should play', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
await controller.play();
expect(videoElement.play).toBeCalled();
});
it('should pause', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
await controller.pause();
expect(videoElement.stop).toBeCalled();
});
describe('should mute', async () => {
it('should mute with player', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.volume = 1;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
await controller.mute();
expect(videoElement.player.volume).toBe(0);
});
it('should ignore calls without player', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => null,
() => mock<HTMLCanvasElement>(),
);
await controller.mute();
// Currently no observable side effects.
});
});
describe('should unmute', async () => {
it('should mute with player', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.volume = 0;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
await controller.unmute();
expect(videoElement.player.volume).toBe(1);
});
it('should ignore calls without player', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => null,
() => mock<HTMLCanvasElement>(),
);
await controller.unmute();
// Currently no observable side effects.
});
});
describe('should return muted state', () => {
it('should return true when muted', () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.volume = 0;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isMuted()).toBeTruthy();
});
it('should return false when not muted', () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.volume = 1;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isMuted()).toBeFalsy();
});
it('should return true when no player', () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => null,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isMuted()).toBeTruthy();
});
});
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(),
() => mock<JSMpeg.VideoElement>(),
() => mock<HTMLCanvasElement>(),
);
await controller.setControls(true);
// Currently no observable side effects.
});
describe('should return paused state', () => {
it('should return true when paused', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.paused = true;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeTruthy();
});
it('should return false when not paused', async () => {
const videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.paused = false;
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeFalsy();
});
it('should return true when no video', () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => null,
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeTruthy();
});
});
describe('should get screenshot URL', async () => {
it('should return screenshot URL with canvas', async () => {
const url = 'data:image/png;base64,';
const canvas = mock<HTMLCanvasElement>();
canvas.toDataURL.mockReturnValue(url);
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => canvas,
);
expect(await controller.getScreenshotURL()).toBe(url);
});
it('should return null without canvas', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => null,
);
expect(await controller.getScreenshotURL()).toBeNull();
});
});
describe('should get fullscreen element', async () => {
it('should return fullscreen element with canvas', async () => {
const canvas = mock<HTMLCanvasElement>();
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => canvas,
);
expect(await controller.getFullscreenElement()).toBe(canvas);
});
it('should return null without cancas', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => null,
);
expect(controller.getFullscreenElement()).toBeNull();
});
});
});
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { 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>>();
Object.defineProperty(cachedValueController, 'value', { value: 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();
});
});
});
@@ -0,0 +1,273 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
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';
}
// @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.play();
expect(video.play).toBeCalled();
});
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.play();
expect(video.play).toBeCalledTimes(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.play();
expect(video.play).toBeCalledTimes(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.play();
expect(video.play).toBeCalledTimes(2);
expect(video.muted).toBeTruthy();
});
it('should ignore calls without a video', async () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
await controller.play();
// Currently no observable side effects.
});
});
it('should pause', async () => {
const video = mock<HTMLVideoElement>();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.pause();
expect(video.pause).toBeCalled();
});
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).toBeCalled();
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).toBeCalledWith(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).toBeCalledWith(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.toBeCalled();
});
});
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.pause();
expect(controller.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.pause();
expect(controller.isPaused()).toBeFalsy();
});
it('should return true when no video', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
expect(controller.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();
});
});
});