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
+4 -6
View File
@@ -429,16 +429,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
public updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
let initialized = false;
if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) {
this._mediaActionsController.initialize(this._refCarousel.value);
initialized = true;
}
const rootChanged = this._refCarousel.value
? this._mediaActionsController.setRoot(this._refCarousel.value)
: false;
// If the view has changed, or if the media actions controller has just been
// initialized, then call the necessary media action.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1626
if (initialized || changedProperties.has('viewManagerEpoch')) {
if (rootChanged || changedProperties.has('viewManagerEpoch')) {
this._setMediaTarget();
}
}
+11 -61
View File
@@ -25,14 +25,14 @@ import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import {
AdvancedCameraCardMediaPlayer,
ExtendedHomeAssistant,
FullscreenElement,
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
} from '../../types.js';
import { aspectRatioToString } from '../../utils/basic.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
import '../icon.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
@@ -40,10 +40,7 @@ import '../ptz.js';
import '../surround.js';
@customElement('advanced-camera-card-live-provider')
export class AdvancedCameraCardLiveProvider
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -84,7 +81,7 @@ export class AdvancedCameraCardLiveProvider
@state()
protected _showStreamTroubleshooting = false;
protected _refProvider: Ref<LitElement & AdvancedCameraCardMediaPlayer> = createRef();
protected _refProvider: Ref<MediaPlayerElement> = createRef();
// A note on dynamic imports:
//
@@ -98,58 +95,9 @@ export class AdvancedCameraCardLiveProvider
// background. These calls fail without waiting for loading here.
protected _importPromises: Promise<unknown>[] = [];
public async play(): Promise<void> {
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await playMediaMutingIfNecessary(this, this._refProvider.value);
}
public async pause(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await this._refProvider.value?.pause();
}
public async mute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await this._refProvider.value?.mute();
}
public async unmute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await this._refProvider.value?.unmute();
}
public isMuted(): boolean {
return this._refProvider.value?.isMuted() ?? true;
}
public async seek(seconds: number): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await this._refProvider.value?.seek(seconds);
}
public async setControls(controls?: boolean): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await this._refProvider.value?.setControls(controls);
}
public isPaused(): boolean {
return this._refProvider.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
return (await this._refProvider.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refProvider.value?.getFullscreenElement() ?? null;
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
}
/**
@@ -265,8 +213,10 @@ export class AdvancedCameraCardLiveProvider
: undefined,
)}
.settings=${this.zoomSettings}
@advanced-camera-card:zoom:zoomed=${() => this.setControls(false)}
@advanced-camera-card:zoom:unzoomed=${() => this.setControls()}
@advanced-camera-card:zoom:zoomed=${async () =>
(await this.getMediaPlayerController())?.setControls(false)}
@advanced-camera-card:zoom:unzoomed=${async () =>
(await this.getMediaPlayerController())?.setControls()}
>
${template}
</advanced-camera-card-zoomer>`
+12 -54
View File
@@ -10,18 +10,17 @@ import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import { CameraConfig, MicrophoneConfig } from '../../../../config/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import {
ExtendedHomeAssistant,
AdvancedCameraCardMediaPlayer,
FullscreenElement,
MediaPlayer,
MediaPlayerController,
Message,
} from '../../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
import { setControlsOnVideo } from '../../../../utils/media.js';
import { screenshotMedia } from '../../../../utils/screenshot.js';
import { renderMessage } from '../../../message.js';
import { VideoRTC } from './video-rtc.js';
@@ -35,10 +34,7 @@ customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@customElement('advanced-camera-card-live-go2rtc')
export class AdvancedCameraCardGo2RTC
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer {
// Not an reactive property to avoid resetting the video.
public hass?: ExtendedHomeAssistant;
@@ -62,52 +58,14 @@ export class AdvancedCameraCardGo2RTC
protected _player?: VideoRTC;
public async play(): Promise<void> {
return this._player?.video?.play();
}
protected _mediaPlayerController = new VideoMediaPlayerController(
this,
() => this._player?.video ?? null,
() => this.controls,
);
public async pause(): Promise<void> {
this._player?.video?.pause();
}
public async mute(): Promise<void> {
if (this._player?.video) {
this._player.video.muted = true;
}
}
public async unmute(): Promise<void> {
if (this._player?.video) {
this._player.video.muted = false;
}
}
public isMuted(): boolean {
return this._player?.video?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
if (this._player?.video) {
this._player.video.currentTime = seconds;
}
}
public async setControls(controls?: boolean): Promise<void> {
if (this._player?.video) {
setControlsOnVideo(this._player.video, controls ?? this.controls);
}
}
public isPaused(): boolean {
return this._player?.video?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return this._player?.video ? screenshotMedia(this._player.video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._player?.video ?? null;
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
disconnectedCallback(): void {
@@ -155,7 +113,7 @@ export class AdvancedCameraCardGo2RTC
}
this._player = new VideoRTC();
this._player.containingPlayer = this;
this._player.mediaPlayerController = this._mediaPlayerController;
this._player.microphoneStream = this.microphoneState?.stream ?? null;
this._player.src = address;
this._player.visibilityCheck = false;
+3 -2
View File
@@ -1,3 +1,5 @@
import { MediaPlayerController } from '../../../../types';
export class VideoRTC extends HTMLElement {
DISCONNECT_TIMEOUT: number;
RECONNECT_TIMEOUT: number;
@@ -29,9 +31,8 @@ export class VideoRTC extends HTMLElement {
onmessage: Record<string, (msg: { type: string; value: string }) => void>;
// Custom methods/members.
containingPlayer: AdvancedCameraCardMediaPlayer | null;
mediaPlayerController: MediaPlayerController | null;
microphoneStream: MediaStream | null;
reconnect();
setControls(controls: boolean): void;
}
@@ -3,7 +3,7 @@ import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../../utils/media';
} from '../../../../utils/controls.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
@@ -162,13 +162,13 @@ export class VideoRTC extends HTMLElement {
this.microphoneStream = null;
/**
* A reference to a containing AdvancedCameraCardMediaPlayer object.
* @type {AdvancedCameraCardMediaPlayer}}
* A reference to a MediaPlayerController for this video
* @type {MediaPlayerController | null}
*/
this.containingPlayer = null;
this.mediaPlayerController = null;
/**
* Whether to show or hide video controls for videos created in future.
* Whether to show or hide video controls for videos created *in future*.
* @type {boolean}}
*/
this.controls = true;
@@ -353,7 +353,9 @@ export class VideoRTC extends HTMLElement {
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}
dispatchMediaLoadedEvent(this, this.video, {
player: this.containingPlayer,
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
capabilities: {
// 2-way audio is only supported on WebRTC connections. The state of
// `this.microphoneStream` is not taken into account here since
@@ -734,7 +736,9 @@ export class VideoRTC extends HTMLElement {
if (!receivedFirstFrame) {
receivedFirstFrame = true;
dispatchMediaLoadedEvent(this, this.video, {
player: this.containingPlayer,
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
technology: ['mjpeg'],
});
}
@@ -779,7 +783,9 @@ export class VideoRTC extends HTMLElement {
context = canvas.getContext('2d');
dispatchMediaLoadedEvent(this, video2, {
player: this.containingPlayer,
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
technology: ['mp4'],
});
}
+10 -44
View File
@@ -7,13 +7,14 @@ import '../../../patches/ha-camera-stream';
import '../../../patches/ha-hls-player.js';
import '../../../patches/ha-web-rtc-player.js';
import liveHAStyle from '../../../scss/live-ha.scss';
import { AdvancedCameraCardMediaPlayer, FullscreenElement } from '../../../types.js';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
} from '../../../types.js';
@customElement('advanced-camera-card-live-ha')
export class AdvancedCameraCardLiveHA
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer {
@property({ attribute: false })
public hass?: HomeAssistant;
@@ -23,46 +24,11 @@ export class AdvancedCameraCardLiveHA
@property({ attribute: true, type: Boolean })
public controls = false;
protected _playerRef: Ref<Element & AdvancedCameraCardMediaPlayer> = createRef();
protected _playerRef: Ref<MediaPlayerElement> = createRef();
public async play(): Promise<void> {
return this._playerRef.value?.play();
}
public async pause(): Promise<void> {
this._playerRef.value?.pause();
}
public async mute(): Promise<void> {
this._playerRef.value?.mute();
}
public async unmute(): Promise<void> {
this._playerRef.value?.unmute();
}
public isMuted(): boolean {
return this._playerRef.value?.isMuted() ?? true;
}
public async seek(seconds: number): Promise<void> {
this._playerRef.value?.seek(seconds);
}
public async setControls(controls?: boolean): Promise<void> {
this._playerRef.value?.setControls(controls ?? this.controls);
}
public isPaused(): boolean {
return this._playerRef.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return (await this._playerRef.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._playerRef.value?.getFullscreenElement() ?? null;
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
return (await this._playerRef.value?.getMediaPlayerController()) ?? null;
}
protected render(): TemplateResult | void {
+13 -47
View File
@@ -4,60 +4,26 @@ import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import { AdvancedCameraCardMediaPlayer, FullscreenElement } from '../../../types.js';
import '../../image-base.js';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
} from '../../../types.js';
import '../../image-updating-player.js';
@customElement('advanced-camera-card-live-image')
export class AdvancedCameraCardLiveImage
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlayer {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
protected _refImage: Ref<Element & AdvancedCameraCardMediaPlayer> = createRef();
protected _refImage: Ref<MediaPlayerElement> = createRef();
public async play(): Promise<void> {
await this._refImage.value?.play();
}
public async pause(): Promise<void> {
await this._refImage.value?.pause();
}
public async mute(): Promise<void> {
await this._refImage.value?.mute();
}
public async unmute(): Promise<void> {
await this._refImage.value?.unmute();
}
public isMuted(): boolean {
return !!this._refImage.value?.isMuted();
}
public async seek(seconds: number): Promise<void> {
await this._refImage.value?.seek(seconds);
}
public async setControls(controls?: boolean): Promise<void> {
await this._refImage.value?.setControls(controls);
}
public isPaused(): boolean {
return this._refImage.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return (await this._refImage.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refImage.value?.getFullscreenElement() ?? null;
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
return (await this._refImage.value?.getMediaPlayerController()) ?? null;
}
protected render(): TemplateResult | void {
@@ -66,13 +32,13 @@ export class AdvancedCameraCardLiveImage
}
return html`
<advanced-camera-card-image-base
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
.hass=${this.hass}
.imageConfig=${this.cameraConfig.image}
.cameraConfig=${this.cameraConfig}
>
</advanced-camera-card-image-base>
</advanced-camera-card-image-updating-player>
`;
}
+16 -56
View File
@@ -11,13 +11,14 @@ import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import {
AdvancedCameraCardMediaPlayer,
ExtendedHomeAssistant,
FullscreenElement,
MediaPlayer,
MediaPlayerController,
Message,
} from '../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js';
@@ -39,10 +40,9 @@ const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
const JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
@customElement('advanced-camera-card-live-jsmpeg')
export class AdvancedCameraCardLiveJSMPEG
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPlayer {
protected hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@@ -52,61 +52,21 @@ export class AdvancedCameraCardLiveJSMPEG
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected hass?: ExtendedHomeAssistant;
@state()
protected _message: Message | null = null;
protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _refreshPlayerTimer = new Timer();
@state()
protected _message: Message | null = null;
protected _mediaPlayerController = new JSMPEGMediaPlayerController(
this,
() => this._jsmpegVideoPlayer ?? null,
() => this._jsmpegCanvasElement ?? null,
);
public async play(): Promise<void> {
return this._jsmpegVideoPlayer?.play();
}
public async pause(): Promise<void> {
this._jsmpegVideoPlayer?.stop();
}
public async mute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player;
if (player) {
player.volume = 0;
}
}
public async unmute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player;
if (player) {
player.volume = 1;
}
}
public isMuted(): boolean {
return this._jsmpegVideoPlayer ? this._jsmpegVideoPlayer.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._jsmpegVideoPlayer?.player?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._jsmpegCanvasElement ?? null;
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
protected willUpdate(changedProperties: PropertyValues): void {
@@ -170,7 +130,7 @@ export class AdvancedCameraCardLiveJSMPEG
// calls back to the player to check for pause status for menu buttons.
if (this._jsmpegCanvasElement) {
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, {
player: this,
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
},
+20 -66
View File
@@ -12,28 +12,28 @@ import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
import { localize } from '../../../localize/localize.js';
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
import {
AdvancedCameraCardError,
AdvancedCameraCardMediaPlayer,
FullscreenElement,
MediaPlayer,
MediaPlayerController,
Message,
} from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/controls.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
} from '../../../utils/media-info.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/media.js';
import { screenshotMedia } from '../../../utils/screenshot.js';
import { renderTask } from '../../../utils/task.js';
import '../../message.js';
import { renderMessage } from '../../message.js';
@@ -44,10 +44,7 @@ import { VideoRTC } from './go2rtc/video-rtc.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@customElement('advanced-camera-card-live-webrtc-card')
export class AdvancedCameraCardLiveWebRTCCard
extends LitElement
implements AdvancedCameraCardMediaPlayer
{
export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements MediaPlayer {
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@@ -65,62 +62,19 @@ export class AdvancedCameraCardLiveWebRTCCard
protected hass?: HomeAssistant;
protected _mediaPlayerController = new VideoMediaPlayerController(
this,
() => this._getVideo(),
() => this.controls,
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
// A task to await the load of the WebRTC component.
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
public async play(): Promise<void> {
return this._getVideo()?.play();
}
public async pause(): Promise<void> {
this._getVideo()?.pause();
}
public async mute(): Promise<void> {
const player = this._getVideo();
if (player) {
player.muted = true;
}
}
public async unmute(): Promise<void> {
const player = this._getVideo();
if (player) {
player.muted = false;
}
}
public isMuted(): boolean {
return this._getVideo()?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
const player = this._getVideo();
if (player) {
player.currentTime = seconds;
}
}
public async setControls(controls?: boolean): Promise<void> {
const player = this._getVideo();
if (player) {
setControlsOnVideo(player, controls ?? this.controls);
}
}
public isPaused(): boolean {
return this._getVideo()?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
const video = this._getVideo();
return video ? screenshotMedia(video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getVideo();
}
connectedCallback(): void {
super.connectedCallback();
@@ -253,7 +207,7 @@ export class AdvancedCameraCardLiveWebRTCCard
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}
dispatchMediaLoadedEvent(this, video, {
player: this,
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(video),