diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index 9e39e06d..504b31f2 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -5,11 +5,21 @@ export class CachedValueController implements ReactiveController { protected _host: ReactiveControllerHost; protected _timerSeconds: number; protected _callback: () => T; + protected _timerStartCallback?: () => void; + protected _timerStopCallback?: () => void; protected _timerID?: number; - constructor(host: ReactiveControllerHost, timerSeconds: number, callback: () => T) { + constructor( + host: ReactiveControllerHost, + timerSeconds: number, + callback: () => T, + timerStartCallback?: () => void, + timerStopCallback?: () => void, + ) { this._timerSeconds = timerSeconds; this._callback = callback; + this._timerStartCallback = timerStartCallback; + this._timerStopCallback = timerStopCallback; (this._host = host).addController(this); } @@ -48,6 +58,7 @@ export class CachedValueController implements ReactiveController { public stopTimer(): void { if (this._timerID !== undefined) { window.clearInterval(this._timerID); + this._timerStopCallback?.(); } this._timerID = undefined; } @@ -59,6 +70,7 @@ export class CachedValueController implements ReactiveController { this.stopTimer(); if (this._timerSeconds > 0) { + this._timerStartCallback?.(); this._timerID = window.setInterval(() => { this.updateValue(); this._host.requestUpdate(); @@ -66,6 +78,10 @@ export class CachedValueController implements ReactiveController { } } + public hasTimer(): boolean { + return !!this._timerID; + } + /** * Host has connected to the cache. */ diff --git a/src/card.ts b/src/card.ts index 99d0b991..2be087c1 100644 --- a/src/card.ts +++ b/src/card.ts @@ -714,6 +714,34 @@ class FrigateCard extends LitElement { }); } + if (this._currentMediaLoadedInfo && this._currentMediaLoadedInfo.player) { + if (this._currentMediaLoadedInfo.capabilities?.supportsPause) { + const paused = this._currentMediaLoadedInfo.player.isPaused(); + buttons.push({ + icon: paused ? 'mdi:play' : 'mdi:pause', + ...this._getConfig().menu.buttons.play, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.play'), + tap_action: createFrigateCardCustomAction( + paused ? 'play' : 'pause', + ) as FrigateCardCustomAction, + }); + } + + if (this._currentMediaLoadedInfo.capabilities?.hasAudio) { + const muted = this._currentMediaLoadedInfo.player.isMuted(); + buttons.push({ + icon: muted ? 'mdi:volume-off' : 'mdi:volume-high', + ...this._getConfig().menu.buttons.mute, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.mute'), + tap_action: createFrigateCardCustomAction( + muted ? 'unmute' : 'mute', + ) as FrigateCardCustomAction, + }); + } + } + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(button), ...button, @@ -1633,6 +1661,18 @@ class FrigateCard extends LitElement { this.requestUpdate(); } break; + case 'mute': + this._currentMediaLoadedInfo?.player?.mute(); + break; + case 'unmute': + this._currentMediaLoadedInfo?.player?.unmute(); + break; + case 'play': + this._currentMediaLoadedInfo?.player?.play(); + break; + case 'pause': + this._currentMediaLoadedInfo?.player?.pause(); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } @@ -2157,6 +2197,15 @@ class FrigateCard extends LitElement { @frigate-card:view:change-context=${this._addViewContextHandler.bind(this)} @frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)} @frigate-card:media:unloaded=${this._mediaUnloadedHandler.bind(this)} + @frigate-card:media:volumechange=${ + () => this.requestUpdate() /* Refresh mute menu button */ + } + @frigate-card:media:play=${ + () => this.requestUpdate() /* Refresh play/pause menu button */ + } + @frigate-card:media:pause=${ + () => this.requestUpdate() /* Refresh play/pause menu button */ + } @frigate-card:render=${() => this.requestUpdate()} > ${renderMenuAbove ? this._renderMenu() : ''} diff --git a/src/components/image.ts b/src/components/image.ts index 9fe081de..880a55f5 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -6,7 +6,7 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS, + unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; @@ -20,13 +20,15 @@ import { CameraConfig, FrigateCardMediaPlayer, ImageViewConfig, - MediaLoadedInfo, + MediaLoadedInfo } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; import { isHassDifferent } from '../utils/ha'; import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent } from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { View } from '../view/view.js'; @@ -89,6 +91,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay // Not implemented. } + public isPaused(): boolean { + return !this._cachedValueController?.hasTimer() ?? true; + } + /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. @@ -143,6 +149,8 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay this, this.imageConfig.refresh_seconds, this._getImageSource.bind(this), + () => dispatchMediaPlayEvent(this), + () => dispatchMediaPauseEvent(this), ); } updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout); @@ -285,7 +293,12 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay ${ref(this._refImage)} src=${live(src)} @load=${(ev: Event) => { - const mediaLoadedInfo = createMediaLoadedInfo(ev, { player: this }); + const mediaLoadedInfo = createMediaLoadedInfo(ev, { + player: this, + capabilities: { + supportsPause: !!this.imageConfig?.refresh_seconds, + }, + }); // Avoid the media being reported as repeatedly loading unless the // media info changes. if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 27b8effd..32f23d2f 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -4,27 +4,33 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS, + unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { VideoRTC } from '../../external/go2rtc/video-rtc'; +import { localize } from '../../localize/localize'; import liveMSEStyle from '../../scss/live-go2rtc.scss'; import { CameraConfig, ExtendedHomeAssistant, FrigateCardMediaPlayer, - MicrophoneConfig, + MicrophoneConfig } from '../../types.js'; -import '../image.js'; +import { mayHaveAudio } from '../../utils/audio'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { hideMediaControlsTemporarily, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS } from '../../utils/media'; -import { dispatchMediaLoadedEvent } from '../../utils/media-info'; -import { localize } from '../../localize/localize'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent +} from '../../utils/media-info'; +import '../image.js'; import { dispatchErrorMessageEvent } from '../message'; -import { VideoRTC } from '../../external/go2rtc/video-rtc'; -import { CameraEndpoints } from '../../camera-manager/types.js'; -import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; // Note (2023-02-18): Depending on the behavior of the player / browser is // possible this URL will need to be re-signed in order to avoid HA spamming @@ -75,11 +81,7 @@ class FrigateCardGo2RTCPlayer extends VideoRTC { super.oninit(); if (this.video) { - const onloadeddata = this.video.onloadeddata; - this.video.onloadeddata = (e) => { - if (onloadeddata) { - onloadeddata.call(this.video, e); - } + this.video.onloadeddata = () => { hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); dispatchMediaLoadedEvent(this, this.video, { player: this._containingPlayer, @@ -89,10 +91,15 @@ class FrigateCardGo2RTCPlayer extends VideoRTC { // that can be created after the fact -- this is purely saying that // were a microphone stream available it could be used usefully. supports2WayAudio: !!this.pc, + supportsPause: true, + hasAudio: mayHaveAudio(this.video), }, }); }; - + this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this), + this.video.onplay = () => dispatchMediaPlayEvent(this), + this.video.onpause = () => dispatchMediaPauseEvent(this), + // Always started muted. Media may be unmuted in accordance with user // configuration. this.video.muted = true; @@ -233,6 +240,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } } + public isPaused(): boolean { + return this._player?.video.paused ?? true; + } + disconnectedCallback(): void { this._player = undefined; } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 7b7b758a..5816691e 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -47,6 +47,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla this._playerRef.value?.setControls(controls); } + public isPaused(): boolean { + return this._playerRef.value?.isPaused() ?? true; + } + protected render(): TemplateResult | void { if (!this.hass) { return; diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 136097f5..0c5df793 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -45,6 +45,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia await this._refImage.value?.setControls(controls); } + public isPaused(): boolean { + return this._refImage.value?.isPaused() ?? true; + } + protected render(): TemplateResult | void { if (!this.hass || !this.cameraConfig) { return; diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 7859e947..cc2510b9 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -2,6 +2,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; import { renderProgressIndicator } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; @@ -9,12 +10,15 @@ import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, - FrigateCardMediaPlayer, + FrigateCardMediaPlayer } from '../../types.js'; -import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; -import { dispatchErrorMessageEvent } from '../message.js'; -import { CameraEndpoints } from '../../camera-manager/types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent +} from '../../utils/media-info.js'; +import { dispatchErrorMessageEvent } from '../message.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -75,6 +79,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi // Not implemented. } + public isPaused(): boolean { + return this._jsmpegVideoPlayer?.player?.paused ?? true; + } + /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -113,10 +121,15 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi videoDecoded = true; dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, { player: this, + capabilities: { + supportsPause: true, + }, }); resolve(player); } }, + onPlay: () => dispatchMediaPlayEvent(this), + onPause: () => dispatchMediaPauseEvent(this), }, ); }); diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 5a4373ad..e69eb797 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -10,7 +10,12 @@ import { FrigateCardError, FrigateCardMediaPlayer, } from '../../types.js'; -import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent, +} from '../../utils/media-info.js'; import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; import { renderTask } from '../../utils/task.js'; import { @@ -18,6 +23,7 @@ import { MEDIA_LOAD_CONTROLS_HIDE_SECONDS, } from '../../utils/media.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; +import { mayHaveAudio } from '../../utils/audio.js'; // Create a wrapper for AlexxIT's WebRTC card // - https://github.com/AlexxIT/WebRTC @@ -80,6 +86,10 @@ export class FrigateCardLiveWebRTCCard } } + public isPaused(): boolean { + return this._getPlayer()?.paused ?? true; + } + connectedCallback(): void { super.connectedCallback(); @@ -178,15 +188,19 @@ export class FrigateCardLiveWebRTCCard this.updateComplete.then(() => { const video = this._getPlayer(); if (video) { - const onloadeddata = video.onloadeddata; - - video.onloadeddata = (e) => { - if (onloadeddata) { - onloadeddata.call(video, e); - } + video.onloadeddata = () => { hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); - dispatchMediaLoadedEvent(this, video, { player: this }); + dispatchMediaLoadedEvent(this, video, { + player: this, + capabilities: { + supportsPause: true, + hasAudio: mayHaveAudio(video), + }, + }); }; + video.onplay = () => dispatchMediaPlayEvent(this); + video.onpause = () => dispatchMediaPauseEvent(this); + video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); } }); } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index a5623dc3..d8ee6ffb 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -787,6 +787,10 @@ export class FrigateCardLiveProvider this._refProvider.value?.setControls(controls); } + public isPaused(): boolean { + return this._refProvider.value?.isPaused() ?? true; + } + /** * Get the fully resolved live provider. * @returns A live provider (that is not 'auto'). diff --git a/src/components/viewer.ts b/src/components/viewer.ts index f7bd42f5..bcd2cd9d 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -6,7 +6,7 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS, + unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; @@ -25,22 +25,23 @@ import { FrigateCardMediaPlayer, MediaLoadedInfo, TransitionEffect, - ViewerConfig, + ViewerConfig } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { mayHaveAudio } from '../utils/audio.js'; import { contentsChanged, errorToConsole } from '../utils/basic.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - playMediaMutingIfNecessary, + playMediaMutingIfNecessary } from '../utils/media.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @@ -52,7 +53,7 @@ import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel, + wrapMediaLoadedEventForCarousel } from './media-carousel.js'; import './next-prev-control.js'; import './surround.js'; @@ -610,6 +611,15 @@ export class FrigateCardViewerProvider } } + public isPaused(): boolean { + if (this._refFrigateCardMediaPlayer.value) { + return this._refFrigateCardMediaPlayer.value.isPaused(); + } else if (this._refVideoProvider.value) { + return this._refVideoProvider.value.paused; + } + return true; + } + /** * Dispatch a clip view that matches the current (snapshot) query. */ @@ -750,8 +760,17 @@ export class FrigateCardViewerProvider } }} @loadeddata=${(ev: Event) => { - dispatchMediaLoadedEvent(this, ev, { player: this }); + dispatchMediaLoadedEvent(this, ev, { + player: this, + capabilities: { + supportsPause: true, + hasAudio: mayHaveAudio(ev.target as HTMLVideoElement), + }, + }); }} + @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} + @play=${() => dispatchMediaPlayEvent(this)} + @pause=${() => dispatchMediaPauseEvent(this)} > ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 5d193951..4c07d494 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -277,6 +277,8 @@ "live": "Live", "media_player": "Send to media player", "microphone": "Microphone", + "mute": "Mute / Unmute", + "play": "Play / Pause", "priority": "Priority", "recordings": "Recordings", "snapshots": "Snapshots", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index f48f9668..9ef35b33 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -275,6 +275,8 @@ "image": "Immagine", "live": "Abitare", "media_player": "Invia a Media Player", + "mute": "", + "play": "", "priority": "Priorità", "snapshots": "Istantanee", "substreams": "Flusso/i secondario/i", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index e5bf41fe..bc1bcb36 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -276,6 +276,8 @@ "image": "Imagem", "live": "Ao vivo", "media_player": "Enviar para o reprodutor de mídia", + "mute": "", + "play": "", "priority": "Prioridade", "recordings": "Gravações", "snapshots": "Instantâneos", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f063e62b..88777a08 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -268,6 +268,8 @@ "image": "Imagem", "live": "Ao vivo", "media_player": "Enviar para o reprodutor de mídia", + "mute": "", + "play": "", "priority": "Prioridade", "snapshots": "Instantâneos", "substreams": "substreams", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 82dd6a49..140b94b6 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -77,6 +77,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { } } + public isPaused(): boolean { + return this._player?.isPaused() ?? true; + } + /** * Master render method. * @returns A rendered template. diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 72fd4720..b193c922 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,17 +9,23 @@ // available as compilation time. // ==================================================================== -import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; +import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; -import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; -import { - hideMediaControlsTemporarily, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, -} from '../utils/media.js'; import { FrigateCardMediaPlayer } from '../types.js'; +import { mayHaveAudio } from '../utils/audio.js'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent, +} from '../utils/media-info.js'; +import { + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + hideMediaControlsTemporarily, +} from '../utils/media.js'; customElements.whenDefined('ha-hls-player').then(() => { @customElement('frigate-card-ha-hls-player') @@ -73,6 +79,10 @@ customElements.whenDefined('ha-hls-player').then(() => { } } + public isPaused(): boolean { + return this._video?.paused ?? true; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts @@ -96,9 +106,18 @@ customElements.whenDefined('ha-hls-player').then(() => { @loadedmetadata=${() => { hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); }} - @loadeddata=${(e) => { - dispatchMediaLoadedEvent(this, e, { player: this }); + @loadeddata=${(ev) => { + dispatchMediaLoadedEvent(this, ev, { + player: this, + capabilities: { + supportsPause: true, + hasAudio: mayHaveAudio(this._video), + }, + }); }} + @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} + @play=${() => dispatchMediaPlayEvent(this)} + @pause=${() => dispatchMediaPauseEvent(this)} > `; } diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index bcc42d0d..2e55a135 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -15,7 +15,13 @@ import { query } from 'lit/decorators/query.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; -import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import { mayHaveAudio } from '../utils/audio.js'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent, +} from '../utils/media-info.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, @@ -72,6 +78,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { } } + public isPaused(): boolean { + return this._video?.paused ?? true; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts @@ -92,9 +102,18 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { @loadedmetadata=${() => { hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); }} - @loadeddata=${(e) => { - dispatchMediaLoadedEvent(this, e, { player: this }); + @loadeddata=${(ev) => { + dispatchMediaLoadedEvent(this, ev, { + player: this, + capabilities: { + supportsPause: true, + hasAudio: mayHaveAudio(this._video), + }, + }); }} + @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} + @play=${() => dispatchMediaPlayEvent(this)} + @pause=${() => dispatchMediaPauseEvent(this)} > `; } diff --git a/src/types.ts b/src/types.ts index db5a2c8c..d778fc7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -219,15 +219,19 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'image', 'live', 'menu_toggle', + 'mute', 'live_substream_on', 'live_substream_off', 'microphone_mute', 'microphone_unmute', + 'play', + 'pause', 'recording', 'recordings', 'snapshot', 'snapshots', 'timeline', + 'unmute', ] as const; const FRIGATE_CARD_ACTIONS = [ ...FRIGATE_CARD_GENERAL_ACTIONS, @@ -1069,6 +1073,8 @@ const menuConfigDefault = { ...hiddenButtonDefault, type: 'momentary' as const, }, + mute: hiddenButtonDefault, + play: hiddenButtonDefault, recordings: hiddenButtonDefault, }, button_size: 40, @@ -1113,6 +1119,8 @@ const menuConfigSchema = z }) .default(menuConfigDefault.buttons.microphone), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), + mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), + play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1473,6 +1481,8 @@ export interface ExtendedHomeAssistant extends HomeAssistant { export interface MediaLoadedCapabilities { supports2WayAudio?: boolean; + supportsPause?: boolean; + hasAudio?: boolean; } export interface MediaLoadedInfo { @@ -1517,6 +1527,7 @@ export interface FrigateCardMediaPlayer { isMuted(): boolean; seek(seconds: number): Promise; setControls(controls: boolean): Promise; + isPaused(): boolean; } export interface CardHelpers { diff --git a/src/utils/audio.ts b/src/utils/audio.ts new file mode 100644 index 00000000..840d0dc7 --- /dev/null +++ b/src/utils/audio.ts @@ -0,0 +1,19 @@ +export interface AudioProperties { + mozHasAudio?: boolean; + audioTracks?: unknown[]; +} + +// 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. +export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean => { + if (video.mozHasAudio !== undefined) { + return video.mozHasAudio; + } + if (video.audioTracks !== undefined) { + return Boolean(video.audioTracks?.length); + } + return true; +}; diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index 4c75e0d3..f6e2b0ad 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -69,6 +69,18 @@ export function dispatchMediaLoadedEvent( } } +export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:volumechange'); +} + +export function dispatchMediaPlayEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:play'); +} + +export function dispatchMediaPauseEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:pause'); +} + /** * Dispatch a pre-existing MediaLoadedInfo object as an event. * @param element The element to send the event. diff --git a/tests/cached-value-controller.test.ts b/tests/cached-value-controller.test.ts new file mode 100644 index 00000000..29714a7b --- /dev/null +++ b/tests/cached-value-controller.test.ts @@ -0,0 +1,109 @@ +import { ReactiveControllerHost } from 'lit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CachedValueController } from '../src/cached-value-controller'; + +// @vitest-environment jsdom +describe('CachedValueController', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should construct', () => { + const host = mock(); + const callback = vi.fn(); + const controller = new CachedValueController(host, 10, callback); + + expect(controller).toBeTruthy(); + }); + + it('should remove host', () => { + const host = mock(); + const callback = vi.fn(); + const controller = new CachedValueController(host, 10, callback); + + controller.removeController(); + expect(host.removeController).toBeCalled(); + }); + + it('should have timer', () => { + const host = mock(); + const callback = vi.fn(); + const startCallback = vi.fn(); + const stopCallback = vi.fn(); + + vi.useFakeTimers(); + + const controller = new CachedValueController( + host, + 10, + callback, + startCallback, + stopCallback, + ); + + controller.startTimer(); + expect(startCallback).toBeCalled(); + + callback.mockReturnValue(3); + vi.runOnlyPendingTimers(); + expect(callback).toBeCalled(); + expect(host.requestUpdate).toBeCalled(); + expect(controller.value).toBe(3); + + callback.mockReturnValue(4); + vi.runOnlyPendingTimers(); + expect(callback).toBeCalled(); + expect(host.requestUpdate).toBeCalled(); + expect(controller.value).toBe(4); + + expect(controller.hasTimer()).toBeTruthy(); + + controller.stopTimer(); + expect(stopCallback).toBeCalled(); + + callback.mockReset(); + vi.runOnlyPendingTimers(); + expect(callback).not.toBeCalled(); + }); + + it('should clear value', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue(42); + + vi.useFakeTimers(); + + const controller = new CachedValueController(host, 10, callback); + controller.startTimer(); + + vi.runOnlyPendingTimers(); + expect(controller.value).equal(42); + + controller.clearValue(); + expect(controller.value).toBeUndefined(); + }); + + it('should connect and disconnect host', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue(43); + const startCallback = vi.fn(); + const stopCallback = vi.fn(); + + const controller = new CachedValueController( + host, + 10, + callback, + startCallback, + stopCallback, + ); + + controller.hostConnected(); + expect(controller.value).equal(43); + expect(startCallback).toBeCalled(); + expect(host.requestUpdate).toBeCalled(); + + controller.hostDisconnected(); + expect(controller.value).toBeUndefined(); + expect(stopCallback).toBeCalled(); + }); +}); diff --git a/tests/utils/audio.test.ts b/tests/utils/audio.test.ts new file mode 100644 index 00000000..c4901023 --- /dev/null +++ b/tests/utils/audio.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { AudioProperties, mayHaveAudio } from '../../src/utils/audio'; + +// @vitest-environment jsdom +describe('mayHaveAudio', () => { + it('should detect audio when mozHasAudio true', () => { + const element: HTMLVideoElement & AudioProperties = document.createElement('video'); + element.mozHasAudio = true; + expect(mayHaveAudio(element)).toBeTruthy(); + }); + + it('should not detect audio when mozHasAudio undefined', () => { + const element: HTMLVideoElement & AudioProperties = document.createElement('video'); + element.mozHasAudio = undefined; + expect(mayHaveAudio(element)).toBeFalsy(); + }); + + it('should detect audio when audioTracks has length', () => { + // Workaround: "Cannot set property audioTracks of # which has only a getter" + const element = {} as HTMLVideoElement & AudioProperties; + element.audioTracks = [1, 2, 3]; + expect(mayHaveAudio(element)).toBeTruthy(); + }); + + it('should not detect audio when audioTracks has no length', () => { + // Workaround: "Cannot set property audioTracks of # which has only a getter" + const element = {} as HTMLVideoElement & AudioProperties; + element.audioTracks = []; + expect(mayHaveAudio(element)).toBeFalsy(); + }); + + it('should detect audio when no evidence to the contrary', () => { + const element = {} as HTMLVideoElement & AudioProperties; + expect(mayHaveAudio(element)).toBeTruthy(); + }); +});