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
+61
View File
@@ -0,0 +1,61 @@
import { LitElement } from 'lit';
import { FullscreenElement, MediaPlayerController } from '../../types';
import { screenshotImage } from '../../utils/screenshot';
export class ImageMediaPlayerController implements MediaPlayerController {
private _host: LitElement;
private _getImageCallback: () => HTMLImageElement | null;
constructor(host: LitElement, getImageCallback: () => HTMLImageElement | null) {
this._host = host;
this._getImageCallback = getImageCallback;
}
public async play(): Promise<void> {
// Not implemented.
}
public async pause(): Promise<void> {
// Not implemented.
}
public async mute(): Promise<void> {
// Not implemented.
}
public async unmute(): Promise<void> {
// Not implemented.
}
public isMuted(): boolean {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async seek(_seconds: number): Promise<void> {
// Not implemented.
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async setControls(_controls: boolean): Promise<void> {
// Not implemented.
}
public isPaused(): boolean {
// The image could be an MJPEG, so it is always reported unpaused.
return false;
}
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
const image = this._getImageCallback();
// It might an MJPEG so still need to screenshot it.
return image ? screenshotImage(image) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getImageCallback() ?? null;
}
}
+73
View File
@@ -0,0 +1,73 @@
import JSMpeg from '@cycjimmy/jsmpeg-player';
import { LitElement } from 'lit';
import { FullscreenElement, MediaPlayerController } from '../../types';
export class JSMPEGMediaPlayerController implements MediaPlayerController {
private _host: LitElement;
private _getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null;
private _getCanvasElementCallback: () => HTMLCanvasElement | null;
constructor(
host: LitElement,
_getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null,
_getCanvasElementCallback: () => HTMLCanvasElement | null,
) {
this._host = host;
this._getJSMPEGVideoElementCallback = _getJSMPEGVideoElementCallback;
this._getCanvasElementCallback = _getCanvasElementCallback;
}
public async play(): Promise<void> {
await this._host.updateComplete;
return this._getJSMPEGVideoElementCallback()?.play();
}
public async pause(): Promise<void> {
await this._host.updateComplete;
return this._getJSMPEGVideoElementCallback()?.stop();
}
public async mute(): Promise<void> {
await this._host.updateComplete;
const player = this._getJSMPEGVideoElementCallback()?.player;
if (player) {
player.volume = 0;
}
}
public async unmute(): Promise<void> {
await this._host.updateComplete;
const player = this._getJSMPEGVideoElementCallback()?.player;
if (player) {
player.volume = 1;
}
}
public isMuted(): boolean {
const player = this._getJSMPEGVideoElementCallback()?.player;
return player ? player.volume === 0 : true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async seek(_seconds: number): Promise<void> {
// JSMPEG does not support seeking.
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async setControls(_controls: boolean): Promise<void> {
// Not implemented.
}
public isPaused(): boolean {
return this._getJSMPEGVideoElementCallback()?.player?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
return this._getCanvasElementCallback()?.toDataURL('image/jpeg') ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getCanvasElementCallback() ?? null;
}
}
@@ -0,0 +1,64 @@
import { LitElement } from 'lit';
import { FullscreenElement, MediaPlayerController } from '../../types';
import { CachedValueController } from '../cached-value-controller';
export class UpdatingImageMediaPlayerController implements MediaPlayerController {
private _host: LitElement;
private _getImageCallback: () => HTMLImageElement | null;
private _getCachedValueController: () => CachedValueController<string> | null;
constructor(
host: LitElement,
getImageCallback: () => HTMLImageElement | null,
getCachedValueController: () => CachedValueController<string> | null,
) {
this._host = host;
this._getImageCallback = getImageCallback;
this._getCachedValueController = getCachedValueController;
}
public async play(): Promise<void> {
await this._host.updateComplete;
this._getCachedValueController()?.startTimer();
}
public async pause(): Promise<void> {
await this._host.updateComplete;
this._getCachedValueController()?.stopTimer();
}
public async mute(): Promise<void> {
// Not implemented.
}
public async unmute(): Promise<void> {
// Not implemented.
}
public isMuted(): boolean {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async seek(_seconds: number): Promise<void> {
// Not implemented.
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async setControls(_controls: boolean): Promise<void> {
// Not implemented.
}
public isPaused(): boolean {
return !this._getCachedValueController()?.hasTimer();
}
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
return this._getCachedValueController()?.value ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getImageCallback() ?? null;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { LitElement } from 'lit';
import { FullscreenElement, MediaPlayerController } from '../../types';
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
import { screenshotVideo } from '../../utils/screenshot';
export class VideoMediaPlayerController implements MediaPlayerController {
private _host: LitElement;
private _getVideoCallback: () => HTMLVideoElement | null;
private _getControlsDefaultCallback: (() => boolean) | null;
constructor(
host: LitElement,
getVideoCallback: () => HTMLVideoElement | null,
getControlsDefaultCallback?: () => boolean,
) {
this._host = host;
this._getVideoCallback = getVideoCallback;
this._getControlsDefaultCallback = getControlsDefaultCallback ?? null;
}
public async play(): Promise<void> {
await this._host.updateComplete;
const video = this._getVideoCallback();
if (!video?.play) {
return;
}
// If the play call fails, and the media is not already muted, mute it first
// and then try again. This works around some browsers that prevent
// auto-play unless the video is muted.
try {
await video.play();
} catch (err: unknown) {
if ((err as Error).name === 'NotAllowedError' && !this.isMuted()) {
await this.mute();
try {
await video.play();
} catch (_) {
// Pass.
}
}
}
}
public async pause(): Promise<void> {
await this._host.updateComplete;
this._getVideoCallback()?.pause();
}
public async mute(): Promise<void> {
await this._host.updateComplete;
// The muted property is only for the initial muted state. Must explicitly
// set the muted on the video player to make the change dynamic.
const video = this._getVideoCallback();
if (video) {
video.muted = true;
}
}
public async unmute(): Promise<void> {
await this._host.updateComplete;
const video = this._getVideoCallback();
if (video) {
video.muted = false;
}
}
public isMuted(): boolean {
return this._getVideoCallback()?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
await this._host.updateComplete;
const video = this._getVideoCallback();
if (video) {
hideMediaControlsTemporarily(video);
video.currentTime = seconds;
}
}
public async setControls(controls?: boolean): Promise<void> {
await this._host.updateComplete;
const video = this._getVideoCallback();
const value = controls ?? this._getControlsDefaultCallback?.();
if (video && value !== undefined) {
setControlsOnVideo(video, value);
}
}
public isPaused(): boolean {
return this._getVideoCallback()?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
const video = this._getVideoCallback();
return video ? screenshotVideo(video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getVideoCallback() ?? null;
}
}