From 3b77b11196af10fcc33108689e736119a7fec2ed Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 3 Mar 2025 20:59:01 -0800 Subject: [PATCH] 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 --- src/card-controller/actions/actions/mute.ts | 2 +- src/card-controller/actions/actions/pause.ts | 2 +- src/card-controller/actions/actions/play.ts | 2 +- src/card-controller/actions/actions/unmute.ts | 2 +- src/card-controller/download-manager.ts | 2 +- .../fullscreen/webkit/index.ts | 12 +- .../media-actions-controller.ts | 31 +- src/components-lib/media-player/image.ts | 61 ++++ src/components-lib/media-player/jsmpeg.ts | 73 +++++ .../media-player/updating-image.ts | 64 ++++ src/components-lib/media-player/video.ts | 109 +++++++ src/components-lib/menu-button-controller.ts | 10 +- src/components/image-player.ts | 55 ++++ ...image-base.ts => image-updating-player.ts} | 68 ++--- src/components/image.ts | 62 +--- src/components/live/carousel.ts | 10 +- src/components/live/provider.ts | 72 +---- src/components/live/providers/go2rtc/index.ts | 66 +---- .../live/providers/go2rtc/video-rtc.d.ts | 5 +- .../live/providers/go2rtc/video-rtc.js | 22 +- src/components/live/providers/ha.ts | 54 +--- src/components/live/providers/image.ts | 60 +--- src/components/live/providers/jsmpeg.ts | 72 +---- src/components/live/providers/webrtc-card.ts | 86 ++---- src/components/video-player.ts | 86 ++++++ src/components/viewer/carousel.ts | 24 +- src/components/viewer/provider.ts | 172 ++--------- src/patches/ha-camera-stream.ts | 92 ++---- src/patches/ha-hls-player.ts | 79 ++--- src/patches/ha-web-rtc-player.ts | 80 ++--- src/patches/types.ts | 3 + src/scss/image-player.scss | 10 + src/scss/image-updating-player.scss | 11 + src/scss/image.scss | 3 +- src/scss/live-provider.scss | 4 +- src/scss/video-player.scss | 10 + src/scss/viewer-provider.scss | 16 +- src/types.ts | 11 +- src/utils/audio.ts | 9 +- src/utils/{media.ts => controls.ts} | 28 -- src/utils/media-info.ts | 8 +- src/utils/screenshot.ts | 20 +- .../actions/actions/mute.test.ts | 8 +- .../actions/actions/pause.test.ts | 8 +- .../actions/actions/play.test.ts | 8 +- .../actions/actions/unmute.test.ts | 8 +- .../card-controller/download-manager.test.ts | 14 +- .../fullscreen/webkit/index.test.ts | 59 ++-- .../media-actions-controller.test.ts | 236 ++++++++++----- .../components-lib/media-player/image.test.ts | 117 ++++++++ .../media-player/jsmpeg.test.ts | 252 ++++++++++++++++ .../media-player/updating-image.test.ts | 192 ++++++++++++ .../components-lib/media-player/video.test.ts | 273 ++++++++++++++++++ .../menu-button-controller.test.ts | 24 +- tests/test-utils.ts | 9 + tests/utils/controls.test.ts | 75 +++++ tests/utils/media.test.ts | 145 ---------- tests/utils/screenshot.test.ts | 41 ++- 58 files changed, 1972 insertions(+), 1165 deletions(-) create mode 100644 src/components-lib/media-player/image.ts create mode 100644 src/components-lib/media-player/jsmpeg.ts create mode 100644 src/components-lib/media-player/updating-image.ts create mode 100644 src/components-lib/media-player/video.ts create mode 100644 src/components/image-player.ts rename src/components/{image-base.ts => image-updating-player.ts} (90%) create mode 100644 src/components/video-player.ts create mode 100644 src/patches/types.ts create mode 100644 src/scss/image-player.scss create mode 100644 src/scss/image-updating-player.scss create mode 100644 src/scss/video-player.scss rename src/utils/{media.ts => controls.ts} (69%) create mode 100644 tests/components-lib/media-player/image.test.ts create mode 100644 tests/components-lib/media-player/jsmpeg.test.ts create mode 100644 tests/components-lib/media-player/updating-image.test.ts create mode 100644 tests/components-lib/media-player/video.test.ts create mode 100644 tests/utils/controls.test.ts delete mode 100644 tests/utils/media.test.ts diff --git a/src/card-controller/actions/actions/mute.ts b/src/card-controller/actions/actions/mute.ts index f90fa6d0..b80ccf61 100644 --- a/src/card-controller/actions/actions/mute.ts +++ b/src/card-controller/actions/actions/mute.ts @@ -4,6 +4,6 @@ import { AdvancedCameraCardAction } from './base'; export class MuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getMediaLoadedInfoManager().get()?.player?.mute(); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.mute(); } } diff --git a/src/card-controller/actions/actions/pause.ts b/src/card-controller/actions/actions/pause.ts index e8a75968..c6120e71 100644 --- a/src/card-controller/actions/actions/pause.ts +++ b/src/card-controller/actions/actions/pause.ts @@ -4,6 +4,6 @@ import { AdvancedCameraCardAction } from './base'; export class PauseAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getMediaLoadedInfoManager().get()?.player?.pause(); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.pause(); } } diff --git a/src/card-controller/actions/actions/play.ts b/src/card-controller/actions/actions/play.ts index 8ef82c23..c307b6b3 100644 --- a/src/card-controller/actions/actions/play.ts +++ b/src/card-controller/actions/actions/play.ts @@ -4,6 +4,6 @@ import { AdvancedCameraCardAction } from './base'; export class PlayAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getMediaLoadedInfoManager().get()?.player?.play(); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.play(); } } diff --git a/src/card-controller/actions/actions/unmute.ts b/src/card-controller/actions/actions/unmute.ts index ed0b33bf..498292f9 100644 --- a/src/card-controller/actions/actions/unmute.ts +++ b/src/card-controller/actions/actions/unmute.ts @@ -4,6 +4,6 @@ import { AdvancedCameraCardAction } from './base'; export class UnmuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { - await api.getMediaLoadedInfoManager().get()?.player?.unmute(); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.unmute(); } } diff --git a/src/card-controller/download-manager.ts b/src/card-controller/download-manager.ts index f3dc5274..0fca446a 100644 --- a/src/card-controller/download-manager.ts +++ b/src/card-controller/download-manager.ts @@ -33,7 +33,7 @@ export class DownloadManager { const url = await this._api .getMediaLoadedInfoManager() .get() - ?.player?.getScreenshotURL(); + ?.mediaPlayerController?.getScreenshotURL(); if (url) { downloadURL(url, generateScreenshotTitle(this._api.getViewManager().getView())); } diff --git a/src/card-controller/fullscreen/webkit/index.ts b/src/card-controller/fullscreen/webkit/index.ts index 665f8680..6163684f 100644 --- a/src/card-controller/fullscreen/webkit/index.ts +++ b/src/card-controller/fullscreen/webkit/index.ts @@ -27,14 +27,16 @@ export class WebkitFullScreenProvider protected _stateChangeHandler = (change: ConditionStateChange): void => { if ( - change.old.mediaLoadedInfo?.player?.getFullscreenElement() !== - change.new.mediaLoadedInfo?.player?.getFullscreenElement() + change.old.mediaLoadedInfo?.mediaPlayerController?.getFullscreenElement() !== + change.new.mediaLoadedInfo?.mediaPlayerController?.getFullscreenElement() ) { - const oldElement = change.old.mediaLoadedInfo?.player?.getFullscreenElement(); + const oldElement = + change.old.mediaLoadedInfo?.mediaPlayerController?.getFullscreenElement(); oldElement?.removeEventListener('webkitbeginfullscreen', this._handler); oldElement?.removeEventListener('webkitendfullscreen', this._endHandler); - const newElement = change.new.mediaLoadedInfo?.player?.getFullscreenElement(); + const newElement = + change.new.mediaLoadedInfo?.mediaPlayerController?.getFullscreenElement(); newElement?.addEventListener('webkitbeginfullscreen', this._handler); newElement?.addEventListener('webkitendfullscreen', this._endHandler); } @@ -46,7 +48,7 @@ export class WebkitFullScreenProvider const element = this._api .getMediaLoadedInfoManager() .get() - ?.player?.getFullscreenElement(); + ?.mediaPlayerController?.getFullscreenElement(); return element instanceof HTMLVideoElement ? element : null; } diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index 8f1bd14e..1d94c037 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -5,7 +5,7 @@ import { AutoPlayCondition, AutoUnmuteCondition, } from '../config/types.js'; -import { AdvancedCameraCardMediaPlayer } from '../types.js'; +import { MediaPlayerElement } from '../types.js'; import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info.js'; import { Timer } from '../utils/timer.js'; @@ -22,7 +22,6 @@ export interface MediaActionsControllerOptions { } type RenderRoot = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget; -type PlayerElement = HTMLElement & AdvancedCameraCardMediaPlayer; /** * General note: Always unmute before playing, since Chrome may pause a piece of @@ -43,13 +42,17 @@ export class MediaActionsController { protected _root: RenderRoot | null = null; protected _eventListeners = new Map void>(); - protected _children: PlayerElement[] = []; + protected _children: MediaPlayerElement[] = []; protected _target: MediaActionsTarget | null = null; protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this)); protected _intersectionObserver = new IntersectionObserver( this._intersectionHandler.bind(this), ); + constructor() { + document.addEventListener('visibilitychange', this._visibilityHandler); + } + public setOptions(options: MediaActionsControllerOptions): void { if (this._options?.microphoneState !== options.microphoneState) { this._microphoneStateChangeHandler( @@ -116,7 +119,7 @@ export class MediaActionsController { } } protected async _play(index: number): Promise { - await this._children[index]?.play(); + await (await this._children[index]?.getMediaPlayerController())?.play(); } protected async _unmuteTargetIfConfigured( condition: AutoUnmuteCondition, @@ -129,7 +132,7 @@ export class MediaActionsController { } } protected async _unmute(index: number): Promise { - await this._children[index]?.unmute(); + await (await this._children[index]?.getMediaPlayerController())?.unmute(); } protected async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise { @@ -150,7 +153,7 @@ export class MediaActionsController { } } protected async _pause(index: number): Promise { - await this._children[index]?.pause(); + await (await this._children[index]?.getMediaPlayerController())?.pause(); } protected async _muteAllIfConfigured(condition: AutoMuteCondition): Promise { @@ -169,7 +172,7 @@ export class MediaActionsController { } } protected async _mute(index: number): Promise { - await this._children[index]?.mute(); + await (await this._children[index]?.getMediaPlayerController())?.mute(); } protected _mutationHandler( @@ -196,17 +199,21 @@ export class MediaActionsController { this._eventListeners.clear(); } - public initialize(root: RenderRoot): void { + public setRoot(root: RenderRoot): boolean { + if (root === this._root) { + return false; + } + + this._target = null; this._root = root; this._initializeRoot(); - document.addEventListener('visibilitychange', this._visibilityHandler); - this._intersectionObserver.disconnect(); - this._intersectionObserver.observe(root); + this._intersectionObserver.observe(this._root); this._mutationObserver.disconnect(); this._mutationObserver.observe(this._root, { childList: true, subtree: true }); + return true; } protected _initializeRoot(): void { @@ -217,7 +224,7 @@ export class MediaActionsController { this._removeChildHandlers(); this._children = [ - ...this._root.querySelectorAll(this._options.playerSelector), + ...this._root.querySelectorAll(this._options.playerSelector), ]; for (const [index, child] of this._children.entries()) { diff --git a/src/components-lib/media-player/image.ts b/src/components-lib/media-player/image.ts new file mode 100644 index 00000000..fa6c69ec --- /dev/null +++ b/src/components-lib/media-player/image.ts @@ -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 { + // Not implemented. + } + + public async pause(): Promise { + // Not implemented. + } + + public async mute(): Promise { + // Not implemented. + } + + public async unmute(): Promise { + // Not implemented. + } + + public isMuted(): boolean { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async seek(_seconds: number): Promise { + // Not implemented. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async setControls(_controls: boolean): Promise { + // Not implemented. + } + + public isPaused(): boolean { + // The image could be an MJPEG, so it is always reported unpaused. + return false; + } + + public async getScreenshotURL(): Promise { + 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; + } +} diff --git a/src/components-lib/media-player/jsmpeg.ts b/src/components-lib/media-player/jsmpeg.ts new file mode 100644 index 00000000..302ad308 --- /dev/null +++ b/src/components-lib/media-player/jsmpeg.ts @@ -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 { + await this._host.updateComplete; + return this._getJSMPEGVideoElementCallback()?.play(); + } + + public async pause(): Promise { + await this._host.updateComplete; + return this._getJSMPEGVideoElementCallback()?.stop(); + } + + public async mute(): Promise { + await this._host.updateComplete; + const player = this._getJSMPEGVideoElementCallback()?.player; + if (player) { + player.volume = 0; + } + } + + public async unmute(): Promise { + 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 { + // JSMPEG does not support seeking. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async setControls(_controls: boolean): Promise { + // Not implemented. + } + + public isPaused(): boolean { + return this._getJSMPEGVideoElementCallback()?.player?.paused ?? true; + } + + public async getScreenshotURL(): Promise { + await this._host.updateComplete; + return this._getCanvasElementCallback()?.toDataURL('image/jpeg') ?? null; + } + + public getFullscreenElement(): FullscreenElement | null { + return this._getCanvasElementCallback() ?? null; + } +} diff --git a/src/components-lib/media-player/updating-image.ts b/src/components-lib/media-player/updating-image.ts new file mode 100644 index 00000000..32d61807 --- /dev/null +++ b/src/components-lib/media-player/updating-image.ts @@ -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 | null; + + constructor( + host: LitElement, + getImageCallback: () => HTMLImageElement | null, + getCachedValueController: () => CachedValueController | null, + ) { + this._host = host; + this._getImageCallback = getImageCallback; + this._getCachedValueController = getCachedValueController; + } + + public async play(): Promise { + await this._host.updateComplete; + this._getCachedValueController()?.startTimer(); + } + + public async pause(): Promise { + await this._host.updateComplete; + this._getCachedValueController()?.stopTimer(); + } + + public async mute(): Promise { + // Not implemented. + } + + public async unmute(): Promise { + // Not implemented. + } + + public isMuted(): boolean { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async seek(_seconds: number): Promise { + // Not implemented. + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async setControls(_controls: boolean): Promise { + // Not implemented. + } + + public isPaused(): boolean { + return !this._getCachedValueController()?.hasTimer(); + } + + public async getScreenshotURL(): Promise { + await this._host.updateComplete; + return this._getCachedValueController()?.value ?? null; + } + + public getFullscreenElement(): FullscreenElement | null { + return this._getImageCallback() ?? null; + } +} diff --git a/src/components-lib/media-player/video.ts b/src/components-lib/media-player/video.ts new file mode 100644 index 00000000..a51cd282 --- /dev/null +++ b/src/components-lib/media-player/video.ts @@ -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 { + 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 { + await this._host.updateComplete; + this._getVideoCallback()?.pause(); + } + + public async mute(): Promise { + 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 { + 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 { + await this._host.updateComplete; + + const video = this._getVideoCallback(); + if (video) { + hideMediaControlsTemporarily(video); + video.currentTime = seconds; + } + } + + public async setControls(controls?: boolean): Promise { + 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 { + await this._host.updateComplete; + + const video = this._getVideoCallback(); + return video ? screenshotVideo(video) : null; + } + + public getFullscreenElement(): FullscreenElement | null { + return this._getVideoCallback() ?? null; + } +} diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 96539d69..50e53038 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -489,10 +489,10 @@ export class MenuButtonController { ): MenuItem | null { if ( currentMediaLoadedInfo && - currentMediaLoadedInfo.player && + currentMediaLoadedInfo.mediaPlayerController && currentMediaLoadedInfo.capabilities?.supportsPause ) { - const paused = currentMediaLoadedInfo.player.isPaused(); + const paused = currentMediaLoadedInfo.mediaPlayerController?.isPaused(); return { icon: paused ? 'mdi:play' : 'mdi:pause', ...config.menu.buttons.play, @@ -512,10 +512,10 @@ export class MenuButtonController { ): MenuItem | null { if ( currentMediaLoadedInfo && - currentMediaLoadedInfo.player && + currentMediaLoadedInfo.mediaPlayerController && currentMediaLoadedInfo?.capabilities?.hasAudio ) { - const muted = currentMediaLoadedInfo.player.isMuted(); + const muted = currentMediaLoadedInfo.mediaPlayerController?.isMuted(); return { icon: muted ? 'mdi:volume-off' : 'mdi:volume-high', ...config.menu.buttons.mute, @@ -533,7 +533,7 @@ export class MenuButtonController { config: AdvancedCameraCardConfig, currentMediaLoadedInfo?: MediaLoadedInfo | null, ): MenuItem | null { - if (currentMediaLoadedInfo && currentMediaLoadedInfo.player) { + if (currentMediaLoadedInfo && currentMediaLoadedInfo.mediaPlayerController) { return { icon: 'mdi:monitor-screenshot', ...config.menu.buttons.screenshot, diff --git a/src/components/image-player.ts b/src/components/image-player.ts new file mode 100644 index 00000000..b7534f04 --- /dev/null +++ b/src/components/image-player.ts @@ -0,0 +1,55 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { ImageMediaPlayerController } from '../components-lib/media-player/image'; +import imagePlayerStyle from '../scss/image-player.scss'; +import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types'; +import { dispatchMediaLoadedEvent } from '../utils/media-info'; + +/** + * A simple media player to wrap a single static image. + */ +@customElement('advanced-camera-card-image-player') +export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPlayer { + @property() + public url?: string; + + @property() + public filetype?: string; + + protected _refImage: Ref> = createRef(); + protected _mediaPlayerController = new ImageMediaPlayerController( + this, + () => this._refImage.value ?? null, + ); + + public async getMediaPlayerController(): Promise { + return this._mediaPlayerController; + } + + protected render(): TemplateResult | void { + return html` { + dispatchMediaLoadedEvent(this, ev, { + ...(this._mediaPlayerController && { + mediaPlayerController: this._mediaPlayerController, + }), + technology: [this.filetype ?? 'jpg'], + }); + }} + />`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(imagePlayerStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-image-player': AdvancedCameraCardImagePlayer; + } +} diff --git a/src/components/image-base.ts b/src/components/image-updating-player.ts similarity index 90% rename from src/components/image-base.ts rename to src/components/image-updating-player.ts index de3d15a8..44b34454 100644 --- a/src/components/image-base.ts +++ b/src/components/image-updating-player.ts @@ -15,14 +15,15 @@ import isEqual from 'lodash-es/isEqual'; import { CameraManager } from '../camera-manager/manager.js'; import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; +import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js'; import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js'; import defaultImage from '../images/iris-screensaver.jpg'; import { localize } from '../localize/localize.js'; -import imageStyle from '../scss/image.scss'; +import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss'; import { - AdvancedCameraCardMediaPlayer, - FullscreenElement, MediaLoadedInfo, + MediaPlayer, + MediaPlayerController, Message, } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; @@ -60,10 +61,13 @@ export const resolveImageMode = (options?: { return 'screensaver'; }; -@customElement('advanced-camera-card-image-base') -export class AdvancedCameraCardImageBase +/** + * A media player to wrap a image that updates continuously. + */ +@customElement('advanced-camera-card-image-updating-player') +export class AdvancedCameraCardImageUpdatingPlayer extends LitElement - implements AdvancedCameraCardMediaPlayer + implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @@ -93,46 +97,14 @@ export class AdvancedCameraCardImageBase protected _mediaLoadedInfo: MediaLoadedInfo | null = null; - public async play(): Promise { - this._cachedValueController?.startTimer(); - } + protected _mediaPlayerController = new UpdatingImageMediaPlayerController( + this, + () => this._refImage.value ?? null, + () => this._cachedValueController ?? null, + ); - public async pause(): Promise { - this._cachedValueController?.stopTimer(); - } - - public async mute(): Promise { - // Not implemented. - } - - public async unmute(): Promise { - // Not implemented. - } - - public isMuted(): boolean { - return true; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async seek(_seconds: number): Promise { - // Not implemented. - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async setControls(_controls: boolean): Promise { - // Not implemented. - } - - public isPaused(): boolean { - return !this._cachedValueController?.hasTimer(); - } - - public async getScreenshotURL(): Promise { - return this._cachedValueController?.value ?? null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._refImage.value ?? null; + public async getMediaPlayerController(): Promise { + return this._mediaPlayerController; } /** @@ -366,7 +338,7 @@ export class AdvancedCameraCardImageBase src=${live(src)} @load=${(ev: Event) => { const mediaLoadedInfo = createMediaLoadedInfo(ev, { - player: this, + mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: !!this.imageConfig?.refresh_seconds, }, @@ -406,12 +378,12 @@ export class AdvancedCameraCardImageBase } static get styles(): CSSResultGroup { - return unsafeCSS(imageStyle); + return unsafeCSS(imageUpdatingPlayerStyle); } } declare global { interface HTMLElementTagNameMap { - 'advanced-camera-card-image-base': AdvancedCameraCardImageBase; + 'advanced-camera-card-image-updating-player': AdvancedCameraCardImageUpdatingPlayer; } } diff --git a/src/components/image.ts b/src/components/image.ts index 5c7261ad..12e35221 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -16,19 +16,16 @@ import { ZoomSettingsObserved } from '../components-lib/zoom/types'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; import { CameraConfig, ImageViewConfig } from '../config/types'; import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; -import basicBlockStyle from '../scss/basic-block.scss'; -import { AdvancedCameraCardMediaPlayer, FullscreenElement } from '../types.js'; +import imageStyle from '../scss/image.scss'; +import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; import { aspectRatioToString } from '../utils/basic'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import './image-base'; -import { resolveImageMode } from './image-base'; +import './image-updating-player'; +import { resolveImageMode } from './image-updating-player'; import './zoomer.js'; @customElement('advanced-camera-card-image') -export class AdvancedCameraCardImage - extends LitElement - implements AdvancedCameraCardMediaPlayer -{ +export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @@ -44,47 +41,12 @@ export class AdvancedCameraCardImage @property({ attribute: false }) public imageConfig?: ImageViewConfig; - protected _refImage: Ref = createRef(); - - public async play(): Promise { - await this._refImage.value?.play(); + public async getMediaPlayerController(): Promise { + await this.updateComplete; + return (await this._refImage.value?.getMediaPlayerController()) ?? null; } - public async pause(): Promise { - await this._refImage.value?.pause(); - } - - public async mute(): Promise { - await this._refImage.value?.mute(); - } - - public async unmute(): Promise { - await this._refImage.value?.unmute(); - } - - public isMuted(): boolean { - return !!this._refImage.value?.isMuted(); - } - - public async seek(seconds: number): Promise { - await this._refImage.value?.seek(seconds); - } - - public async setControls(controls?: boolean): Promise { - await this._refImage.value?.setControls(controls); - } - - public isPaused(): boolean { - return this._refImage.value?.isPaused() ?? true; - } - - public async getScreenshotURL(): Promise { - return (await this._refImage.value?.getScreenshotURL()) ?? null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._refImage.value?.getFullscreenElement() ?? null; - } + protected _refImage: Ref = createRef(); protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) { @@ -147,19 +109,19 @@ export class AdvancedCameraCardImage } return this._useZoomIfRequired(html` - - + `); } static get styles(): CSSResultGroup { - return unsafeCSS(basicBlockStyle); + return unsafeCSS(imageStyle); } } diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 3f83afda..4f78c75c 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -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(); } } diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 9ec1e3e7..bd314206 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -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 = createRef(); + protected _refProvider: Ref = 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[] = []; - public async play(): Promise { + public async getMediaPlayerController(): Promise { await this.updateComplete; - await this._refProvider.value?.updateComplete; - await playMediaMutingIfNecessary(this, this._refProvider.value); - } - - public async pause(): Promise { - await this.updateComplete; - await this._refProvider.value?.updateComplete; - await this._refProvider.value?.pause(); - } - - public async mute(): Promise { - await this.updateComplete; - await this._refProvider.value?.updateComplete; - await this._refProvider.value?.mute(); - } - - public async unmute(): Promise { - 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 { - await this.updateComplete; - await this._refProvider.value?.updateComplete; - await this._refProvider.value?.seek(seconds); - } - - public async setControls(controls?: boolean): Promise { - 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 { - 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} ` diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 09c5f21c..46a3b863 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -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 { - return this._player?.video?.play(); - } + protected _mediaPlayerController = new VideoMediaPlayerController( + this, + () => this._player?.video ?? null, + () => this.controls, + ); - public async pause(): Promise { - this._player?.video?.pause(); - } - - public async mute(): Promise { - if (this._player?.video) { - this._player.video.muted = true; - } - } - - public async unmute(): Promise { - if (this._player?.video) { - this._player.video.muted = false; - } - } - - public isMuted(): boolean { - return this._player?.video?.muted ?? true; - } - - public async seek(seconds: number): Promise { - if (this._player?.video) { - this._player.video.currentTime = seconds; - } - } - - public async setControls(controls?: boolean): Promise { - if (this._player?.video) { - setControlsOnVideo(this._player.video, controls ?? this.controls); - } - } - - public isPaused(): boolean { - return this._player?.video?.paused ?? true; - } - - public async getScreenshotURL(): Promise { - return this._player?.video ? screenshotMedia(this._player.video) : null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._player?.video ?? null; + public async getMediaPlayerController(): Promise { + 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; diff --git a/src/components/live/providers/go2rtc/video-rtc.d.ts b/src/components/live/providers/go2rtc/video-rtc.d.ts index 614408f4..7a3c6a40 100644 --- a/src/components/live/providers/go2rtc/video-rtc.d.ts +++ b/src/components/live/providers/go2rtc/video-rtc.d.ts @@ -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 void>; // Custom methods/members. - containingPlayer: AdvancedCameraCardMediaPlayer | null; + mediaPlayerController: MediaPlayerController | null; microphoneStream: MediaStream | null; reconnect(); - setControls(controls: boolean): void; } diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index 8f91760c..6b3a55a9 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -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'], }); } diff --git a/src/components/live/providers/ha.ts b/src/components/live/providers/ha.ts index 6572325b..3e72e55a 100644 --- a/src/components/live/providers/ha.ts +++ b/src/components/live/providers/ha.ts @@ -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 = createRef(); + protected _playerRef: Ref = createRef(); - public async play(): Promise { - return this._playerRef.value?.play(); - } - - public async pause(): Promise { - this._playerRef.value?.pause(); - } - - public async mute(): Promise { - this._playerRef.value?.mute(); - } - - public async unmute(): Promise { - this._playerRef.value?.unmute(); - } - - public isMuted(): boolean { - return this._playerRef.value?.isMuted() ?? true; - } - - public async seek(seconds: number): Promise { - this._playerRef.value?.seek(seconds); - } - - public async setControls(controls?: boolean): Promise { - this._playerRef.value?.setControls(controls ?? this.controls); - } - - public isPaused(): boolean { - return this._playerRef.value?.isPaused() ?? true; - } - - public async getScreenshotURL(): Promise { - return (await this._playerRef.value?.getScreenshotURL()) ?? null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._playerRef.value?.getFullscreenElement() ?? null; + public async getMediaPlayerController(): Promise { + await this.updateComplete; + return (await this._playerRef.value?.getMediaPlayerController()) ?? null; } protected render(): TemplateResult | void { diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index 62131786..581631dd 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -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 = createRef(); + protected _refImage: Ref = createRef(); - public async play(): Promise { - await this._refImage.value?.play(); - } - - public async pause(): Promise { - await this._refImage.value?.pause(); - } - - public async mute(): Promise { - await this._refImage.value?.mute(); - } - - public async unmute(): Promise { - await this._refImage.value?.unmute(); - } - - public isMuted(): boolean { - return !!this._refImage.value?.isMuted(); - } - - public async seek(seconds: number): Promise { - await this._refImage.value?.seek(seconds); - } - - public async setControls(controls?: boolean): Promise { - await this._refImage.value?.setControls(controls); - } - - public isPaused(): boolean { - return this._refImage.value?.isPaused() ?? true; - } - - public async getScreenshotURL(): Promise { - return (await this._refImage.value?.getScreenshotURL()) ?? null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._refImage.value?.getFullscreenElement() ?? null; + public async getMediaPlayerController(): Promise { + await this.updateComplete; + return (await this._refImage.value?.getMediaPlayerController()) ?? null; } protected render(): TemplateResult | void { @@ -66,13 +32,13 @@ export class AdvancedCameraCardLiveImage } return html` - - + `; } diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index 279ec82b..a4fa1213 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -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 { - return this._jsmpegVideoPlayer?.play(); - } - - public async pause(): Promise { - this._jsmpegVideoPlayer?.stop(); - } - - public async mute(): Promise { - const player = this._jsmpegVideoPlayer?.player; - if (player) { - player.volume = 0; - } - } - - public async unmute(): Promise { - 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 { - // JSMPEG does not support seeking. - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async setControls(_controls: boolean): Promise { - // Not implemented. - } - - public isPaused(): boolean { - return this._jsmpegVideoPlayer?.player?.paused ?? true; - } - - public async getScreenshotURL(): Promise { - return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._jsmpegCanvasElement ?? null; + public async getMediaPlayerController(): Promise { + 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, }, diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index 8cd90a3d..806bb013 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -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 { + 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 { - return this._getVideo()?.play(); - } - - public async pause(): Promise { - this._getVideo()?.pause(); - } - - public async mute(): Promise { - const player = this._getVideo(); - if (player) { - player.muted = true; - } - } - - public async unmute(): Promise { - const player = this._getVideo(); - if (player) { - player.muted = false; - } - } - - public isMuted(): boolean { - return this._getVideo()?.muted ?? true; - } - - public async seek(seconds: number): Promise { - const player = this._getVideo(); - if (player) { - player.currentTime = seconds; - } - } - - public async setControls(controls?: boolean): Promise { - const player = this._getVideo(); - if (player) { - setControlsOnVideo(player, controls ?? this.controls); - } - } - - public isPaused(): boolean { - return this._getVideo()?.paused ?? true; - } - - public async getScreenshotURL(): Promise { - 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), diff --git a/src/components/video-player.ts b/src/components/video-player.ts new file mode 100644 index 00000000..c65dffb2 --- /dev/null +++ b/src/components/video-player.ts @@ -0,0 +1,86 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { VideoMediaPlayerController } from '../components-lib/media-player/video'; +import videoPlayerStyle from '../scss/video-player.scss'; +import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types'; +import { mayHaveAudio } from '../utils/audio'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../utils/controls'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent, +} from '../utils/media-info'; + +@customElement('advanced-camera-card-video-player') +export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPlayer { + @property() + public url?: string; + + @property({ type: Boolean }) + public controls = false; + + protected _refVideo: Ref> = createRef(); + protected _mediaPlayerController = new VideoMediaPlayerController( + this, + () => this._refVideo.value ?? null, + () => this.controls, + ); + + public async getMediaPlayerController(): Promise { + return this._mediaPlayerController; + } + + protected render(): TemplateResult | void { + return html` + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(videoPlayerStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-video-player': AdvancedCameraCardVideoPlayer; + } +} diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 1122c478..3ad66c1d 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -23,9 +23,9 @@ import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; import viewerCarouselStyle from '../../scss/viewer-carousel.scss'; import { - AdvancedCameraCardMediaPlayer, ExtendedHomeAssistant, MediaLoadedInfo, + MediaPlayerController, } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js'; @@ -92,7 +92,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { protected _media: ViewMedia[] | null = null; protected _mediaActionsController = new MediaActionsController(); - protected _player: AdvancedCameraCardMediaPlayer | null = null; + protected _loadedMediaPlayerController: MediaPlayerController | null = null; protected _refCarousel: Ref = createRef(); updated(changedProperties: PropertyValues): void { @@ -110,8 +110,8 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { } } - if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) { - this._mediaActionsController.initialize(this._refCarousel.value); + if (this._refCarousel.value) { + this._mediaActionsController.setRoot(this._refCarousel.value); } } @@ -383,11 +383,11 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { this._setViewSelectedIndex(ev.detail.index); }} @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { - this._player = ev.detail.player ?? null; + this._loadedMediaPlayerController = ev.detail.mediaPlayerController ?? null; this._seekHandler(); }} @advanced-camera-card:media:unloaded=${() => { - this._player = null; + this._loadedMediaPlayerController = null; }} > ${this.showControls ? this._renderNextPrevious('left', neighbors) : ''} @@ -418,7 +418,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { protected async _seekHandler(): Promise { const view = this.viewManagerEpoch?.manager.getView(); const seek = view?.context?.mediaViewer?.seek; - if (!this.hass || !seek || !this._media || !this._player) { + if (!this.hass || !seek || !this._media || !this._loadedMediaPlayerController) { return; } const selectedMedia = this._media[this._selected]; @@ -428,17 +428,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { const seekTimeInMedia = selectedMedia.includesTime(seek); setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); - if (!seekTimeInMedia && !this._player.isPaused()) { - this._player.pause(); - } else if (seekTimeInMedia && this._player.isPaused()) { - this._player.play(); + if (!seekTimeInMedia && !this._loadedMediaPlayerController.isPaused()) { + this._loadedMediaPlayerController.pause(); + } else if (seekTimeInMedia && this._loadedMediaPlayerController.isPaused()) { + this._loadedMediaPlayerController.play(); } const seekTime = (await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null; if (seekTime !== null) { - this._player.seek(seekTime); + this._loadedMediaPlayerController.seek(seekTime); } } diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 4208a385..02bdc6a9 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -17,12 +17,12 @@ import { CardWideConfig, ViewerConfig } from '../../config/types.js'; import '../../patches/ha-hls-player.js'; import viewerProviderStyle from '../../scss/viewer-provider.scss'; import { - AdvancedCameraCardMediaPlayer, ExtendedHomeAssistant, - FullscreenElement, + MediaPlayer, + MediaPlayerController, + MediaPlayerElement, ResolvedMedia, } from '../../types.js'; -import { mayHaveAudio } from '../../utils/audio.js'; import { aspectRatioToString, errorToConsole } from '../../utils/basic.js'; import { canonicalizeHAURL, @@ -35,30 +35,16 @@ import { getWebProxiedURL, shouldUseWebProxy, } from '../../utils/ha/web-proxy.js'; -import { - dispatchMediaLoadedEvent, - dispatchMediaPauseEvent, - dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent, -} from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; -import { - hideMediaControlsTemporarily, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - playMediaMutingIfNecessary, - setControlsOnVideo, -} from '../../utils/media.js'; -import { screenshotMedia } from '../../utils/screenshot.js'; import { ViewMediaClassifier } from '../../view/media-classifier.js'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier.js'; import { VideoContentType, ViewMedia } from '../../view/media.js'; +import '../image-player.js'; import { renderProgressIndicator } from '../progress-indicator.js'; +import '../video-player.js'; @customElement('advanced-camera-card-viewer-provider') -export class AdvancedCameraCardViewerProvider - extends LitElement - implements AdvancedCameraCardMediaPlayer -{ +export class AdvancedCameraCardViewerProvider extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -86,100 +72,14 @@ export class AdvancedCameraCardViewerProvider @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _refAdvancedCameraCardMediaPlayer: Ref< - Element & AdvancedCameraCardMediaPlayer - > = createRef(); - protected _refVideoProvider: Ref = createRef(); - protected _refImageProvider: Ref = createRef(); + protected _refProvider: Ref = createRef(); @state() protected _url: string | null = null; - public async play(): Promise { - await playMediaMutingIfNecessary( - this, - this._refAdvancedCameraCardMediaPlayer.value ?? this._refVideoProvider.value, - ); - } - - public async pause(): Promise { - ( - this._refAdvancedCameraCardMediaPlayer.value || this._refVideoProvider.value - )?.pause(); - } - - public async mute(): Promise { - if (this._refAdvancedCameraCardMediaPlayer.value) { - this._refAdvancedCameraCardMediaPlayer.value?.mute(); - } else if (this._refVideoProvider.value) { - this._refVideoProvider.value.muted = true; - } - } - - public async unmute(): Promise { - if (this._refAdvancedCameraCardMediaPlayer.value) { - this._refAdvancedCameraCardMediaPlayer.value?.mute(); - } else if (this._refVideoProvider.value) { - this._refVideoProvider.value.muted = false; - } - } - - public isMuted(): boolean { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return this._refAdvancedCameraCardMediaPlayer.value?.isMuted() ?? true; - } else if (this._refVideoProvider.value) { - return this._refVideoProvider.value.muted; - } - return true; - } - - public async seek(seconds: number): Promise { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return this._refAdvancedCameraCardMediaPlayer.value.seek(seconds); - } else if (this._refVideoProvider.value) { - hideMediaControlsTemporarily(this._refVideoProvider.value); - this._refVideoProvider.value.currentTime = seconds; - } - } - - public async setControls(controls?: boolean): Promise { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return this._refAdvancedCameraCardMediaPlayer.value.setControls(controls); - } else if (this._refVideoProvider.value) { - setControlsOnVideo( - this._refVideoProvider.value, - controls ?? this.viewerConfig?.controls.builtin ?? true, - ); - } - } - - public isPaused(): boolean { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return this._refAdvancedCameraCardMediaPlayer.value.isPaused(); - } else if (this._refVideoProvider.value) { - return this._refVideoProvider.value.paused; - } - return true; - } - - public async getScreenshotURL(): Promise { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return await this._refAdvancedCameraCardMediaPlayer.value.getScreenshotURL(); - } else if (this._refVideoProvider.value) { - return screenshotMedia(this._refVideoProvider.value); - } else if (this._refImageProvider.value) { - return this._refImageProvider.value.src; - } - return null; - } - - public getFullscreenElement(): FullscreenElement | null { - if (this._refAdvancedCameraCardMediaPlayer.value) { - return this._refAdvancedCameraCardMediaPlayer.value.getFullscreenElement(); - } else if (this._refVideoProvider.value) { - return this._refVideoProvider.value; - } - return null; + public async getMediaPlayerController(): Promise { + await this.updateComplete; + return (await this._refProvider.value?.getMediaPlayerController()) ?? null; } protected async _switchToRelatedClipView(): Promise { @@ -332,8 +232,10 @@ export class AdvancedCameraCardViewerProvider : undefined, )} .settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined} - @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()} @advanced-camera-card:zoom:change=${(ev: CustomEvent) => handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)} > @@ -359,7 +261,7 @@ export class AdvancedCameraCardViewerProvider ${ViewMediaClassifier.isVideo(this.media) ? this.media.getVideoContentType() === VideoContentType.HLS ? html` ` : html` - + ` - : html` { if (this.viewerConfig?.snapshot_click_plays_clip) { this._switchToRelatedClipView(); } }} - @load=${(ev: Event) => { - dispatchMediaLoadedEvent(this, ev, { player: this, technology: ['jpg'] }); - }} - />`} + >`} `); } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 3e2e7aa0..676046bf 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -12,18 +12,12 @@ import { css, CSSResultGroup, html, nothing, PropertyValues, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; +import '../components/image-player.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; -import { - AdvancedCameraCardMediaPlayer, - FullscreenElement, - MediaLoadedInfo, -} from '../types.js'; -import { - createMediaLoadedInfo, - dispatchExistingMediaLoadedInfoAsEvent, -} from '../utils/media-info.js'; -import './ha-hls-player'; -import './ha-web-rtc-player'; +import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js'; +import { dispatchExistingMediaLoadedInfoAsEvent } from '../utils/media-info.js'; +import './ha-hls-player.js'; +import './ha-web-rtc-player.js'; customElements.whenDefined('ha-camera-stream').then(() => { // ======================================================================================== @@ -44,12 +38,12 @@ customElements.whenDefined('ha-camera-stream').then(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars class AdvancedCameraCardHaCameraStream extends customElements.get('ha-camera-stream') - implements AdvancedCameraCardMediaPlayer + implements MediaPlayer { // Due to an obscure behavior when this card is casted, this element needs // to use query rather than the ref directive to find the player. @query('.player:not(.hidden)') - protected _player: AdvancedCameraCardMediaPlayer; + protected _player: MediaPlayer; protected _mediaLoadedInfoPerStream: Record = {}; protected _mediaLoadedInfoDispatched: MediaLoadedInfo | null = null; @@ -59,46 +53,9 @@ customElements.whenDefined('ha-camera-stream').then(() => { // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts // ======================================================================================== - public async play(): Promise { - return this._player?.play(); - } - - public async pause(): Promise { - this._player?.pause(); - } - - public async mute(): Promise { - this._player?.mute(); - } - - public async unmute(): Promise { - this._player?.unmute(); - } - - public isMuted(): boolean { - return this._player?.isMuted() ?? true; - } - - public async seek(seconds: number): Promise { - this._player?.seek(seconds); - } - - public async setControls(controls?: boolean): Promise { - if (this._player) { - this._player.setControls(controls ?? this.controls); - } - } - - public isPaused(): boolean { - return this._player?.isPaused() ?? true; - } - - public async getScreenshotURL(): Promise { - return this._player ? await this._player.getScreenshotURL() : null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._player?.getFullscreenElement() ?? null; + public async getMediaPlayerController(): Promise { + await this.updateComplete; + return (await this._player?.getMediaPlayerController()) ?? null; } protected _storeMediaLoadedInfoHandler( @@ -123,16 +80,17 @@ customElements.whenDefined('ha-camera-stream').then(() => { } if (stream.type === STREAM_TYPE_MJPEG) { return html` - - this._storeMediaLoadedInfo( - STREAM_TYPE_MJPEG, - createMediaLoadedInfo(ev, { player: this, technology: ['mjpeg'] }), - )} - .src=${typeof this._connected == 'undefined' || this._connected + ) => { + this._storeMediaLoadedInfo(STREAM_TYPE_MJPEG, ev.detail); + ev.stopPropagation(); + }} + src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) : this._posterUrl || ''} - /> + filetype="mjpeg" + class="player" + > `; } @@ -146,8 +104,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { .hass=${this.hass} .entityid=${this.stateObj.entity_id} .posterUrl=${this._posterUrl} - @advanced-camera-card:media:loaded=${(ev) => - this._storeMediaLoadedInfoHandler(STREAM_TYPE_HLS, ev)} + @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { + this._storeMediaLoadedInfoHandler(STREAM_TYPE_HLS, ev); + ev.stopPropagation(); + }} @streams=${this._handleHlsStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; @@ -162,8 +122,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { .hass=${this.hass} .entityid=${this.stateObj.entity_id} .posterUrl=${this._posterUrl} - @advanced-camera-card:media:loaded=${(ev) => - this._storeMediaLoadedInfoHandler(STREAM_TYPE_WEB_RTC, ev)} + @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { + this._storeMediaLoadedInfoHandler(STREAM_TYPE_WEB_RTC, ev); + ev.stopPropagation(); + }} @streams=${this._handleWebRtcStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 840d1bc4..6f0bbf19 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -13,86 +13,43 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; +import { VideoMediaPlayerController } from '../components-lib/media-player/video.js'; import { renderMessage } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; -import { AdvancedCameraCardMediaPlayer, FullscreenElement } from '../types.js'; +import { MediaPlayer, MediaPlayerController } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; import { errorToConsole } from '../utils/basic.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} 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 { ConstructableLitElement } from './types.js'; customElements.whenDefined('ha-hls-player').then(() => { + const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement; + @customElement('advanced-camera-card-ha-hls-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars - class AdvancedCameraCardHaHlsPlayer - extends customElements.get('ha-hls-player') - implements AdvancedCameraCardMediaPlayer - { + class AdvancedCameraCardHaHlsPlayer extends HaHlsPlayer implements MediaPlayer { // Due to an obscure behavior when this card is casted, this element needs // to use query rather than the ref directive to find the player. @query('#video') protected _video: HTMLVideoElement; - public async play(): Promise { - return this._video?.play(); - } + protected _mediaPlayerController = new VideoMediaPlayerController( + this, + () => this._video, + () => this.controls, + ); - public async pause(): Promise { - this._video?.pause(); - } - - public async mute(): Promise { - // The muted property is only for the initial muted state. Must explicitly - // set the muted on the video player to make the change dynamic. - if (this._video) { - this._video.muted = true; - } - } - - public async unmute(): Promise { - // See note in mute(). - if (this._video) { - this._video.muted = false; - } - } - - public isMuted(): boolean { - return this._video?.muted ?? true; - } - - public async seek(seconds: number): Promise { - if (this._video) { - hideMediaControlsTemporarily(this._video); - this._video.currentTime = seconds; - } - } - - public async setControls(controls?: boolean): Promise { - if (this._video) { - setControlsOnVideo(this._video, controls ?? this.controls); - } - } - - public isPaused(): boolean { - return this._video?.paused ?? true; - } - - public async getScreenshotURL(): Promise { - return this._video ? screenshotMedia(this._video) : null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._video; + public async getMediaPlayerController(): Promise { + return this._mediaPlayerController; } // ===================================================================================== @@ -141,7 +98,7 @@ customElements.whenDefined('ha-hls-player').then(() => { private _loadedDataHandler(ev: Event) { super._loadedData(); dispatchMediaLoadedEvent(this, ev, { - player: this, + mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: true, hasAudio: mayHaveAudio(this._video), diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 5a0cfbfd..c73099a6 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -14,84 +14,44 @@ import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; +import { VideoMediaPlayerController } from '../components-lib/media-player/video.js'; import { renderMessage } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; -import { AdvancedCameraCardMediaPlayer, FullscreenElement } from '../types.js'; +import { MediaPlayer, MediaPlayerController } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} 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 { ConstructableLitElement } from './types.js'; customElements.whenDefined('ha-web-rtc-player').then(() => { + const HaWebRtcPlayer = customElements.get( + 'ha-web-rtc-player', + ) as ConstructableLitElement; + @customElement('advanced-camera-card-ha-web-rtc-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars - class AdvancedCameraCardHaWebRtcPlayer - extends customElements.get('ha-web-rtc-player') - implements AdvancedCameraCardMediaPlayer - { + class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer { // Due to an obscure behavior when this card is casted, this element needs // to use query rather than the ref directive to find the player. @query('#remote-stream') protected _video: HTMLVideoElement; - public async play(): Promise { - return this._video?.play(); - } + protected _mediaPlayerController = new VideoMediaPlayerController( + this, + () => this._video, + () => this.controls, + ); - public async pause(): Promise { - this._video?.pause(); - } - - public async mute(): Promise { - // The muted property is only for the initial muted state. Must explicitly - // set the muted on the video player to make the change dynamic. - if (this._video) { - this._video.muted = true; - } - } - - public async unmute(): Promise { - // See note in mute(). - if (this._video) { - this._video.muted = false; - } - } - - public isMuted(): boolean { - return this._video?.muted ?? true; - } - - public async seek(seconds: number): Promise { - if (this._video) { - this._video.currentTime = seconds; - } - } - - public async setControls(controls?: boolean): Promise { - if (this._video) { - setControlsOnVideo(this._video, controls ?? this.controls); - } - } - - public isPaused(): boolean { - return this._video?.paused ?? true; - } - - public async getScreenshotURL(): Promise { - return this._video ? screenshotMedia(this._video) : null; - } - - public getFullscreenElement(): FullscreenElement | null { - return this._video ?? null; + public async getMediaPlayerController(): Promise { + return this._mediaPlayerController; } // ===================================================================================== @@ -136,7 +96,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { private _loadedDataHandler(ev: Event) { super._loadedData(); dispatchMediaLoadedEvent(this, ev, { - player: this, + mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: true, hasAudio: mayHaveAudio(this._video), diff --git a/src/patches/types.ts b/src/patches/types.ts new file mode 100644 index 00000000..8ffcfec3 --- /dev/null +++ b/src/patches/types.ts @@ -0,0 +1,3 @@ +import { LitElement } from 'lit'; + +export type ConstructableLitElement = { new (...args: unknown[]): LitElement }; diff --git a/src/scss/image-player.scss b/src/scss/image-player.scss new file mode 100644 index 00000000..b711e7d7 --- /dev/null +++ b/src/scss/image-player.scss @@ -0,0 +1,10 @@ +@use 'basic-block.scss'; +@use 'media-layout.scss'; + +img { + width: 100%; + height: 100%; + display: block; + + @include media-layout.media-layout(); +} diff --git a/src/scss/image-updating-player.scss b/src/scss/image-updating-player.scss new file mode 100644 index 00000000..658a0550 --- /dev/null +++ b/src/scss/image-updating-player.scss @@ -0,0 +1,11 @@ +@use 'basic-block.scss'; +@use 'media-background.scss'; +@use 'media-layout.scss'; + +img { + width: 100%; + height: 100%; + display: block; + + @include media-layout.media-layout(); +} diff --git a/src/scss/image.scss b/src/scss/image.scss index f44e0141..09e8d7bc 100644 --- a/src/scss/image.scss +++ b/src/scss/image.scss @@ -1,5 +1,6 @@ -@use 'media-background.scss'; +@use 'basic-block.scss'; @use 'media-layout.scss'; +@use 'media-background.scss'; img { width: 100%; diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss index 71536ce1..f2733318 100644 --- a/src/scss/live-provider.scss +++ b/src/scss/live-provider.scss @@ -1,9 +1,7 @@ +@use 'basic-block.scss'; @use 'media-background.scss'; :host { - display: block; - height: 100%; - width: 100%; position: relative; } diff --git a/src/scss/video-player.scss b/src/scss/video-player.scss new file mode 100644 index 00000000..ec91d599 --- /dev/null +++ b/src/scss/video-player.scss @@ -0,0 +1,10 @@ +@use 'basic-block.scss'; +@use 'media-layout.scss'; + +video { + width: 100%; + height: 100%; + display: block; + + @include media-layout.media-layout(); +} diff --git a/src/scss/viewer-provider.scss b/src/scss/viewer-provider.scss index 53663142..a3d25453 100644 --- a/src/scss/viewer-provider.scss +++ b/src/scss/viewer-provider.scss @@ -1,22 +1,14 @@ +@use 'basic-block.scss'; @use 'media-background.scss'; -@use 'media-layout.scss'; -:host { +advanced-camera-card-ha-hls-player, +advanced-camera-card-image-player, +advanced-camera-card-video-player { display: block; width: 100%; height: 100%; } -img, -video, -advanced-camera-card-ha-hls-player { - display: block; - width: 100%; - height: 100%; - - @include media-layout.media-layout(); -} - advanced-camera-card-progress-indicator { padding: 30px; box-sizing: border-box; diff --git a/src/types.ts b/src/types.ts index d8ee58fb..7274e418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,7 +39,8 @@ export interface MediaLoadedInfo { width: number; height: number; technology?: MediaTechnology[]; - player?: AdvancedCameraCardMediaPlayer; + + mediaPlayerController?: MediaPlayerController; capabilities?: MediaLoadedCapabilities; } @@ -63,7 +64,7 @@ export type WebkitHTMLVideoElement = HTMLVideoElement & { export type FullscreenElement = HTMLElement; -export interface AdvancedCameraCardMediaPlayer { +export interface MediaPlayerController { play(): Promise; pause(): Promise; mute(): Promise; @@ -77,6 +78,12 @@ export interface AdvancedCameraCardMediaPlayer { getFullscreenElement(): FullscreenElement | null; } +export interface MediaPlayer { + getMediaPlayerController(): Promise; +} + +export type MediaPlayerElement = T & MediaPlayer; + export type LovelaceCardWithEditor = LovelaceCard & { constructor: { getConfigElement(): Promise; diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 840d0dc7..ddca34ff 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -4,10 +4,11 @@ export interface AudioProperties { } // There is currently no consistent cross-browser modern way to determine if a -// viden has audio tracks. The below will work in ~24% of browsers, but notably -// not in Chrome. There used to be a usable `webkitAudioDecodedByteCount` -// property, but this now seems to be consistently 0 in Chrome. This generously -// defaults to assuming there is audio when we cannot rule it out. +// video element has audio tracks. The below will work in ~24% of browsers, but +// notably not in Chrome. There used to be a usable +// `webkitAudioDecodedByteCount` property, but this now seems to be consistently +// 0 in Chrome. This generously defaults to assuming there is audio when we +// cannot rule it out. export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean => { if (video.mozHasAudio !== undefined) { return video.mozHasAudio; diff --git a/src/utils/media.ts b/src/utils/controls.ts similarity index 69% rename from src/utils/media.ts rename to src/utils/controls.ts index 252f7f6a..85ba8e5b 100644 --- a/src/utils/media.ts +++ b/src/utils/controls.ts @@ -1,4 +1,3 @@ -import { AdvancedCameraCardMediaPlayer } from '../types'; import { Timer } from './timer'; // The number of seconds to hide the video controls for after loading (in order @@ -59,30 +58,3 @@ export const hideMediaControlsTemporarily = ( setControlsOnVideo(video, oldValue); }); }; - -/** - * @param player The Advanced Camera Card Media Player object. - * @param video An underlying video or media player upon which to call play. - */ -export const playMediaMutingIfNecessary = async ( - player: AdvancedCameraCardMediaPlayer, - video?: HTMLVideoElement | AdvancedCameraCardMediaPlayer, -): Promise => { - // 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. - if (video?.play) { - try { - await video.play(); - } catch (err: unknown) { - if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) { - await player.mute(); - try { - await video.play(); - } catch (_) { - // Pass. - } - } - } - } -}; diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index cf8f54bc..3929b2a6 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -1,7 +1,7 @@ import { - AdvancedCameraCardMediaPlayer, MediaLoadedCapabilities, MediaLoadedInfo, + MediaPlayerController, MediaTechnology, } from '../types.js'; import { dispatchAdvancedCameraCardEvent } from './basic.js'; @@ -17,7 +17,7 @@ const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF; export function createMediaLoadedInfo( source: Event | HTMLElement, options?: { - player?: AdvancedCameraCardMediaPlayer; + mediaPlayerController?: MediaPlayerController; capabilities?: MediaLoadedCapabilities; technology?: MediaTechnology[]; }, @@ -45,7 +45,7 @@ export function createMediaLoadedInfo( return { width: (target as HTMLCanvasElement).width, height: (target as HTMLCanvasElement).height, - player: options?.player, + mediaPlayerController: options?.mediaPlayerController, ...options, }; } @@ -61,7 +61,7 @@ export function dispatchMediaLoadedEvent( target: HTMLElement, source: Event | HTMLElement, options?: { - player?: AdvancedCameraCardMediaPlayer; + mediaPlayerController?: MediaPlayerController; capabilities?: MediaLoadedCapabilities; technology?: MediaTechnology[]; }, diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts index 7aff55ef..048af561 100644 --- a/src/utils/screenshot.ts +++ b/src/utils/screenshot.ts @@ -1,16 +1,28 @@ import { format } from 'date-fns'; import { View } from '../view/view'; -export const screenshotMedia = (video: HTMLVideoElement): string | null => { +export const screenshotImage = (image: HTMLImageElement): string | null => { + return screenshotElement(image, image.naturalWidth, image.naturalHeight); +}; + +export const screenshotVideo = (video: HTMLVideoElement): string | null => { + return screenshotElement(video, video.videoWidth, video.videoHeight); +}; + +const screenshotElement = ( + src: CanvasImageSource, + width: number, + height: number, +): string | null => { const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + canvas.width = width; + canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { return null; } - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + ctx.drawImage(src, 0, 0, canvas.width, canvas.height); return canvas.toDataURL('image/jpeg'); }; diff --git a/tests/card-controller/actions/actions/mute.test.ts b/tests/card-controller/actions/actions/mute.test.ts index 31234251..4c778753 100644 --- a/tests/card-controller/actions/actions/mute.test.ts +++ b/tests/card-controller/actions/actions/mute.test.ts @@ -1,15 +1,15 @@ import { expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { MuteAction } from '../../../../src/card-controller/actions/actions/mute'; -import { AdvancedCameraCardMediaPlayer } from '../../../../src/types'; +import { MediaPlayerController } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; it('should handle mute action', async () => { const api = createCardAPI(); - const player = mock(); + const mediaPlayerController = mock(); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const action = new MuteAction( @@ -22,5 +22,5 @@ it('should handle mute action', async () => { await action.execute(api); - expect(player.mute).toBeCalled(); + expect(mediaPlayerController.mute).toBeCalled(); }); diff --git a/tests/card-controller/actions/actions/pause.test.ts b/tests/card-controller/actions/actions/pause.test.ts index 02b12d7b..6b6127a1 100644 --- a/tests/card-controller/actions/actions/pause.test.ts +++ b/tests/card-controller/actions/actions/pause.test.ts @@ -1,15 +1,15 @@ import { expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { PauseAction } from '../../../../src/card-controller/actions/actions/pause'; -import { AdvancedCameraCardMediaPlayer } from '../../../../src/types'; +import { MediaPlayerController } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; it('should handle pause action', async () => { const api = createCardAPI(); - const player = mock(); + const mediaPlayerController = mock(); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const action = new PauseAction( @@ -22,5 +22,5 @@ it('should handle pause action', async () => { await action.execute(api); - expect(player.pause).toBeCalled(); + expect(mediaPlayerController.pause).toBeCalled(); }); diff --git a/tests/card-controller/actions/actions/play.test.ts b/tests/card-controller/actions/actions/play.test.ts index 3c35373f..5f3a9a55 100644 --- a/tests/card-controller/actions/actions/play.test.ts +++ b/tests/card-controller/actions/actions/play.test.ts @@ -1,15 +1,15 @@ import { expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { PlayAction } from '../../../../src/card-controller/actions/actions/play'; -import { AdvancedCameraCardMediaPlayer } from '../../../../src/types'; +import { MediaPlayerController } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; it('should handle play action', async () => { const api = createCardAPI(); - const player = mock(); + const mediaPlayerController = mock(); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const action = new PlayAction( @@ -22,5 +22,5 @@ it('should handle play action', async () => { await action.execute(api); - expect(player.play).toBeCalled(); + expect(mediaPlayerController.play).toBeCalled(); }); diff --git a/tests/card-controller/actions/actions/unmute.test.ts b/tests/card-controller/actions/actions/unmute.test.ts index a47a9801..0b8a70f0 100644 --- a/tests/card-controller/actions/actions/unmute.test.ts +++ b/tests/card-controller/actions/actions/unmute.test.ts @@ -1,15 +1,15 @@ import { expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { UnmuteAction } from '../../../../src/card-controller/actions/actions/unmute'; -import { AdvancedCameraCardMediaPlayer } from '../../../../src/types'; +import { MediaPlayerController } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; it('should handle unmute action', async () => { const api = createCardAPI(); - const player = mock(); + const mediaPlayerController = mock(); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const action = new UnmuteAction( @@ -22,5 +22,5 @@ it('should handle unmute action', async () => { await action.execute(api); - expect(player.unmute).toBeCalled(); + expect(mediaPlayerController.unmute).toBeCalled(); }); diff --git a/tests/card-controller/download-manager.test.ts b/tests/card-controller/download-manager.test.ts index 07282f8a..84b718b6 100644 --- a/tests/card-controller/download-manager.test.ts +++ b/tests/card-controller/download-manager.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { DownloadManager } from '../../src/card-controller/download-manager'; -import { AdvancedCameraCardMediaPlayer } from '../../src/types'; +import { MediaPlayerController } from '../../src/types'; import { downloadMedia, downloadURL } from '../../src/utils/download.js'; import { createCardAPI, @@ -62,12 +62,12 @@ describe('DownloadManager.downloadScreenshot', () => { it('with url', async () => { const api = createCardAPI(); - const player = mock(); - player.getScreenshotURL.mockResolvedValue('http://screenshot'); + const mediaPlayerController = mock(); + mediaPlayerController.getScreenshotURL.mockResolvedValue('http://screenshot'); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const manager = new DownloadManager(api); @@ -78,12 +78,12 @@ describe('DownloadManager.downloadScreenshot', () => { it('without url', async () => { const api = createCardAPI(); - const player = mock(); - player.getScreenshotURL.mockResolvedValue(null); + const mediaPlayerController = mock(); + mediaPlayerController.getScreenshotURL.mockResolvedValue(null); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); const manager = new DownloadManager(api); diff --git a/tests/card-controller/fullscreen/webkit/index.test.ts b/tests/card-controller/fullscreen/webkit/index.test.ts index 08809c93..abadebab 100644 --- a/tests/card-controller/fullscreen/webkit/index.test.ts +++ b/tests/card-controller/fullscreen/webkit/index.test.ts @@ -2,10 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit'; import { ConditionStateManager } from '../../../../src/conditions/state-manager'; -import { - AdvancedCameraCardMediaPlayer, - WebkitHTMLVideoElement, -} from '../../../../src/types'; +import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; const createWebkitVideoElement = (): HTMLVideoElement & @@ -13,10 +10,10 @@ const createWebkitVideoElement = (): HTMLVideoElement & return document.createElement('video'); }; -const createPlayer = (element: HTMLElement): AdvancedCameraCardMediaPlayer => { - const player = mock(); - player.getFullscreenElement.mockReturnValue(element); - return player; +const createMediaPlayerController = (element: HTMLElement): MediaPlayerController => { + const mediaPlayerController = mock(); + mediaPlayerController.getFullscreenElement.mockReturnValue(element); + return mediaPlayerController; }; // @vitest-environment jsdom @@ -57,10 +54,10 @@ describe('WebkitFullScreenProvider', () => { const element = createWebkitVideoElement(); element.webkitDisplayingFullscreen = fullscreen; - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -76,10 +73,10 @@ describe('WebkitFullScreenProvider', () => { const element = createWebkitVideoElement(); element.webkitSupportsFullscreen = supported; - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -96,10 +93,10 @@ describe('WebkitFullScreenProvider', () => { element.webkitEnterFullscreen = vi.fn(); element.webkitSupportsFullscreen = true; - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -116,10 +113,10 @@ describe('WebkitFullScreenProvider', () => { element.webkitExitFullscreen = vi.fn(); element.webkitSupportsFullscreen = true; - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -137,10 +134,10 @@ describe('WebkitFullScreenProvider', () => { element.webkitExitFullscreen = vi.fn(); element.webkitSupportsFullscreen = false; - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -160,10 +157,10 @@ describe('WebkitFullScreenProvider', () => { element.webkitEnterFullscreen = vi.fn(); element.webkitExitFullscreen = vi.fn(); - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); @@ -190,9 +187,11 @@ describe('WebkitFullScreenProvider', () => { provider.connect(); const element_1 = createWebkitVideoElement(); - const player_1 = mock(); - player_1.getFullscreenElement.mockReturnValue(element_1); - const mediaLoadedInfo_1 = createMediaLoadedInfo({ player: player_1 }); + const mediaPlayerController_1 = mock(); + mediaPlayerController_1.getFullscreenElement.mockReturnValue(element_1); + const mediaLoadedInfo_1 = createMediaLoadedInfo({ + mediaPlayerController: mediaPlayerController_1, + }); stateManager.setState({ mediaLoadedInfo: mediaLoadedInfo_1 }); @@ -201,9 +200,11 @@ describe('WebkitFullScreenProvider', () => { expect(handler).toBeCalledTimes(1); const element_2 = createWebkitVideoElement(); - const player_2 = mock(); - player_2.getFullscreenElement.mockReturnValue(element_2); - const mediaLoadedInfo_2 = createMediaLoadedInfo({ player: player_2 }); + const mediaPlayerController_2 = mock(); + mediaPlayerController_2.getFullscreenElement.mockReturnValue(element_2); + const mediaLoadedInfo_2 = createMediaLoadedInfo({ + mediaPlayerController: mediaPlayerController_2, + }); stateManager.setState({ mediaLoadedInfo: mediaLoadedInfo_2 }); @@ -243,14 +244,14 @@ describe('WebkitFullScreenProvider', () => { const element = createWebkitVideoElement(); element.play = vi.fn(); - const player = createPlayer(element); + const mediaPlayerController = createMediaPlayerController(element); vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( createMediaLoadedInfo({ - player: player, + mediaPlayerController, }), ); - const mediaLoadedInfo = createMediaLoadedInfo({ player }); + const mediaLoadedInfo = createMediaLoadedInfo({ mediaPlayerController }); stateManager.setState({ mediaLoadedInfo }); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index 424c5758..c2bffe23 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -1,10 +1,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { MicrophoneState } from '../../src/card-controller/types'; import { MediaActionsController, MediaActionsControllerOptions, } from '../../src/components-lib/media-actions-controller'; -import { AdvancedCameraCardMediaPlayer } from '../../src/types'; +import { MediaPlayerController, MediaPlayerElement } from '../../src/types'; import { IntersectionObserverMock, MutationObserverMock, @@ -18,30 +19,22 @@ import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test const getPlayer = ( element: HTMLElement, selector: string, -): (HTMLElement & AdvancedCameraCardMediaPlayer) | null => { +): MediaPlayerElement | null => { return element.querySelector(selector); }; -const createPlayer = (): HTMLElement & AdvancedCameraCardMediaPlayer => { +const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => { const player = document.createElement('video'); - - player['play'] = vi.fn(); - player['pause'] = vi.fn(); - player['mute'] = vi.fn(); - player['unmute'] = vi.fn(); - player['isMuted'] = vi.fn().mockReturnValue(true); - player['seek'] = vi.fn(); - player['getScreenshotURL'] = vi.fn(); - player['setControls'] = vi.fn(); - player['isPaused'] = vi.fn(); - - return player as unknown as HTMLElement & AdvancedCameraCardMediaPlayer; + player['getMediaPlayerController'] = vi + .fn() + .mockResolvedValue(controller ?? mock()); + return player as unknown as MediaPlayerElement; }; const createPlayerSlideNodes = (n = 10): HTMLElement[] => { const divs = createTestSlideNodes({ n: n }); for (const div of divs) { - div.appendChild(createPlayer()); + div.appendChild(createPlayerElement()); } return divs; }; @@ -61,11 +54,11 @@ describe('MediaActionsController', () => { vi.clearAllMocks(); }); - describe('should initialize', () => { + describe('should set root', () => { it('should have root', async () => { const controller = new MediaActionsController(); - controller.initialize(createParent()); + controller.setRoot(createParent()); expect(controller.hasRoot()).toBeTruthy(); }); @@ -76,13 +69,23 @@ describe('MediaActionsController', () => { const children = createPlayerSlideNodes(); const parent = createParent({ children: children }); - controller.initialize(parent); + controller.setRoot(parent); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).not.toBeCalled(); }); - it('should re-initialize after mutation', async () => { + it('should do nothing on resetting same root', () => { + const controller = new MediaActionsController(); + const parent = createParent({ children: createPlayerSlideNodes() }); + + expect(controller.setRoot(parent)).toBeTruthy(); + expect(controller.setRoot(parent)).toBeFalsy(); + }); + + it('should re-setRoot after mutation', async () => { const controller = new MediaActionsController(); controller.setOptions({ playerSelector: 'video', @@ -90,9 +93,11 @@ describe('MediaActionsController', () => { }); const parent = createParent({ children: createPlayerSlideNodes(1) }); - controller.initialize(parent); + controller.setRoot(parent); - const newPlayer = createPlayer(); + const mediaPlayerController = mock(); + + const newPlayer = createPlayerElement(mediaPlayerController); const newChild = document.createElement('div'); newChild.appendChild(newPlayer); parent.append(newChild); @@ -101,7 +106,7 @@ describe('MediaActionsController', () => { await controller.setTarget(1, true); - expect(newPlayer.play).toBeCalled(); + expect(mediaPlayerController.play).toBeCalled(); }); }); @@ -115,13 +120,15 @@ describe('MediaActionsController', () => { const children = createPlayerSlideNodes(); const parent = createParent({ children: children }); - controller.initialize(parent); + controller.setRoot(parent); controller.destroy(); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).not.toBeCalled(); }); }); @@ -146,11 +153,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); @@ -162,13 +171,19 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); }); it('should unselect before selecting a new target', async () => { @@ -180,13 +195,17 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); await controller.setTarget(1, true); - expect(getPlayer(children[0], 'video')?.pause).toBeCalled(); - expect(getPlayer(children[0], 'video')?.mute).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.pause, + ).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, + ).toBeCalled(); }); it('should select after target was previously visible', async () => { @@ -198,17 +217,25 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, false); - expect(getPlayer(children[0], 'video')?.play).not.toBeCalled(); - expect(getPlayer(children[0], 'video')?.unmute).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).not.toBeCalled(); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).toBeCalled(); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalled(); }); }); @@ -221,12 +248,16 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(1); controller.unsetTarget(); @@ -236,8 +267,12 @@ describe('MediaActionsController', () => { await flushPromises(); // Play/Mute will not have been called again. - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(1); }); describe('should respond to media loaded', () => { @@ -249,10 +284,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); getPlayer(children[0], 'video')?.dispatchEvent( new Event('advanced-camera-card:media:loaded'), @@ -260,7 +298,9 @@ describe('MediaActionsController', () => { await flushPromises(); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(2); }); it('should unmute after media load', async () => { @@ -271,10 +311,12 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(1); getPlayer(children[0], 'video')?.dispatchEvent( new Event('advanced-camera-card:media:loaded'), @@ -282,7 +324,9 @@ describe('MediaActionsController', () => { await flushPromises(); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(2); }); it('should take no action on unrelated media load', async () => { @@ -294,7 +338,7 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); @@ -304,8 +348,12 @@ describe('MediaActionsController', () => { await flushPromises(); - expect(getPlayer(children[9], 'video')?.play).not.toBeCalled(); - expect(getPlayer(children[9], 'video')?.unmute).not.toBeCalled(); + expect( + (await getPlayer(children[9], 'video')?.getMediaPlayerController())?.play, + ).not.toBeCalled(); + expect( + (await getPlayer(children[9], 'video')?.getMediaPlayerController())?.unmute, + ).not.toBeCalled(); }); it('should play and unmute on unselected but targeted media load', async () => { @@ -317,12 +365,16 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, false); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(1); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(1); getPlayer(children[0], 'video')?.dispatchEvent( new Event('advanced-camera-card:media:loaded'), @@ -330,8 +382,12 @@ describe('MediaActionsController', () => { await flushPromises(); - expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play, + ).toBeCalledTimes(2); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalledTimes(2); }); }); @@ -356,12 +412,14 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); await controller.setTarget(0, false); - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); }); @@ -389,11 +447,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).not.toBeCalled(); Object.defineProperty(document, 'visibilityState', { value: 'visible', @@ -402,7 +462,9 @@ describe('MediaActionsController', () => { await callVisibilityHandler(); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); }); @@ -432,11 +494,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).not.toBeCalled(); Object.defineProperty(document, 'visibilityState', { value: 'hidden', @@ -445,7 +509,9 @@ describe('MediaActionsController', () => { await callVisibilityHandler(); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); }); @@ -471,11 +537,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).not.toBeCalled(); // There's always a first call to an intersection observer handler. In // this case the MediaActionsController ignores it. @@ -484,7 +552,9 @@ describe('MediaActionsController', () => { await callIntersectionHandler(true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); }); @@ -510,11 +580,13 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).not.toBeCalled(); // There's always a first call to an intersection observer handler. In // this case the MediaActionsController ignores it. @@ -523,7 +595,9 @@ describe('MediaActionsController', () => { await callIntersectionHandler(true); // Not configured to take action on selection. - expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], + ).toBeCalledTimes(called ? 1 : 0); }, ); }); @@ -558,7 +632,7 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); @@ -568,7 +642,9 @@ describe('MediaActionsController', () => { microphoneState: createMicrophoneState({ muted: false }), }); - expect(getPlayer(children[0], 'video')?.unmute).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalled(); }); it('should mute after delay after microphone muted', async () => { @@ -581,7 +657,7 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); @@ -593,7 +669,9 @@ describe('MediaActionsController', () => { vi.runOnlyPendingTimers(); - expect(getPlayer(children[0], 'video')?.mute).toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, + ).toBeCalled(); }); it('should not mute after delay after microphone muted', async () => { @@ -606,7 +684,7 @@ describe('MediaActionsController', () => { }); const children = createPlayerSlideNodes(); - controller.initialize(createParent({ children: children })); + controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); @@ -618,7 +696,9 @@ describe('MediaActionsController', () => { vi.runOnlyPendingTimers(); - expect(getPlayer(children[0], 'video')?.mute).not.toBeCalled(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, + ).not.toBeCalled(); }); }); }); diff --git a/tests/components-lib/media-player/image.test.ts b/tests/components-lib/media-player/image.test.ts new file mode 100644 index 00000000..02b63897 --- /dev/null +++ b/tests/components-lib/media-player/image.test.ts @@ -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(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.play(); + + // Currently no observable side effects. + }); + + it('should ignore pause', async () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.pause(); + + // Currently no observable side effects. + }); + + it('should ignore mute', async () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.mute(); + + // Currently no observable side effects. + }); + + it('should ignore unmute', async () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.unmute(); + + // Currently no observable side effects. + }); + + it('should always report muted', () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + expect(controller.isMuted()).toBeTruthy(); + }); + + it('should ignore seek', async () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.seek(10); + + // Currently no observable side effects. + }); + + it('should ignore set controls', async () => { + const image = mock(); + const controller = new ImageMediaPlayerController(createLitElement(), () => image); + + await controller.setControls(true); + + // Currently no observable side effects. + }); + + it('should always report unpaused', () => { + const image = mock(); + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/tests/components-lib/media-player/jsmpeg.test.ts b/tests/components-lib/media-player/jsmpeg.test.ts new file mode 100644 index 00000000..53e9e541 --- /dev/null +++ b/tests/components-lib/media-player/jsmpeg.test.ts @@ -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(); + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + await controller.play(); + + expect(videoElement.play).toBeCalled(); + }); + + it('should pause', async () => { + const videoElement = mock(); + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + await controller.pause(); + + expect(videoElement.stop).toBeCalled(); + }); + + describe('should mute', async () => { + it('should mute with player', async () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.volume = 1; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + await controller.mute(); + + expect(videoElement.player.volume).toBe(0); + }); + + it('should ignore calls without player', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => null, + () => mock(), + ); + + await controller.mute(); + + // Currently no observable side effects. + }); + }); + + describe('should unmute', async () => { + it('should mute with player', async () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.volume = 0; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + await controller.unmute(); + + expect(videoElement.player.volume).toBe(1); + }); + + it('should ignore calls without player', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => null, + () => mock(), + ); + + await controller.unmute(); + + // Currently no observable side effects. + }); + }); + + describe('should return muted state', () => { + it('should return true when muted', () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.volume = 0; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + expect(controller.isMuted()).toBeTruthy(); + }); + + it('should return false when not muted', () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.volume = 1; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + expect(controller.isMuted()).toBeFalsy(); + }); + + it('should return true when no player', () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => null, + () => mock(), + ); + + expect(controller.isMuted()).toBeTruthy(); + }); + }); + + it('should ignore seek', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => mock(), + ); + + await controller.seek(10); + + // Currently no observable side effects. + }); + + it('should ignore set controls', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => mock(), + ); + await controller.setControls(true); + + // Currently no observable side effects. + }); + + describe('should return paused state', () => { + it('should return true when paused', async () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.paused = true; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + expect(controller.isPaused()).toBeTruthy(); + }); + + it('should return false when not paused', async () => { + const videoElement = mock(); + videoElement.player = mock(); + videoElement.player.paused = false; + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => videoElement, + () => mock(), + ); + + expect(controller.isPaused()).toBeFalsy(); + }); + + it('should return true when no video', () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => null, + () => mock(), + ); + + 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(); + canvas.toDataURL.mockReturnValue(url); + + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => canvas, + ); + + expect(await controller.getScreenshotURL()).toBe(url); + }); + + it('should return null without canvas', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => null, + ); + + expect(await controller.getScreenshotURL()).toBeNull(); + }); + }); + + describe('should get fullscreen element', async () => { + it('should return fullscreen element with canvas', async () => { + const canvas = mock(); + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => canvas, + ); + + expect(await controller.getFullscreenElement()).toBe(canvas); + }); + + it('should return null without cancas', async () => { + const controller = new JSMPEGMediaPlayerController( + createLitElement(), + () => mock(), + () => null, + ); + + expect(controller.getFullscreenElement()).toBeNull(); + }); + }); +}); diff --git a/tests/components-lib/media-player/updating-image.test.ts b/tests/components-lib/media-player/updating-image.test.ts new file mode 100644 index 00000000..53f2641d --- /dev/null +++ b/tests/components-lib/media-player/updating-image.test.ts @@ -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>(); + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => cachedValueController, + ); + + await controller.play(); + + expect(cachedValueController.startTimer).toHaveBeenCalled(); + }); + + it('should pause', async () => { + const cachedValueController = mock>(); + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => cachedValueController, + ); + + await controller.pause(); + + expect(cachedValueController.stopTimer).toHaveBeenCalled(); + }); + + it('should ignore mute', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => mock>(), + ); + + await controller.mute(); + + // Currently no observable side effects. + }); + + it('should ignore unmute', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => mock>(), + ); + + await controller.unmute(); + + // Currently no observable side effects. + }); + + it('should always report muted', () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => mock>(), + ); + + expect(controller.isMuted()).toBeTruthy(); + }); + + it('should ignore seek', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => mock>(), + ); + + await controller.seek(10); + + // Currently no observable side effects. + }); + + it('should ignore set controls', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => mock>(), + ); + + await controller.setControls(true); + + // Currently no observable side effects. + }); + + it('should always report unpaused', () => { + const image = mock(); + 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.hasTimer.mockReturnValue(false); + + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => cachedValueController, + ); + + expect(controller.isPaused()).toBeTruthy(); + }); + + it('should return false when the cached value controller has a timer', () => { + const cachedValueController = mock>(); + cachedValueController.hasTimer.mockReturnValue(true); + + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => cachedValueController, + ); + + expect(controller.isPaused()).toBeFalsy(); + }); + + it('should return true without cached value controller', () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => 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>(); + Object.defineProperty(cachedValueController, 'value', { value: url }); + + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => cachedValueController, + ); + + expect(await controller.getScreenshotURL()).toBe(url); + }); + + it('should return null without cached value controller', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => mock(), + () => null, + ); + + expect(await controller.getScreenshotURL()).toBeNull(); + }); + }); + + describe('should get fullscreen element', async () => { + it('should return fullscreen element with image', async () => { + const image = mock(); + + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => image, + () => mock>(), + ); + + expect(await controller.getFullscreenElement()).toBe(image); + }); + + it('should return null without image', async () => { + const controller = new UpdatingImageMediaPlayerController( + createLitElement(), + () => null, + () => mock>(), + ); + + expect(controller.getFullscreenElement()).toBeNull(); + }); + }); +}); diff --git a/tests/components-lib/media-player/video.test.ts b/tests/components-lib/media-player/video.test.ts new file mode 100644 index 00000000..987de508 --- /dev/null +++ b/tests/components-lib/media-player/video.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + video.muted = true; + const controller = new VideoMediaPlayerController(createLitElement(), () => video); + + expect(controller.isMuted()).toBeTruthy(); + }); + + it('should return false when not muted', () => { + const video = mock(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 015fe1f2..9c81a477 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -19,7 +19,7 @@ import { MenuItem, ViewDisplayMode, } from '../../src/config/types'; -import { AdvancedCameraCardMediaPlayer } from '../../src/types'; +import { MediaPlayerController } from '../../src/types'; import { createGeneralAction, createViewAction } from '../../src/utils/action'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; @@ -1349,13 +1349,13 @@ describe('MenuButtonController', () => { }); it('should have pause button', () => { - const player = mock(); + const mediaPlayerController = mock(); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { supportsPause: true, }, - player: player, + mediaPlayerController, }), }); @@ -1370,14 +1370,14 @@ describe('MenuButtonController', () => { }); it('should have play button', () => { - const player = mock(); - player.isPaused.mockReturnValue(true); + const mediaPlayerController = mock(); + mediaPlayerController.isPaused.mockReturnValue(true); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { supportsPause: true, }, - player: player, + mediaPlayerController, }), }); @@ -1392,13 +1392,13 @@ describe('MenuButtonController', () => { }); it('should have mute button', () => { - const player = mock(); + const mediaPlayerController = mock(); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { hasAudio: true, }, - player: player, + mediaPlayerController, }), }); @@ -1413,14 +1413,14 @@ describe('MenuButtonController', () => { }); it('should have unmute button', () => { - const player = mock(); - player.isMuted.mockReturnValue(true); + const mediaPlayerController = mock(); + mediaPlayerController.isMuted.mockReturnValue(true); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { hasAudio: true, }, - player: player, + mediaPlayerController, }), }); @@ -1437,7 +1437,7 @@ describe('MenuButtonController', () => { it('should have screenshot button', () => { const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ - player: mock(), + mediaPlayerController: mock(), }), }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index f12703e7..381c3b99 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -473,6 +473,15 @@ export const createLitElement = (): LitElement => { const element = document.createElement('div') as unknown as LitElement; element.addController = vi.fn(); element.requestUpdate = vi.fn(); + + const promise: Promise = new Promise((resolve) => { + resolve(false); + }); + + // Need to overwrite a read-only property. + Object.defineProperty(element, 'updateComplete', { + value: promise, + }); return element; }; diff --git a/tests/utils/controls.test.ts b/tests/utils/controls.test.ts new file mode 100644 index 00000000..962d4748 --- /dev/null +++ b/tests/utils/controls.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + AdvancedCameraCardHTMLVideoElement, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + hideMediaControlsTemporarily, + setControlsOnVideo, +} from '../../src/utils/controls.js'; + +// @vitest-environment jsdom +describe('setControlsOnVideo', () => { + it('should set controls', () => { + const video = document.createElement('video'); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + }); + + it('should stop timer', () => { + const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); + hideMediaControlsTemporarily(video); + + expect(video._controlsHideTimer).toBeTruthy(); + expect(video._controlsHideTimer?.isRunning()).toBeTruthy(); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +describe('hideMediaControlsTemporarily', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should set controls', () => { + const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); + video.controls = true; + hideMediaControlsTemporarily(video); + + expect(video.controls).toBeFalsy(); + vi.runOnlyPendingTimers(); + + expect(video.controls).toBeTruthy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); + + it('should add event listener that resets controls', () => { + const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); + video.controls = true; + + hideMediaControlsTemporarily(video); + expect(video.controls).toBeFalsy(); + + // After a new media starts to load, the controls should reset. + video.dispatchEvent(new Event('loadstart')); + expect(video.controls).toBeTruthy(); + + // ... but only once, future loadstart events without subsequent calls to + // hideMediaControlsTemporarily() should do nothing. + video.controls = false; + video.dispatchEvent(new Event('loadstart')); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +describe('constants', () => { + it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => { + expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2); + }); +}); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts deleted file mode 100644 index 99e16287..00000000 --- a/tests/utils/media.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { AdvancedCameraCardMediaPlayer } from '../../src/types.js'; -import { - AdvancedCameraCardHTMLVideoElement, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - hideMediaControlsTemporarily, - playMediaMutingIfNecessary, - setControlsOnVideo, -} from '../../src/utils/media.js'; - -// @vitest-environment jsdom -describe('setControlsOnVideo', () => { - it('should set controls', () => { - const video = document.createElement('video'); - - setControlsOnVideo(video, false); - expect(video.controls).toBeFalsy(); - }); - - it('should stop timer', () => { - const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); - hideMediaControlsTemporarily(video); - - expect(video._controlsHideTimer).toBeTruthy(); - expect(video._controlsHideTimer?.isRunning()).toBeTruthy(); - - setControlsOnVideo(video, false); - expect(video.controls).toBeFalsy(); - expect(video._controlsHideTimer).toBeFalsy(); - }); -}); - -describe('hideMediaControlsTemporarily', () => { - beforeAll(() => { - vi.useFakeTimers(); - }); - - afterAll(() => { - vi.useRealTimers(); - }); - - it('should set controls', () => { - const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); - video.controls = true; - hideMediaControlsTemporarily(video); - - expect(video.controls).toBeFalsy(); - vi.runOnlyPendingTimers(); - - expect(video.controls).toBeTruthy(); - expect(video._controlsHideTimer).toBeFalsy(); - }); - - it('should add event listener that resets controls', () => { - const video: AdvancedCameraCardHTMLVideoElement = document.createElement('video'); - video.controls = true; - - hideMediaControlsTemporarily(video); - expect(video.controls).toBeFalsy(); - - // After a new media starts to load, the controls should reset. - video.dispatchEvent(new Event('loadstart')); - expect(video.controls).toBeTruthy(); - - // ... but only once, future loadstart events without subsequent calls to - // hideMediaControlsTemporarily() should do nothing. - video.controls = false; - video.dispatchEvent(new Event('loadstart')); - expect(video._controlsHideTimer).toBeFalsy(); - }); -}); - -class NotAllowedError extends Error { - name = 'NotAllowedError'; -} - -describe('playMediaMutingIfNecessary', () => { - it('should play', async () => { - const player = mock(); - const video = mock(); - video.play.mockResolvedValue(); - await playMediaMutingIfNecessary(player, video); - expect(video.play).toBeCalled(); - }); - - it('should mute if not allowed to play and unmuted', async () => { - const player = mock(); - player.isMuted.mockReturnValue(false); - player.mute.mockResolvedValue(); - - const video = mock(); - video.play.mockRejectedValueOnce(new NotAllowedError()).mockResolvedValueOnce(); - - await playMediaMutingIfNecessary(player, video); - - expect(video.play).toBeCalledTimes(2); - expect(player.isMuted).toBeCalled(); - expect(player.mute).toBeCalled(); - }); - - it('should not mute if not allowed to play and already unmuted', async () => { - const player = mock(); - player.isMuted.mockReturnValue(true); - - const video = mock(); - video.play.mockRejectedValueOnce(new NotAllowedError()); - - await playMediaMutingIfNecessary(player, video); - - expect(video.play).toBeCalledTimes(1); - expect(player.isMuted).toBeCalled(); - expect(player.mute).not.toBeCalled(); - }); - - it('should ignore exception if subsequent play call throws', async () => { - const player = mock(); - player.isMuted.mockReturnValue(false); - - const video = mock(); - video.play.mockRejectedValue(new NotAllowedError()); - - await playMediaMutingIfNecessary(player, video); - - expect(video.play).toBeCalledTimes(2); - expect(player.isMuted).toBeCalled(); - expect(player.mute).toBeCalled(); - }); - - it('should ignore calls without a video', async () => { - const player = mock(); - player.isMuted.mockReturnValue(false); - - await playMediaMutingIfNecessary(player); - - expect(player.isMuted).not.toBeCalled(); - expect(player.mute).not.toBeCalled(); - }); -}); - -describe('constants', () => { - it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => { - expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2); - }); -}); diff --git a/tests/utils/screenshot.test.ts b/tests/utils/screenshot.test.ts index 89241a99..0bee00b6 100644 --- a/tests/utils/screenshot.test.ts +++ b/tests/utils/screenshot.test.ts @@ -1,12 +1,16 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { generateScreenshotTitle, screenshotMedia } from '../../src/utils/screenshot'; +import { + generateScreenshotTitle, + screenshotImage, + screenshotVideo, +} from '../../src/utils/screenshot'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { TestViewMedia, createView } from '../test-utils'; // @vitest-environment jsdom -describe('screenshotMedia', () => { +describe('screenshotVideo', () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -19,7 +23,7 @@ describe('screenshotMedia', () => { canvas.getContext = getContext; vi.spyOn(document, 'createElement').mockReturnValue(canvas); - expect(screenshotMedia(video)).toBeNull(); + expect(screenshotVideo(video)).toBeNull(); }); it('should screenshot', () => { @@ -31,7 +35,36 @@ describe('screenshotMedia', () => { canvas.toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64'); vi.spyOn(document, 'createElement').mockReturnValue(canvas); - expect(screenshotMedia(video)).toBe('data:image/jpeg;base64'); + expect(screenshotVideo(video)).toBe('data:image/jpeg;base64'); + }); +}); + +describe('screenshotImage', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not screenshot without context', () => { + const image = document.createElement('img'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(null); + canvas.getContext = getContext; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotImage(image)).toBeNull(); + }); + + it('should screenshot', () => { + const image = document.createElement('img'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(mock()); + canvas.getContext = getContext; + canvas.toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64'); + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotImage(image)).toBe('data:image/jpeg;base64'); }); });