Add play/pause/mute/unmute Frigate card commands.

This commit is contained in:
Dermot Duffy
2023-05-11 20:25:40 -07:00
parent 2e5b86d6c9
commit 8365b43c04
23 changed files with 435 additions and 49 deletions
+17 -1
View File
@@ -5,11 +5,21 @@ export class CachedValueController<T> implements ReactiveController {
protected _host: ReactiveControllerHost; protected _host: ReactiveControllerHost;
protected _timerSeconds: number; protected _timerSeconds: number;
protected _callback: () => T; protected _callback: () => T;
protected _timerStartCallback?: () => void;
protected _timerStopCallback?: () => void;
protected _timerID?: number; 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._timerSeconds = timerSeconds;
this._callback = callback; this._callback = callback;
this._timerStartCallback = timerStartCallback;
this._timerStopCallback = timerStopCallback;
(this._host = host).addController(this); (this._host = host).addController(this);
} }
@@ -48,6 +58,7 @@ export class CachedValueController<T> implements ReactiveController {
public stopTimer(): void { public stopTimer(): void {
if (this._timerID !== undefined) { if (this._timerID !== undefined) {
window.clearInterval(this._timerID); window.clearInterval(this._timerID);
this._timerStopCallback?.();
} }
this._timerID = undefined; this._timerID = undefined;
} }
@@ -59,6 +70,7 @@ export class CachedValueController<T> implements ReactiveController {
this.stopTimer(); this.stopTimer();
if (this._timerSeconds > 0) { if (this._timerSeconds > 0) {
this._timerStartCallback?.();
this._timerID = window.setInterval(() => { this._timerID = window.setInterval(() => {
this.updateValue(); this.updateValue();
this._host.requestUpdate(); this._host.requestUpdate();
@@ -66,6 +78,10 @@ export class CachedValueController<T> implements ReactiveController {
} }
} }
public hasTimer(): boolean {
return !!this._timerID;
}
/** /**
* Host has connected to the cache. * Host has connected to the cache.
*/ */
+49
View File
@@ -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) => ({ const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(button), style: this._getStyleFromActions(button),
...button, ...button,
@@ -1633,6 +1661,18 @@ class FrigateCard extends LitElement {
this.requestUpdate(); this.requestUpdate();
} }
break; 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: default:
console.warn(`Frigate card received unknown card action: ${action}`); 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:view:change-context=${this._addViewContextHandler.bind(this)}
@frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)} @frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)}
@frigate-card:media:unloaded=${this._mediaUnloadedHandler.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()} @frigate-card:render=${() => this.requestUpdate()}
> >
${renderMenuAbove ? this._renderMenu() : ''} ${renderMenuAbove ? this._renderMenu() : ''}
+16 -3
View File
@@ -6,7 +6,7 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js'; import { live } from 'lit/directives/live.js';
@@ -20,13 +20,15 @@ import {
CameraConfig, CameraConfig,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
ImageViewConfig, ImageViewConfig,
MediaLoadedInfo, MediaLoadedInfo
} from '../types.js'; } from '../types.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import { isHassDifferent } from '../utils/ha'; import { isHassDifferent } from '../utils/ha';
import { import {
createMediaLoadedInfo, createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
@@ -89,6 +91,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
// Not implemented. // Not implemented.
} }
public isPaused(): boolean {
return !this._cachedValueController?.hasTimer() ?? true;
}
/** /**
* Get the camera entity for the current camera configuration. * Get the camera entity for the current camera configuration.
* @returns The entity or undefined if no camera entity is available. * @returns The entity or undefined if no camera entity is available.
@@ -143,6 +149,8 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
this, this,
this.imageConfig.refresh_seconds, this.imageConfig.refresh_seconds,
this._getImageSource.bind(this), this._getImageSource.bind(this),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
); );
} }
updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout); updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout);
@@ -285,7 +293,12 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
${ref(this._refImage)} ${ref(this._refImage)}
src=${live(src)} src=${live(src)}
@load=${(ev: Event) => { @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 // Avoid the media being reported as repeatedly loading unless the
// media info changes. // media info changes.
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
+26 -15
View File
@@ -4,27 +4,33 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; 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 liveMSEStyle from '../../scss/live-go2rtc.scss';
import { import {
CameraConfig, CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MicrophoneConfig, MicrophoneConfig
} from '../../types.js'; } from '../../types.js';
import '../image.js'; import { mayHaveAudio } from '../../utils/audio';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS
} from '../../utils/media'; } from '../../utils/media';
import { dispatchMediaLoadedEvent } from '../../utils/media-info'; import {
import { localize } from '../../localize/localize'; dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent
} from '../../utils/media-info';
import '../image.js';
import { dispatchErrorMessageEvent } from '../message'; 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 // 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 // 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(); super.oninit();
if (this.video) { if (this.video) {
const onloadeddata = this.video.onloadeddata; this.video.onloadeddata = () => {
this.video.onloadeddata = (e) => {
if (onloadeddata) {
onloadeddata.call(this.video, e);
}
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
dispatchMediaLoadedEvent(this, this.video, { dispatchMediaLoadedEvent(this, this.video, {
player: this._containingPlayer, player: this._containingPlayer,
@@ -89,10 +91,15 @@ class FrigateCardGo2RTCPlayer extends VideoRTC {
// that can be created after the fact -- this is purely saying that // that can be created after the fact -- this is purely saying that
// were a microphone stream available it could be used usefully. // were a microphone stream available it could be used usefully.
supports2WayAudio: !!this.pc, 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 // Always started muted. Media may be unmuted in accordance with user
// configuration. // configuration.
this.video.muted = true; 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 { disconnectedCallback(): void {
this._player = undefined; this._player = undefined;
} }
+4
View File
@@ -47,6 +47,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
this._playerRef.value?.setControls(controls); this._playerRef.value?.setControls(controls);
} }
public isPaused(): boolean {
return this._playerRef.value?.isPaused() ?? true;
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass) { if (!this.hass) {
return; return;
+4
View File
@@ -45,6 +45,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
await this._refImage.value?.setControls(controls); await this._refImage.value?.setControls(controls);
} }
public isPaused(): boolean {
return this._refImage.value?.isPaused() ?? true;
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) { if (!this.hass || !this.cameraConfig) {
return; return;
+17 -4
View File
@@ -2,6 +2,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { renderProgressIndicator } from '../../components/message.js'; import { renderProgressIndicator } from '../../components/message.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
@@ -9,12 +10,15 @@ import {
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardMediaPlayer, FrigateCardMediaPlayer
} from '../../types.js'; } 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 { 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. // Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -75,6 +79,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
// Not implemented. // Not implemented.
} }
public isPaused(): boolean {
return this._jsmpegVideoPlayer?.player?.paused ?? true;
}
/** /**
* Create a JSMPEG player. * Create a JSMPEG player.
* @param url The URL for the player to connect to. * @param url The URL for the player to connect to.
@@ -113,10 +121,15 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
videoDecoded = true; videoDecoded = true;
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, { dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, {
player: this, player: this,
capabilities: {
supportsPause: true,
},
}); });
resolve(player); resolve(player);
} }
}, },
onPlay: () => dispatchMediaPlayEvent(this),
onPause: () => dispatchMediaPauseEvent(this),
}, },
); );
}); });
+22 -8
View File
@@ -10,7 +10,12 @@ import {
FrigateCardError, FrigateCardError,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
} from '../../types.js'; } 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 { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
import { renderTask } from '../../utils/task.js'; import { renderTask } from '../../utils/task.js';
import { import {
@@ -18,6 +23,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../../utils/media.js'; } from '../../utils/media.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { mayHaveAudio } from '../../utils/audio.js';
// Create a wrapper for AlexxIT's WebRTC card // Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC // - https://github.com/AlexxIT/WebRTC
@@ -80,6 +86,10 @@ export class FrigateCardLiveWebRTCCard
} }
} }
public isPaused(): boolean {
return this._getPlayer()?.paused ?? true;
}
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -178,15 +188,19 @@ export class FrigateCardLiveWebRTCCard
this.updateComplete.then(() => { this.updateComplete.then(() => {
const video = this._getPlayer(); const video = this._getPlayer();
if (video) { if (video) {
const onloadeddata = video.onloadeddata; video.onloadeddata = () => {
video.onloadeddata = (e) => {
if (onloadeddata) {
onloadeddata.call(video, e);
}
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); 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);
} }
}); });
} }
+4
View File
@@ -787,6 +787,10 @@ export class FrigateCardLiveProvider
this._refProvider.value?.setControls(controls); this._refProvider.value?.setControls(controls);
} }
public isPaused(): boolean {
return this._refProvider.value?.isPaused() ?? true;
}
/** /**
* Get the fully resolved live provider. * Get the fully resolved live provider.
* @returns A live provider (that is not 'auto'). * @returns A live provider (that is not 'auto').
+26 -7
View File
@@ -6,7 +6,7 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js'; import { guard } from 'lit/directives/guard.js';
@@ -25,22 +25,23 @@ import {
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MediaLoadedInfo, MediaLoadedInfo,
TransitionEffect, TransitionEffect,
ViewerConfig, ViewerConfig
} from '../types.js'; } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { mayHaveAudio } from '../utils/audio.js';
import { contentsChanged, errorToConsole } from '../utils/basic.js'; import { contentsChanged, errorToConsole } from '../utils/basic.js';
import { canonicalizeHAURL } from '../utils/ha/index.js'; import { canonicalizeHAURL } from '../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.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 { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { import {
changeViewToRecentEventsForCameraAndDependents, changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents
} from '../utils/media-to-view.js'; } from '../utils/media-to-view.js';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
playMediaMutingIfNecessary, playMediaMutingIfNecessary
} from '../utils/media.js'; } from '../utils/media.js';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-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 { Lazyload } from './embla-plugins/lazyload.js';
import { import {
FrigateCardMediaCarousel, FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel, wrapMediaLoadedEventForCarousel
} from './media-carousel.js'; } from './media-carousel.js';
import './next-prev-control.js'; import './next-prev-control.js';
import './surround.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. * Dispatch a clip view that matches the current (snapshot) query.
*/ */
@@ -750,8 +760,17 @@ export class FrigateCardViewerProvider
} }
}} }}
@loadeddata=${(ev: Event) => { @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)}
> >
<source <source
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''} src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
+2
View File
@@ -1782,6 +1782,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ label: localize('config.menu.buttons.type') }, { label: localize('config.menu.buttons.type') },
)}`, )}`,
)} )}
${this._renderMenuButton('play')}
${this._renderMenuButton('mute')}
</div> </div>
` `
: ''} : ''}
+2
View File
@@ -277,6 +277,8 @@
"live": "Live", "live": "Live",
"media_player": "Send to media player", "media_player": "Send to media player",
"microphone": "Microphone", "microphone": "Microphone",
"mute": "Mute / Unmute",
"play": "Play / Pause",
"priority": "Priority", "priority": "Priority",
"recordings": "Recordings", "recordings": "Recordings",
"snapshots": "Snapshots", "snapshots": "Snapshots",
+2
View File
@@ -275,6 +275,8 @@
"image": "Immagine", "image": "Immagine",
"live": "Abitare", "live": "Abitare",
"media_player": "Invia a Media Player", "media_player": "Invia a Media Player",
"mute": "",
"play": "",
"priority": "Priorità", "priority": "Priorità",
"snapshots": "Istantanee", "snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i", "substreams": "Flusso/i secondario/i",
+2
View File
@@ -276,6 +276,8 @@
"image": "Imagem", "image": "Imagem",
"live": "Ao vivo", "live": "Ao vivo",
"media_player": "Enviar para o reprodutor de mídia", "media_player": "Enviar para o reprodutor de mídia",
"mute": "",
"play": "",
"priority": "Prioridade", "priority": "Prioridade",
"recordings": "Gravações", "recordings": "Gravações",
"snapshots": "Instantâneos", "snapshots": "Instantâneos",
+2
View File
@@ -268,6 +268,8 @@
"image": "Imagem", "image": "Imagem",
"live": "Ao vivo", "live": "Ao vivo",
"media_player": "Enviar para o reprodutor de mídia", "media_player": "Enviar para o reprodutor de mídia",
"mute": "",
"play": "",
"priority": "Prioridade", "priority": "Prioridade",
"snapshots": "Instantâneos", "snapshots": "Instantâneos",
"substreams": "substreams", "substreams": "substreams",
+4
View File
@@ -77,6 +77,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
} }
} }
public isPaused(): boolean {
return this._player?.isPaused() ?? true;
}
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
+27 -8
View File
@@ -9,17 +9,23 @@
// available as compilation time. // 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 { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; 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 { 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(() => { customElements.whenDefined('ha-hls-player').then(() => {
@customElement('frigate-card-ha-hls-player') @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: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts // - 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=${() => { @loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}} }}
@loadeddata=${(e) => { @loadeddata=${(ev) => {
dispatchMediaLoadedEvent(this, e, { player: this }); dispatchMediaLoadedEvent(this, ev, {
player: this,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
});
}} }}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
></video> ></video>
`; `;
} }
+22 -3
View File
@@ -15,7 +15,13 @@ import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js'; 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 { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, 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: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts // - 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=${() => { @loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}} }}
@loadeddata=${(e) => { @loadeddata=${(ev) => {
dispatchMediaLoadedEvent(this, e, { player: this }); dispatchMediaLoadedEvent(this, ev, {
player: this,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(this._video),
},
});
}} }}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
></video> ></video>
`; `;
} }
+11
View File
@@ -219,15 +219,19 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image', 'image',
'live', 'live',
'menu_toggle', 'menu_toggle',
'mute',
'live_substream_on', 'live_substream_on',
'live_substream_off', 'live_substream_off',
'microphone_mute', 'microphone_mute',
'microphone_unmute', 'microphone_unmute',
'play',
'pause',
'recording', 'recording',
'recordings', 'recordings',
'snapshot', 'snapshot',
'snapshots', 'snapshots',
'timeline', 'timeline',
'unmute',
] as const; ] as const;
const FRIGATE_CARD_ACTIONS = [ const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_GENERAL_ACTIONS, ...FRIGATE_CARD_GENERAL_ACTIONS,
@@ -1069,6 +1073,8 @@ const menuConfigDefault = {
...hiddenButtonDefault, ...hiddenButtonDefault,
type: 'momentary' as const, type: 'momentary' as const,
}, },
mute: hiddenButtonDefault,
play: hiddenButtonDefault,
recordings: hiddenButtonDefault, recordings: hiddenButtonDefault,
}, },
button_size: 40, button_size: 40,
@@ -1113,6 +1119,8 @@ const menuConfigSchema = z
}) })
.default(menuConfigDefault.buttons.microphone), .default(menuConfigDefault.buttons.microphone),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
}) })
.default(menuConfigDefault.buttons), .default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1473,6 +1481,8 @@ export interface ExtendedHomeAssistant extends HomeAssistant {
export interface MediaLoadedCapabilities { export interface MediaLoadedCapabilities {
supports2WayAudio?: boolean; supports2WayAudio?: boolean;
supportsPause?: boolean;
hasAudio?: boolean;
} }
export interface MediaLoadedInfo { export interface MediaLoadedInfo {
@@ -1517,6 +1527,7 @@ export interface FrigateCardMediaPlayer {
isMuted(): boolean; isMuted(): boolean;
seek(seconds: number): Promise<void>; seek(seconds: number): Promise<void>;
setControls(controls: boolean): Promise<void>; setControls(controls: boolean): Promise<void>;
isPaused(): boolean;
} }
export interface CardHelpers { export interface CardHelpers {
+19
View File
@@ -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;
};
+12
View File
@@ -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. * Dispatch a pre-existing MediaLoadedInfo object as an event.
* @param element The element to send the event. * @param element The element to send the event.
+109
View File
@@ -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<ReactiveControllerHost>();
const callback = vi.fn();
const controller = new CachedValueController(host, 10, callback);
expect(controller).toBeTruthy();
});
it('should remove host', () => {
const host = mock<ReactiveControllerHost>();
const callback = vi.fn();
const controller = new CachedValueController(host, 10, callback);
controller.removeController();
expect(host.removeController).toBeCalled();
});
it('should have timer', () => {
const host = mock<ReactiveControllerHost>();
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<ReactiveControllerHost>();
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<ReactiveControllerHost>();
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();
});
});
+36
View File
@@ -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 #<HTMLMediaElement> 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 #<HTMLMediaElement> 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();
});
});