From 66ae262654ec378eda102949330bd980538ab73e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 29 Oct 2021 16:36:36 -0700 Subject: [PATCH] Implement downloading and correct MediaShow events in carousel. --- src/browse-media-util.ts | 126 +++++++++++++++++++ src/card.ts | 115 ++++++++++++++---- src/common.ts | 208 ++++++++++++++++++++------------ src/components/elements.ts | 12 +- src/components/gallery.ts | 19 ++- src/components/image.ts | 4 +- src/components/live.ts | 33 +++-- src/components/viewer.ts | 183 +++++++++++++++++----------- src/localize/languages/en.json | 8 +- src/patches/ha-camera-stream.ts | 4 +- src/patches/ha-hls-player.ts | 4 +- src/types.ts | 119 ++++++++++-------- src/view.ts | 22 ++-- 13 files changed, 582 insertions(+), 275 deletions(-) create mode 100644 src/browse-media-util.ts diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts new file mode 100644 index 00000000..56ac0079 --- /dev/null +++ b/src/browse-media-util.ts @@ -0,0 +1,126 @@ +import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant } from './types.js'; +import { HomeAssistant } from 'custom-card-helpers'; +import { homeAssistantWSRequest } from './common.js'; +import { browseMediaSourceSchema } from './types.js'; + +import dayjs from 'dayjs'; +import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; + +dayjs.extend(dayjs_custom_parse_format); + +export class BrowseMediaUtil { + /** + * Return the Frigate event_id given a BrowseMediaSource object. + * @param media The event to extract the id from. + * @returns The `event_id` or `null` if not successfully parsed. + */ + static extractEventID(media: BrowseMediaSource): string | null { + const result = media.media_content_id.match( + /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/); + return result && result.groups ? result.groups['id'] : null; + } + + /** + * Return the event start time given a BrowseMediaSource object. + * @param browseMedia The media object to extract the start time from. + * @returns The start time in unix/epoch time, or null if it cannot be determined. + */ + static extractEventStartTime( + browseMedia: BrowseMediaSource, + ): number | null { + // Example: 2021-08-27 20:57:22 [10s, Person 76%] + const result = browseMedia.title.match(/^(?.+) \[/); + if (result && result.groups) { + const iso_datetime_str = result.groups['iso_datetime']; + if (iso_datetime_str) { + const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); + if (iso_datetime.isValid()) { + return iso_datetime.unix(); + } + } + } + return null; + } + + /** + * Determine if a BrowseMediaSource object is truly a media item (vs a folder). + * @param media The media object. + * @returns `true` if it's truly a media item, `false` otherwise. + */ + static isTrueMedia(media: BrowseMediaSource): boolean { + return !media.can_expand; + } + + /** + * From a BrowseMediaSource item extract the first true media item from the + * children (i.e. a clip/snapshot, not a folder). + * @param media The media object with children. + * @returns The first true media item found. + */ + static getFirstTrueMediaChildIndex( + media: BrowseMediaSource | null, + ): number | null { + if (!media || !media.children) { + return null; + } + for (let i = 0; i < media.children.length; i++) { + if (this.isTrueMedia(media.children[i])) { + return i; + } + } + return null; + } + + // + + /** + * Browse Frigate media with a media content id. May throw. + * @param hass The HomeAssistant object. + * @param media_content_id The media content id to browse. + * @returns A BrowseMediaSource object or null on malformed. + */ + static async browseMedia( + hass: (HomeAssistant & ExtendedHomeAssistant) | null, + media_content_id: string, + ): Promise { + if (!hass) { + return null; + } + const request = { + type: 'media_source/browse_media', + media_content_id: media_content_id, + }; + return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); + } + + // Browse Frigate media with query parameters. + + /** + * Browse Frigate media with a media query. May throw. + * @param hass The HomeAssistant object. + * @param params The search parameters to use to search for media. + * @returns A BrowseMediaSource object or null on malformed. + */ + static async browseMediaQuery( + hass: HomeAssistant & ExtendedHomeAssistant, + params: BrowseMediaQueryParameters, + ): Promise { + return this.browseMedia( + hass, + // Defined in: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py + [ + 'media-source://frigate', + params.clientId, + 'event-search', + params.mediaType, + '', // Name/Title to render (not necessary here) + params.after ? String(params.after) : '', + params.before ? String(params.before) : '', + params.cameraName, + params.label, + params.zone, + ].join('/'), + ); + } +} diff --git a/src/card.ts b/src/card.ts index 8d5b34f8..dad591a2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -26,7 +26,7 @@ import type { Entity, ExtendedHomeAssistant, FrigateCardConfig, - MediaLoadInfo, + MediaShowInfo, MenuButton, Message, } from './types.js'; @@ -35,7 +35,12 @@ import { CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu.js'; import { View } from './view.js'; -import { homeAssistantWSRequest, shouldUpdateBasedOnHass } from './common.js'; +import { + homeAssistantSignPath, + homeAssistantWSRequest, + isValidMediaShowInfo, + shouldUpdateBasedOnHass, +} from './common.js'; import { localize } from './localize/localize.js'; import { renderMessage, renderProgressIndicator } from './components/message.js'; @@ -52,9 +57,7 @@ import './patches/ha-hls-player.js'; import cardStyle from './scss/card.scss'; import { ResolvedMediaCache } from './resolved-media.js'; - -const MEDIA_HEIGHT_CUTOFF = 50; -const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; +import { BrowseMediaUtil } from './browse-media-util.js'; /** A note on media callbacks: * @@ -118,7 +121,7 @@ export class FrigateCard extends LitElement { protected _entitiesToMonitor: string[] = []; // Information about the most recently loaded media item. - protected _mediaInfo: MediaLoadInfo | null = null; + protected _mediaShowInfo: MediaShowInfo | null = null; // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; @@ -215,6 +218,14 @@ export class FrigateCard extends LitElement { emphasize: this._view.is('image'), }); } + if (this._view.isViewerView() && (this.config.menu_buttons?.download ?? true)) { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'download', + title: localize('menu.download'), + icon: 'mdi:download', + }); + } if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { buttons.push({ type: 'internal-menu-icon', @@ -415,6 +426,57 @@ export class FrigateCard extends LitElement { return true; } + protected async _downloadViewerMedia(): Promise { + if (!this._hass || !this._view.isViewerView()) { + // Should not occur. + return; + } + + if (!this._view.media) { + this._setMessageAndUpdate({ + message: localize('error.download_no_media'), + type: 'error', + }) + return; + } + const event_id = BrowseMediaUtil.extractEventID(this._view.media); + if (!event_id) { + this._setMessageAndUpdate({ + message: localize('error.download_no_event_id'), + type: 'error', + }) + return; + } + + const path = + `/api/frigate/${this.config.frigate_client_id}` + + `/notifications/${event_id}/` + + `${this._view.isClipRelatedView() ? 'clip.mp4': 'snapshot.jpg'}` + + `?download=true`; + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(this._hass, path); + } catch (e) { + console.error(e, (e as Error).stack); + } + + if (!response) { + this._setMessageAndUpdate({ + message: localize('error.download_sign_failed'), + type: 'error', + }) + return; + } + + // Use the HTML5 download attribute to prevent a new window from temporarily + // opening. + const link = document.createElement('a'); + link.setAttribute('download', ''); + link.href = response; + link.click(); + link.remove(); + } + protected _menuActionHandler(action: string, button: MenuButton): void { if (button.type != 'internal-menu-icon') { handleAction(this, this._hass as HomeAssistant, button, action); @@ -431,6 +493,9 @@ export class FrigateCard extends LitElement { case 'snapshots': this._changeView(new View({ view: button.card_action })); break; + case 'download': + this._downloadViewerMedia(); + break; case 'frigate_ui': const frigate_url = this._getFrigateURLFromContext(); if (frigate_url) { @@ -537,23 +602,24 @@ export class FrigateCard extends LitElement { return this._setMessageAndUpdate(e.detail); } - protected _mediaLoadHandler(e: CustomEvent): void { - const mediaInfo = e.detail; + protected _mediaShowHandler(e: CustomEvent): void { + const mediaShowInfo = e.detail; // In Safari, with WebRTC, 0x0 is occasionally returned during loading, // so treat anything less than a safety cutoff as bogus. - if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) { + if (!isValidMediaShowInfo(mediaShowInfo)) { return; } + console.info(`Media show: ${JSON.stringify(mediaShowInfo)}`); let requestRefresh = false; if ( this._isAspectRatioEnforced() && - (mediaInfo.width != this._mediaInfo?.width || - mediaInfo.height != this._mediaInfo?.height) + (mediaShowInfo.width != this._mediaShowInfo?.width || + mediaShowInfo.height != this._mediaShowInfo?.height) ) { requestRefresh = true; } - this._mediaInfo = mediaInfo; + this._mediaShowInfo = mediaShowInfo; if (requestRefresh) { this.requestUpdate(); } @@ -599,8 +665,8 @@ export class FrigateCard extends LitElement { } const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; - if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) { - return (this._mediaInfo.height / this._mediaInfo.width) * 100; + if (aspect_ratio_mode == 'dynamic' && this._mediaShowInfo) { + return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100; } const default_aspect_ratio = this.config.dimensions?.aspect_ratio; @@ -643,8 +709,8 @@ export class FrigateCard extends LitElement { screenfull.isEnabled && screenfull.isFullscreen && this._view.isMediaView() && - this._mediaInfo && - this._mediaInfo.width / this._mediaInfo.height < + this._mediaShowInfo && + this._mediaShowInfo.width / this._mediaShowInfo.height < window.innerWidth / window.innerHeight ) { // If the menu is outside the media (i.e. above/below) allow space for it. @@ -652,7 +718,7 @@ export class FrigateCard extends LitElement { ? MENU_HEIGHT : 0; innerStyle['max-width'] = `calc(${ - (100 * this._mediaInfo.width) / this._mediaInfo.height + (100 * this._mediaShowInfo.width) / this._mediaShowInfo.height }vh - ${allowance}px )`; } @@ -703,8 +769,7 @@ export class FrigateCard extends LitElement { hidden: this.config.live_preload && !this._view.isGalleryView(), }; const viewerClasses = { - hidden: - this.config.live_preload && !['clip', 'snapshot'].includes(this._view.view), + hidden: this.config.live_preload && !this._view.isViewerView(), }; const liveClasses = { hidden: this.config.live_preload && this._view.view != 'live', @@ -720,7 +785,7 @@ export class FrigateCard extends LitElement { ? html` ` @@ -736,7 +801,7 @@ export class FrigateCard extends LitElement { > ` : ``} - ${!this._message && (this._view.is('clip') || this._view.is('snapshot')) + ${!this._message && this._view.isViewerView() ? html` (error: z.ZodError): string[] { const errors = error.format(); return Object.keys(errors).filter((v) => !v.startsWith('_')); } +/** + * Make a HomeAssistant websocket request. May throw. + * @param hass The HomeAssistant object to send the request with. + * @param schema The expected Zod schema of the response. + * @param request The request to make. + * @returns The parsed valid response or null on malformed. + */ export async function homeAssistantWSRequest( hass: HomeAssistant & ExtendedHomeAssistant, schema: ZodSchema, @@ -43,66 +57,41 @@ export async function homeAssistantWSRequest( return parseResult.data; } -export function isTrueMedia(media: BrowseMediaSource): boolean { - return !media.can_expand; -} - -// From a BrowseMediaSource item extract the first true media item (i.e. a -// clip/snapshot, not a folder). -export function getFirstTrueMediaChildIndex( - media: BrowseMediaSource | null, -): number | null { - if (!media || !media.children) { - return null; - } - for (let i = 0; i < media.children.length; i++) { - if (isTrueMedia(media.children[i])) { - return i; - } - } - return null; -} - -// Browse Frigate media with a media content id. -export async function browseMedia( - hass: (HomeAssistant & ExtendedHomeAssistant) | null, - media_content_id: string, -): Promise { - if (!hass) { - return null; - } - const request = { - type: 'media_source/browse_media', - media_content_id: media_content_id, - }; - return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); -} - -// Browse Frigate media with query parameters. -export async function browseMediaQuery( +/** + * Request that HA sign a path. May throw. + * @param hass The HomeAssistant object used to request the signature. + * @param path The path to sign. + * @param expires An optional number of seconds to sign the path for. + * @returns The signed URL, or null if the response was malformed. + */ +export async function homeAssistantSignPath( hass: HomeAssistant & ExtendedHomeAssistant, - params: BrowseMediaQueryParameters, -): Promise { - return browseMedia( + path: string, + expires?: number, +): Promise { + const request = { + type: 'auth/sign_path', + path: path, + expires: expires, + }; + const response = await homeAssistantWSRequest( hass, - // Defined in: - // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py - [ - 'media-source://frigate', - params.clientId, - 'event-search', - params.mediaType, - '', // Name/Title to render (not necessary here) - params.after ? String(params.after) : '', - params.before ? String(params.before) : '', - params.cameraName, - params.label, - params.zone, - ].join('/'), + signedPathSchema, + request, ); + if (!response) { + return null; + } + return hass.hassUrl(response.path); } -export function dispatchEvent(element: HTMLElement, name: string, detail?: T): void { +/** + * Dispatch a Frigate Card event. + * @param element The element to send the event. + * @param name The name of the Frigate card event to send. + * @param detail An optional detail object to attach. + */ +export function dispatchFrigateCardEvent(element: HTMLElement, name: string, detail?: T): void { element.dispatchEvent( new CustomEvent(`frigate-card:${name}`, { bubbles: true, @@ -112,18 +101,28 @@ export function dispatchEvent(element: HTMLElement, name: string, detail?: T) ); } +/** + * Dispatch a Frigate card play event. + * @param element The element to send the event. + */ export function dispatchPlayEvent(element: HTMLElement): void { - dispatchEvent(element, 'play'); + dispatchFrigateCardEvent(element, 'play'); } +/** + * Dispatch a Frigate card pause event. + * @param element The element to send the event. + */ export function dispatchPauseEvent(element: HTMLElement): void { - dispatchEvent(element, 'pause'); + dispatchFrigateCardEvent(element, 'pause'); } -export function dispatchMediaLoadEvent( - element: HTMLElement, - source: Event | HTMLElement, -): void { +/** + * Create a MediaShowInfo object. + * @param source An event or HTMLElement that should be used as a source. + * @returns A new MediaShowInfo object or null if one could not be created. + */ +export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null { let target: HTMLElement | EventTarget; if (source instanceof Event) { target = source.composedPath()[0]; @@ -132,46 +131,88 @@ export function dispatchMediaLoadEvent( } if (target instanceof HTMLImageElement) { - dispatchEvent(element, 'media-load', { + return { width: (target as HTMLImageElement).naturalWidth, height: (target as HTMLImageElement).naturalHeight, - }); + }; } else if (target instanceof HTMLVideoElement) { - dispatchEvent(element, 'media-load', { + return { width: (target as HTMLVideoElement).videoWidth, height: (target as HTMLVideoElement).videoHeight, - }); + }; } else if (target instanceof HTMLCanvasElement) { - dispatchEvent(element, 'media-load', { + return { width: (target as HTMLCanvasElement).width, height: (target as HTMLCanvasElement).height, - }); + }; + } + return null; +} + +/** + * Dispatch a Frigate card media show event. + * @param element The element to send the event. + * @param source An event or HTMLElement that should be used as a source. + */ +export function dispatchMediaShowEvent( + element: HTMLElement, + source: Event | HTMLElement, +): void { + const mediaShowInfo = createMediaShowInfo(source); + if (mediaShowInfo) { + dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo); } } +/** + * Dispatch a pre-existing MediaShowInfo object as an event. + * @param element The element to send the event. + * @param mediaShowInfo The MediaShowInfo object to send. + */ +export function dispatchExistingMediaShowInfoAsEvent( + element: HTMLElement, + mediaShowInfo: MediaShowInfo, +): void { + dispatchFrigateCardEvent(element, 'media-show', mediaShowInfo); +} + +/** + * Dispatch an event with a message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + * @param icon An optional icon to attach to the message. + */ export function dispatchMessageEvent( element: HTMLElement, message: string, icon?: string, ): void { - dispatchEvent(element, 'message', { + dispatchFrigateCardEvent(element, 'message', { message: message, type: 'info', icon: icon, }); } -export function dispatchErrorMessageEvent( - element: HTMLElement, - message: string, -): void { - dispatchEvent(element, 'message', { +/** + * Dispatch an event with an error message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + */ +export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void { + dispatchFrigateCardEvent(element, 'message', { message: message, type: 'error', }); } -// Determine whether the card should be updated based on Home Assistant changes. +/** + * Determine whether the card should be updated based on Home Assistant changes. + * @param newHass The new HA object. + * @param oldHass The old HA object. + * @param entities The entities to examine for changes. + * @returns A boolean indicating whether or not to allow an update. + */ export function shouldUpdateBasedOnHass( newHass: HomeAssistant | null, oldHass: HomeAssistant | undefined, @@ -198,3 +239,12 @@ export function shouldUpdateBasedOnHass( } return false; } + +/** + * Determine if a MediaShowInfo object is valid/acceptable. + * @param info The MediaShowInfo object. + * @returns True if the object is valid, false otherwise. + */ +export function isValidMediaShowInfo(info: MediaShowInfo): boolean { + return info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF; +} diff --git a/src/components/elements.ts b/src/components/elements.ts index 4990c6e2..7dd296d3 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -10,7 +10,7 @@ import { MenuStateIcon, PictureElements, } from '../types.js'; -import { dispatchErrorMessageEvent, dispatchEvent } from '../common.js'; +import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js'; import elementsStyle from '../scss/elements.scss'; import { localize } from '../localize/localize.js'; @@ -134,7 +134,11 @@ export class FrigateCardElements extends LitElement { protected _menuRemoveHandler(ev: Event): void { // Re-dispatch event from this element (instead of the disconnected one, as // there is no parent of the disconnected element). - dispatchEvent(this, 'menu-remove', (ev as CustomEvent).detail); + dispatchFrigateCardEvent( + this, + 'menu-remove', + (ev as CustomEvent).detail, + ); } protected _menuAddHandler(ev: Event): void { @@ -274,13 +278,13 @@ export class FrigateCardElementsBaseMenuIcon extends LitElement { connectedCallback(): void { super.connectedCallback(); if (this._config) { - dispatchEvent(this, 'menu-add', this._config); + dispatchFrigateCardEvent(this, 'menu-add', this._config); } } disconnectedCallback(): void { if (this._config) { - dispatchEvent(this, 'menu-remove', this._config); + dispatchFrigateCardEvent(this, 'menu-remove', this._config); } super.disconnectedCallback(); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index bc78b7ae..9271f4f4 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -1,28 +1,25 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { until } from 'lit/directives/until.js'; import { HomeAssistant } from 'custom-card-helpers'; +import { customElement, property, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { until } from 'lit/directives/until.js'; import type { BrowseMediaSource, BrowseMediaQueryParameters, ExtendedHomeAssistant, } from '../types.js'; - +import { BrowseMediaUtil } from '../browse-media-util.js'; import { View } from '../view.js'; import { - browseMedia, - browseMediaQuery, dispatchErrorMessageEvent, dispatchMessageEvent, - getFirstTrueMediaChildIndex, } from '../common.js'; import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from './message.js'; import galleryStyle from '../scss/gallery.scss'; -import { styleMap } from 'lit/directives/style-map.js'; const MAX_THUMBNAIL_WIDTH = 175; const DEFAULT_COLUMNS = 5; @@ -79,15 +76,15 @@ export class FrigateCardGallery extends LitElement { let parent: BrowseMediaSource | null; try { if (this.view.target) { - parent = await browseMedia(this.hass, this.view.target.media_content_id); + parent = await BrowseMediaUtil.browseMedia(this.hass, this.view.target.media_content_id); } else { - parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); + parent = await BrowseMediaUtil.browseMediaQuery(this.hass, this.browseMediaQueryParameters); } } catch (e: any) { return dispatchErrorMessageEvent(this, e.message); } - if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { + if (!parent || !parent.children || BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) == null) { return dispatchMessageEvent( this, this._getMediaType() == 'clips' @@ -149,7 +146,7 @@ export class FrigateCardGallery extends LitElement { src="${child.thumbnail}" @click=${() => { new View({ - view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot', + view: this._getMediaType() == 'clips' ? 'clip-specific' : 'snapshot-specific', target: parent ?? undefined, childIndex: index, previous: this.view ?? undefined, diff --git a/src/components/image.ts b/src/components/image.ts index 864ed772..6cef4d79 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,7 +1,7 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { dispatchMediaLoadEvent } from '../common.js'; +import { dispatchMediaShowEvent } from '../common.js'; import imageStyle from '../scss/image.scss'; import defaultImage from '../images/frigate-bird-in-sky.jpg' @@ -15,7 +15,7 @@ export class FrigateCardImage extends LitElement { return html` { - dispatchMediaLoadEvent(this, e); + dispatchMediaShowEvent(this, e); }} >`; } diff --git a/src/components/live.ts b/src/components/live.ts index 457331cf..95032b06 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,19 +1,17 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js'; +import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; -import { HomeAssistant } from 'custom-card-helpers'; - -import { signedPathSchema } from '../types.js'; -import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js'; import { localize } from '../localize/localize.js'; import { dispatchErrorMessageEvent, - dispatchMediaLoadEvent, + dispatchMediaShowEvent, dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, - homeAssistantWSRequest, + homeAssistantSignPath, } from '../common.js'; import { renderProgressIndicator } from '../components/message.js'; @@ -142,7 +140,7 @@ export class FrigateCardLiveWebRTC extends LitElement { if (onloadedmetadata) { onloadedmetadata.call(video, e); } - dispatchMediaLoadEvent(this, video); + dispatchMediaShowEvent(this, video); }; video.onplay = (e) => { if (onplay) { @@ -184,21 +182,20 @@ export class FrigateCardLiveJSMPEG extends LitElement { return null; } - const request = { - type: 'auth/sign_path', - path: `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`, - expires: URL_SIGN_EXPIRY_SECONDS, - }; - // Sign the path so it includes an authSig parameter. - let response; + let response: string | null | undefined; try { - response = await homeAssistantWSRequest(this.hass, signedPathSchema, request); + response = await homeAssistantSignPath( + this.hass, + `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`, + URL_SIGN_EXPIRY_SECONDS); } catch (err) { console.warn(err); return null; } - const url = this.hass.hassUrl(response.path); - return url.replace(/^http/i, 'ws'); + if (!response) { + return null; + } + return response.replace(/^http/i, 'ws'); } protected _createJSMPEGPlayer(): JSMpeg.VideoElement { @@ -229,7 +226,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { // ignore any subsequent calls. if (!videoDecoded && this._jsmpegCanvasElement) { videoDecoded = true; - dispatchMediaLoadEvent(this, this._jsmpegCanvasElement); + dispatchMediaShowEvent(this, this._jsmpegCanvasElement); } }, }, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 73d855e3..e44d5e03 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -6,36 +6,33 @@ import { unsafeCSS, PropertyValues, } from 'lit'; +import { BrowseMediaUtil } from '../browse-media-util.js'; import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators.js'; -import { until } from 'lit/directives/until.js'; import { ifDefined } from 'lit-html/directives/if-defined.js'; - -import dayjs from 'dayjs'; -import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; +import { until } from 'lit/directives/until.js'; import type { BrowseMediaNeighbors, BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant, + MediaShowInfo, NextPreviousControlStyle, } from '../types.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; -import { localize } from '../localize/localize.js'; +import { View } from '../view.js'; import { - browseMediaQuery, + createMediaShowInfo, dispatchErrorMessageEvent, - dispatchMediaLoadEvent, dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, - getFirstTrueMediaChildIndex, - isTrueMedia, + dispatchExistingMediaShowInfoAsEvent, + isValidMediaShowInfo, } from '../common.js'; - -import { View } from '../view.js'; +import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from '../components/message.js'; import './next-prev-control.js'; @@ -45,9 +42,6 @@ import viewerStyle from '../scss/viewer.scss'; const IMG_TRANSPARENT_1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; -// Load dayjs plugin(s). -dayjs.extend(dayjs_custom_parse_format); - @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) @@ -89,7 +83,7 @@ export class FrigateCardViewer extends LitElement { let errorFree = true; for (let i = 0; target.children && i < (target.children || []).length; ++i) { - if (isTrueMedia(target.children[i])) { + if (BrowseMediaUtil.isTrueMedia(target.children[i])) { errorFree &&= !!(await ResolvedMediaUtil.resolveMedia( this.hass, target.children[i], @@ -110,16 +104,18 @@ export class FrigateCardViewer extends LitElement { } let autoplay = true; - let view = this.view; - if (!view.target) { + if (this.view.is('clip') || this.view.is('snapshot')) { let parent: BrowseMediaSource | null = null; try { - parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); + parent = await BrowseMediaUtil.browseMediaQuery( + this.hass, + this.browseMediaQueryParameters, + ); } catch (e) { return dispatchErrorMessageEvent(this, (e as Error).message); } - const childIndex = getFirstTrueMediaChildIndex(parent); + const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { return dispatchMessageEvent( this, @@ -129,11 +125,8 @@ export class FrigateCardViewer extends LitElement { this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', ); } - view = new View({ - view: this.view.view, - target: parent, - childIndex: childIndex, - }); + this.view.target = parent; + this.view.childIndex = childIndex; // In this block, no clip has been manually selected, so this is loading // the most recent clip on card load. In this mode, autoplay of the clip @@ -143,12 +136,12 @@ export class FrigateCardViewer extends LitElement { autoplay = this.autoplayClip ?? true; } - if (view.target && !(await this._resolveAllMediaForTarget(view.target))) { + if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) { return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); } return html` = {}; + // A "map" from slide number to MediaShowInfo object or null if the slide has + // been lazy loaded, but the MediaShowInfo object is not yet available. + protected _mediaShowInfo: Record = {}; + /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. @@ -233,8 +230,9 @@ export class FrigateCardViewerCore extends LitElement { this._carousel = EmblaCarousel(carouselNode, { startIndex: isNaN(startIndex) ? undefined : startIndex, }); - // Update views based on slide selections. - this._carousel.on('select', this._slideSelectHandler.bind(this)); + // Update views and dispatch media-show events based on slide selections. + this._carousel.on('select', this._selectSlideSetViewHandler.bind(this)); + this._carousel.on('select', this._selectSlideMediaShowHandler.bind(this)); // Lazily load media that is displayed. These handlers are registered // regardless of the value of this.lazyLoad to allow that value to change @@ -245,28 +243,6 @@ export class FrigateCardViewerCore extends LitElement { } } - /** - * Get the event start time from a media object. - * @param browseMedia The media object to extract the start time from. - * @returns The start time in unix/epoch time, or null if it cannot be determined. - */ - protected _extractEventStartTimeFromBrowseMedia( - browseMedia: BrowseMediaSource, - ): number | null { - // Example: 2021-08-27 20:57:22 [10s, Person 76%] - const result = browseMedia.title.match(/^(?.+) \[/); - if (result && result.groups) { - const iso_datetime_str = result.groups['iso_datetime']; - if (iso_datetime_str) { - const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); - if (iso_datetime.isValid()) { - return iso_datetime.unix(); - } - } - } - return null; - } - /** * Get the previous and next true media items from the current view. * @returns A BrowseMediaNeighbors with indices and objects of true media @@ -286,7 +262,7 @@ export class FrigateCardViewerCore extends LitElement { let prevIndex: number | null = null; for (let i = this.view.childIndex - 1; i >= 0; i--) { const media = this.view.target.children[i]; - if (media && isTrueMedia(media)) { + if (media && BrowseMediaUtil.isTrueMedia(media)) { prevIndex = i; break; } @@ -296,7 +272,7 @@ export class FrigateCardViewerCore extends LitElement { let nextIndex: number | null = null; for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { const media = this.view.target.children[i]; - if (media && isTrueMedia(media)) { + if (media && BrowseMediaUtil.isTrueMedia(media)) { nextIndex = i; break; } @@ -330,7 +306,7 @@ export class FrigateCardViewerCore extends LitElement { return null; } - const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot); + const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot); if (!snapshotStartTime) { return null; } @@ -348,10 +324,10 @@ export class FrigateCardViewerCore extends LitElement { let latest: number | null = null; for (let i = 0; i < this.view.target.children.length; i++) { const child = this.view.target.children[i]; - if (!isTrueMedia(child)) { + if (!BrowseMediaUtil.isTrueMedia(child)) { continue; } - const startTime = this._extractEventStartTimeFromBrowseMedia(child); + const startTime = BrowseMediaUtil.extractEventStartTime(child); if (startTime && (earliest === null || startTime < earliest)) { earliest = startTime; @@ -367,7 +343,7 @@ export class FrigateCardViewerCore extends LitElement { let clips: BrowseMediaSource | null; try { - clips = await browseMediaQuery(this.hass, { + clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { ...this.browseMediaQueryParameters, mediaType: 'clips', before: latest, @@ -384,13 +360,13 @@ export class FrigateCardViewerCore extends LitElement { for (let i = 0; i < clips.children.length; i++) { const child = clips.children[i]; - if (!isTrueMedia(child)) { + if (!BrowseMediaUtil.isTrueMedia(child)) { continue; } - const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child); + const clipStartTime = BrowseMediaUtil.extractEventStartTime(child); if (clipStartTime && clipStartTime === snapshotStartTime) { return new View({ - view: 'clip', + view: 'clip-specific', target: clips, childIndex: i, previous: this.view, @@ -403,12 +379,12 @@ export class FrigateCardViewerCore extends LitElement { /** * Handle the user selecting a new slide in the carousel. */ - protected _slideSelectHandler(): void { + protected _selectSlideSetViewHandler(): void { if (!this._carousel || !this.view) { return; } - // Update the childIndex in the view (without re-render) + // Update the childIndex in the view. const slidesInView = this._carousel.slidesInView(true); if (slidesInView.length) { const childIndex = this._slideToChild[slidesInView[0]]; @@ -434,8 +410,6 @@ export class FrigateCardViewerCore extends LitElement { /** * Lazily load media in the carousel. - * @param eventName The Embla event name that triggered this load. - * // TODO delete eventName above? */ protected _lazyLoadMediaHandler(): void { if (!this.lazyLoad || !this._carousel) { @@ -459,6 +433,12 @@ export class FrigateCardViewerCore extends LitElement { } slidesToLoad.forEach((index) => { + // Only lazy loads slides that are not already loaded. + if (index in this._mediaShowInfo) { + return; + } + this._mediaShowInfo[index] = null; + const slide = slides[index]; // Snapshots. @@ -505,7 +485,7 @@ export class FrigateCardViewerCore extends LitElement { this._slideToChild = {}; for (let i = 0; i < this.view.target.children?.length; ++i) { - const slide = this._renderMediaItem(this.view.target.children[i]); + const slide = this._renderMediaItem(this.view.target.children[i], slides.length); if (slide) { this._slideToChild[slides.length] = i; slides.push(slide); @@ -541,15 +521,72 @@ export class FrigateCardViewerCore extends LitElement { `; } + /** + * Fire a media show event when a slide is selected. + */ + protected _selectSlideMediaShowHandler(): void { + if (!this._carousel || !this.view) { + return; + } + + this._carousel.slidesInView(true).forEach((slideIndex) => { + if (slideIndex in this._mediaShowInfo) { + const mediaShowInfo = this._mediaShowInfo[slideIndex]; + if (mediaShowInfo) { + dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); + } + } + }); + } + + /** + * Handle a media-show event that is generated by a child component, saving the + * contents for future use when the relevant slide is shown. + * @param slideIndex The relevant slide index. + * @param event The media-show event from the child component. + */ + protected _mediaShowEventHandler( + slideIndex: number, + event: CustomEvent, + ): void { + this._mediaShowInfoHandler(slideIndex, event.detail); + // Don't allow the inbound event to propagate upwards, that will be + // automatically done at the appropriate time as the slide is shown. + event.stopPropagation(); + } + + /** + * Handle a MediaShowInfo object that is generated on media load, by saving it + * for future, or immediate use, when the relevant slide is displayed. + * @param slideIndex The relevant slide index. + * @param mediaShowInfo The MediaShowInfo object generated by the media. + */ + protected _mediaShowInfoHandler( + slideIndex: number, + mediaShowInfo?: MediaShowInfo | null, + ): void { + // isValidMediaShowInfo is used to weed out the initial load of the + // transparent 1x1 placeholders. + if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { + this._mediaShowInfo[slideIndex] = mediaShowInfo; + if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) { + dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); + } + } + } + /** * Render a given media item. * @param mediaToRender The media item to render. * @returns A template or void if the item could not be rendered. */ - protected _renderMediaItem(mediaToRender: BrowseMediaSource): TemplateResult | void { + protected _renderMediaItem( + mediaToRender: BrowseMediaSource, + slideIndex: number, + ): TemplateResult | void { // media that can be expanded (folders) cannot be resolved to a single media // item, skip them. - if (!this.view || !isTrueMedia(mediaToRender)) { + if (!this.view || !BrowseMediaUtil.isTrueMedia(mediaToRender)) { return; } @@ -560,7 +597,7 @@ export class FrigateCardViewerCore extends LitElement { return html`
- ${this.view.is('clip') + ${this.view.isClipRelatedView() ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' ? html`) => + this._mediaShowEventHandler(slideIndex, e)} > ` : html`
`; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 7152651e..f2b46c1b 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -46,7 +46,8 @@ "snapshot": "Latest Snapshot", "frigate_ui": "Frigate User Interface", "fullscreen": "Fullscreen", - "image": "Static Image" + "image": "Static Image", + "download": "Download event media" }, "control": { "nextprev": "Media Next & Previous Controls", @@ -104,6 +105,9 @@ "could_not_render_elements": "Could not render picture elements", "invalid_elements_config": "Invalid picture elements configuration", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", - "jsmpeg_no_player": "Could not start JSMPEG player" + "jsmpeg_no_player": "Could not start JSMPEG player", + "download_no_media": "No media to download", + "download_no_event_id": "Could not extract Frigate event id from media", + "download_sign_failed": "Could not sign media URL for download" } } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 9367392e..d22e3a3d 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -11,7 +11,7 @@ import { TemplateResult, html } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { dispatchMediaLoadEvent } from '../common.js'; +import { dispatchMediaShowEvent } from '../common.js'; customElements.whenDefined('ha-camera-stream').then(() => { // ======================================================================================== @@ -53,7 +53,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { if (typeof this._elementResized != 'undefined') { this._elementResized(); } - dispatchMediaLoadEvent(this, e); + dispatchMediaShowEvent(this, e); }} .src=${ (typeof this._connected == 'undefined' || diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index a4a1b981..ac094822 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -14,7 +14,7 @@ import { html, } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { dispatchMediaLoadEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common.js'; +import { dispatchMediaShowEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common.js'; customElements.whenDefined("ha-hls-player").then(() => { @customElement("frigate-card-ha-hls-player") @@ -33,7 +33,7 @@ customElements.whenDefined("ha-hls-player").then(() => { ?controls=${this.controls} @loadeddata=${(e) => { this._elementResized(); - dispatchMediaLoadEvent(this, e); + dispatchMediaShowEvent(this, e); }} @pause=${() => dispatchPauseEvent(this)} @play=${() => dispatchPlayEvent(this)} diff --git a/src/types.ts b/src/types.ts index f074cc8a..b139f724 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,15 +20,23 @@ declare global { * Internal types. */ -export const FRIGATE_CARD_VIEWS = [ - 'live', - 'clip', - 'clips', - 'snapshot', - 'snapshots', - 'image' +const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ + 'live', // Live view. + 'clip', // Most recent clip. + 'clips', // Clips gallery. + 'snapshot', // Most recent snapshot. + 'snapshots', // Snapshots gallery. + 'image', // Static image. ] as const; -export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; + +const FRIGATE_CARD_VIEWS_INTERNAL = [ + 'clip-specific', // A specific clip. + 'snapshot-specific', // A specific snapshot. +] as const; + +export type FrigateCardView = + | typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number] + | typeof FRIGATE_CARD_VIEWS_INTERNAL[number]; export const FRIGATE_MENU_MODES = [ 'none', @@ -115,7 +123,7 @@ const elementsBaseSchema = z.object({ /** * Picture Element Types - * + * * All picture element types are validated (not just the Frigate card custom * ones) as a convenience to present the user with a consistent error display * up-front regardless of where they made their error. @@ -126,7 +134,8 @@ const stateBadgeIconSchema = elementsBaseSchema.merge( z.object({ type: z.literal('state-badge'), entity: z.string(), - })); + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#state-icon const stateIconSchema = elementsBaseSchema.merge( @@ -135,7 +144,8 @@ const stateIconSchema = elementsBaseSchema.merge( entity: z.string(), icon: z.string().optional(), state_color: z.boolean().default(true), - })); + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#state-label const stateLabelSchema = elementsBaseSchema.merge( @@ -145,19 +155,19 @@ const stateLabelSchema = elementsBaseSchema.merge( attribute: z.string().optional(), prefix: z.string().optional(), suffix: z.string().optional(), - })); + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#service-call-button -const serviceCallButtonSchema = - elementsBaseSchema.merge(z - .object({ - type: z.literal('service-button'), - // Title is required for service button. - title: z.string(), - service: z.string(), - service_data: z.object({}).passthrough().optional(), - }) - ) +const serviceCallButtonSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('service-button'), + // Title is required for service button. + title: z.string(), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#icon const iconSchema = elementsBaseSchema.merge( @@ -165,7 +175,8 @@ const iconSchema = elementsBaseSchema.merge( type: z.literal('icon'), icon: z.string(), entity: z.string().optional(), - })); + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#image-element const imageSchema = elementsBaseSchema.merge( @@ -179,32 +190,37 @@ const imageSchema = elementsBaseSchema.merge( filter: z.string().optional(), state_filter: z.object({}).passthrough().optional(), aspect_ratio: z.string().optional(), -})); + }), +); // https://www.home-assistant.io/lovelace/picture-elements/#image-element const conditionalSchema = z.object({ - type: z.literal('conditional'), - conditions: z.object({ + type: z.literal('conditional'), + conditions: z + .object({ entity: z.string(), state: z.string().optional(), state_not: z.string().optional(), - }).array(), - elements: z.lazy(() => pictureElementsSchema), - }); + }) + .array(), + elements: z.lazy(() => pictureElementsSchema), +}); // https://www.home-assistant.io/lovelace/picture-elements/#custom-elements -const customSchema = z.object({ +const customSchema = z + .object({ // Insist that Frigate card custom elements are handled by other schemas. type: z.string().superRefine((val, ctx) => { if (!val.match(/^custom:(?!frigate-card).+/)) { ctx.addIssue({ code: z.ZodIssueCode.invalid_type, - expected: "string", - received: "string", + expected: 'string', + received: 'string', }); } - }) - }).passthrough(); + }), + }) + .passthrough(); /** * Custom Element Types @@ -213,13 +229,15 @@ const customSchema = z.object({ export const menuIconSchema = iconSchema.merge( z.object({ type: z.literal('custom:frigate-card-menu-icon'), - })); + }), +); export type MenuIcon = z.infer; export const menuStateIconSchema = stateIconSchema.merge( z.object({ type: z.literal('custom:frigate-card-menu-state-icon'), - })); + }), +); export type MenuStateIcon = z.infer; const frigateConditionalSchema = z.object({ @@ -231,7 +249,6 @@ const frigateConditionalSchema = z.object({ }); export type FrigateConditional = z.infer; - // 'internalMenuIconSchema' is excluded to disallow the user from manually // changing the internal menu buttons. const pictureElementSchema = z.union([ @@ -259,7 +276,7 @@ export const frigateCardConfigSchema = z.object({ frigate_url: z.string().optional(), frigate_client_id: z.string().optional().default('frigate'), frigate_camera_name: z.string().optional(), - view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'), + view_default: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED).optional().default('live'), view_timeout: z .number() .or( @@ -283,9 +300,11 @@ export const frigateCardConfigSchema = z.object({ label: z.string().optional(), zone: z.string().optional(), autoplay_clip: z.boolean().default(false), - event_viewer: z.object({ - lazy_load: z.boolean().default(true), - }).optional(), + event_viewer: z + .object({ + lazy_load: z.boolean().default(true), + }) + .optional(), menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'), menu_buttons: z .object({ @@ -294,6 +313,7 @@ export const frigateCardConfigSchema = z.object({ clips: z.boolean().default(true), snapshots: z.boolean().default(true), image: z.boolean().default(false), + download: z.boolean().default(true), frigate_ui: z.boolean().default(true), fullscreen: z.boolean().default(true), }) @@ -333,14 +353,13 @@ export const frigateCardConfigSchema = z.object({ export type FrigateCardConfig = z.infer; // Schema for card (non-user configured) menu icons. -const internalMenuIconSchema = z - .object({ - type: z.literal('internal-menu-icon'), - title: z.string(), - icon: z.string().optional(), - emphasize: z.boolean().default(false).optional(), - card_action: z.string(), - }); +const internalMenuIconSchema = z.object({ + type: z.literal('internal-menu-icon'), + title: z.string(), + icon: z.string().optional(), + emphasize: z.boolean().default(false).optional(), + card_action: z.string(), +}); const menuButtonSchema = z.union([ menuIconSchema, @@ -370,7 +389,7 @@ export interface BrowseMediaNeighbors { nextIndex: number | null; } -export interface MediaLoadInfo { +export interface MediaShowInfo { width: number; height: number; } diff --git a/src/view.ts b/src/view.ts index 0524631c..548ef84e 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,4 +1,5 @@ import type { BrowseMediaSource, FrigateCardView } from './types.js'; +import { dispatchFrigateCardEvent } from './common.js'; export interface ViewParameters { view?: FrigateCardView; @@ -38,18 +39,27 @@ export class View { return !this.isGalleryView(); } + /** + * Determine if a view is for the media viewer. + */ + public isViewerView(): boolean { + return ['clip', 'clip-specific', 'snapshot', 'snapshot-specific'].includes( + this.view, + ); + } + /** * Determine if a view is related to a clip or clips. */ public isClipRelatedView(): boolean { - return ['clip', 'clips'].includes(this.view); + return ['clip', 'clips', 'clip-specific'].includes(this.view); } /** * Determine if a view is related to a snapshot or snapshots. */ public isSnapshotRelatedView(): boolean { - return ['snapshot', 'snapshots'].includes(this.view); + return ['snapshot', 'snapshots', 'snapshot-specific'].includes(this.view); } /** @@ -70,12 +80,6 @@ export class View { * @param node The element dispatching the event. */ public dispatchChangeEvent(node: HTMLElement): void { - node.dispatchEvent( - new CustomEvent('frigate-card:change-view', { - bubbles: true, - composed: true, - detail: this, - }), - ); + dispatchFrigateCardEvent(node, 'change-view', this); } }