From e1f146b5609cf49e97eafb2fd7d73c32984e304b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 12 Mar 2023 20:28:46 -0700 Subject: [PATCH] Add download control to thumbnails. --- README.md | 13 +- src/camera-manager/frigate/engine-frigate.ts | 1 + src/camera-manager/types.ts | 1 + src/card.ts | 105 ++----- src/components/gallery.ts | 2 + src/components/message.ts | 10 +- src/components/thumbnail-carousel.ts | 1 + src/components/thumbnail.ts | 282 ++++++++++++------- src/const.ts | 13 +- src/editor.ts | 49 ++-- src/localize/languages/en.json | 6 +- src/localize/languages/it.json | 1 + src/localize/languages/pt-BR.json | 1 + src/performance.ts | 20 +- src/scss/thumbnail.scss | 8 +- src/types.ts | 8 +- src/utils/download.ts | 49 ++++ 17 files changed, 354 insertions(+), 216 deletions(-) create mode 100644 src/utils/download.ts diff --git a/README.md b/README.md index 2591b665..ed316a12 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,7 @@ live: | `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| | `size` | 100 | :white_check_mark: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`. | | `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_download_control` | `true` | :white_check_mark: | Whether to show the download control on each thumbnail.| | `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| | `media` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view.| @@ -537,6 +538,7 @@ media_viewer: | `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| | `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `175`.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each thumbnail.| | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| @@ -619,6 +621,7 @@ media_gallery: | - | - | - | - | | `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the gallery. Must be >= `75` and <= `175`.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each thumbnail.| | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| @@ -680,8 +683,9 @@ timeline: | `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| | `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| -| `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| -| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| +| `show_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each thumbnail.| +| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| +| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| @@ -1646,6 +1650,7 @@ live: media: clips size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true mode: none @@ -1704,6 +1709,7 @@ media_viewer: size: 100 mode: none show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true timeline: @@ -1750,6 +1756,7 @@ media_gallery: thumbnails: size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true actions: @@ -2174,6 +2181,7 @@ timeline: mode: left size: 100 show_details: true + show_download_control: true show_favorite_control: true show_timeline_control: true ``` @@ -2284,6 +2292,7 @@ overrides: media: clips size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true mode: none diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 56bc0f52..d1021ba9 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1073,6 +1073,7 @@ export class FrigateCameraManagerEngine public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { return { canFavorite: ViewMediaClassifier.isEvent(media), + canDownload: true, }; } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index d2c9f03d..94b39e61 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -104,6 +104,7 @@ export type CameraManagerCapabilities = BaseCapabilities; export type CameraManagerCameraCapabilities = BaseCapabilities; export interface CameraManagerMediaCapabilities { canFavorite: boolean; + canDownload: boolean; } export interface CameraManagerCameraMetadata { diff --git a/src/card.ts b/src/card.ts index 74b2a72c..3b971c30 100644 --- a/src/card.ts +++ b/src/card.ts @@ -39,20 +39,20 @@ import { Actions, ActionType, CameraConfig, + CardWideConfig, ExtendedHomeAssistant, + FRIGATE_CARD_VIEW_DEFAULT, + FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, + FrigateCardError, FrigateCardView, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, MediaLoadedInfo, - MESSAGE_TYPE_PRIORITIES, MenuButton, + MESSAGE_TYPE_PRIORITIES, Message, RawFrigateCardConfig, - CardWideConfig, - FrigateCardError, - FRIGATE_CARD_VIEW_DEFAULT, } from './types.js'; import { convertActionToFrigateCardCustomAction, @@ -66,7 +66,6 @@ import { getEntityIcon, getEntityTitle, getHassDifferences, - homeAssistantSignPath, isCardInPanel, isHassDifferent, isTriggeredState, @@ -92,6 +91,7 @@ import isEqual from 'lodash-es/isEqual'; import merge from 'lodash-es/merge'; import { FrigateCardInitializer } from './utils/initializer.js'; import 'web-dialog'; +import { downloadMedia } from './utils/download.js'; /** A note on media callbacks: * @@ -409,10 +409,14 @@ class FrigateCard extends LitElement { this._cameraManager, selectedCameraID, ); + const selectedMedia = this._view?.queryResults?.getSelectedResult(); const cameraCapabilities = allSelectedCameraIDs ? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs) : null; + const mediaCapabilities = selectedMedia + ? this._cameraManager?.getMediaCapabilities(selectedMedia) + : null; buttons.push({ // Use a magic icon value that the menu will use to render the custom @@ -588,11 +592,7 @@ class FrigateCard extends LitElement { }); } - if ( - !this._isBeingCasted() && - (this._view?.isViewerView() || - (this._view?.is('timeline') && !!this._view?.queryResults?.hasSelectedResult())) - ) { + if (mediaCapabilities?.canDownload && !this._isBeingCasted()) { buttons.push({ icon: 'mdi:download', ...this._getConfig().menu.buttons.download, @@ -1090,6 +1090,19 @@ class FrigateCard extends LitElement { } } + protected _handleThrownError(error: unknown) { + if (error instanceof Error) { + errorToConsole(error); + } + if (error instanceof FrigateCardError) { + this._setMessageAndUpdate({ + message: error.message, + type: 'error', + context: error.context, + }); + } + } + protected async _initializeCameras( hass: HomeAssistant, config: FrigateCardConfig, @@ -1115,16 +1128,7 @@ class FrigateCard extends LitElement { cameras, ); } catch (e: unknown) { - if (e instanceof Error) { - errorToConsole(e); - } - if (e instanceof FrigateCardError) { - this._setMessageAndUpdate({ - message: e.message, - type: 'error', - context: e.context, - }); - } + this._handleThrownError(e); } // If there's no view set yet, set one. This will be the case on initial camera load. @@ -1319,64 +1323,15 @@ class FrigateCard extends LitElement { * Download media being displayed in the viewer. */ protected async _downloadViewerMedia(): Promise { - if (!this._hass || !(this._view?.isViewerView() || this._view?.is('timeline'))) { - // Should not occur. - return; - } - const media = this._view.queryResults?.getSelectedResult(); - - if (!media) { - this._setMessageAndUpdate({ - message: localize('error.download_no_media'), - type: 'error', - }); + const media = this._view?.queryResults?.getSelectedResult(); + if (!this._hass || !this._cameraManager || !media) { return; } - const cameraConfig = this._getSelectedCameraConfig(); - if (!cameraConfig || !cameraConfig.frigate.camera_name) { - return; - } - - const path = this._cameraManager?.getMediaDownloadPath(media); - if (!path) { - return; - } - - let response: string | null | undefined; try { - response = await homeAssistantSignPath(this._hass, path); - } catch (e) { - errorToConsole(e as Error); - } - - if (!response) { - this._setMessageAndUpdate({ - message: localize('error.download_sign_failed'), - type: 'error', - }); - return; - } - - if ( - navigator.userAgent.startsWith('Home Assistant/') || - navigator.userAgent.startsWith('HomeAssistant/') - ) { - // Home Assistant companion apps cannot download files without opening a - // new browser window. - // - // User-agents are specified here: - // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 - // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 - window.open(response, '_blank'); - } else { - // 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(); + await downloadMedia(this._hass, this._cameraManager, media); + } catch (error) { + this._handleThrownError(error); } } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index d0a847f9..f9794769 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -464,6 +464,8 @@ export class FrigateCardGalleryCore extends LitElement { .show_favorite_control} ?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails .show_timeline_control} + ?show_download_control=${!!this.galleryConfig?.controls.thumbnails + .show_download_control} @click=${(ev: Event) => { if (this.view && this._media) { this.view diff --git a/src/components/message.ts b/src/components/message.ts index 83dd9208..e233379e 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -190,11 +190,13 @@ export function dispatchErrorMessageEvent( */ export function dispatchFrigateCardErrorEvent( element: EventTarget, - error: FrigateCardError | Error, + error: unknown, ): void { - dispatchErrorMessageEvent(element, error.message, { - ...(error instanceof FrigateCardError && { context: error.context }), - }); + if (error instanceof Error) { + dispatchErrorMessageEvent(element, error.message, { + ...(error instanceof FrigateCardError && { context: error.context }), + }); + } } declare global { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index e3c4eba9..cc550fe7 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -163,6 +163,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { ?details=${!!this.config?.show_details} ?show_favorite_control=${this.config?.show_favorite_control} ?show_timeline_control=${this.config?.show_timeline_control} + ?show_download_control=${this.config?.show_download_control} @click=${(ev: Event) => { if (this.view && this.view.queryResults) { dispatchFrigateCardEvent( diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index fbb4f636..0b315e44 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -30,6 +30,8 @@ import type { ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; import { CameraManager } from '../camera-manager/manager.js'; import { ViewMediaClassifier } from '../view/media-classifier.js'; +import { downloadMedia } from '../utils/download.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -162,56 +164,78 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { if (!this.media) { return; } - const score = this.media.getScore(); - const startTime = this.media.getStartTime(); - const endTime = this.media.getEndTime(); - const what = this.media.getWhat(); - const where = this.media.getWhere(); - const tags = this.media.getTags(); + const rawScore = this.media.getScore(); + const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; + const rawStartTime = this.media.getStartTime(); + const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; + + const rawEndTime = this.media.getEndTime(); + const endTime = rawStartTime + ? rawEndTime + ? getDurationString(rawStartTime, rawEndTime) + : localize('event.in_progress') + : null; + + const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null; + const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null; + const tags = prettifyTitle(this.media.getTags()?.join(', ')) ?? null; + const whatWithTags = + what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null; + + const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; return html` - ${what - ? html`
- ${prettifyTitle(what.join(', ')) + - (tags ? ': ' + prettifyTitle(tags.join(', ')) : '')} - ${score ? html`(${(score * 100).toFixed(2) + '%'})` : ''} + ${whatWithTags + ? html`
+ ${whatWithTags} + ${score ? html`${score}` : ''}
` : ``}
${startTime - ? html`
- - ${formatDateAndTime(startTime)} + ? html`
+ + ${startTime}
-
- - ${endTime - ? getDurationString(startTime, endTime) - : localize('event.in_progress')} +
+ + ${endTime}
` : ''} ${this.cameraTitle - ? html`
- - ${this.cameraTitle} + ? html`
+ + ${this.cameraTitle}
` : ''} ${where - ? html`
- - ${prettifyTitle(where.join(', '))} + ? html`
+ + ${where}
` : html``} ${tags - ? html`
- - ${prettifyTitle(tags.join(', '))} + ? html`
+ + ${tags}
` : html``} - ${this.seek - ? html`
- - ${format(this.seek, 'HH:mm:ss')} + ${seek + ? html`
+ + ${seek}
` : html``}
@@ -238,38 +262,56 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { if (!this.media) { return; } - const startTime = this.media.getStartTime(); - const endTime = this.media.getEndTime(); + const rawStartTime = this.media.getStartTime(); + const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; + + const rawEndTime = this.media.getEndTime(); + const endTime = rawStartTime + ? rawEndTime + ? getDurationString(rawStartTime, rawEndTime) + : localize('event.in_progress') + : null; + + const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; + const eventCount = this.media.getEventCount(); return html` ${this.cameraTitle - ? html`
- ${this.cameraTitle} + ? html`
+ ${this.cameraTitle}
` : ``}
${startTime - ? html`
- - ${formatDateAndTime(startTime)} + ? html`
+ + ${startTime}
-
- - ${endTime - ? getDurationString(startTime, endTime) - : localize('event.in_progress')} +
+ + ${endTime}
` : ''} - ${this.seek - ? html`
- - ${format(this.seek, 'HH:mm:ss')} + ${seek + ? html`
+ + ${seek}
` : html``} ${eventCount !== null - ? html`
- - ${eventCount} + ? html`
+ + ${eventCount}
` : ``}
@@ -303,6 +345,9 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public show_timeline_control = false; + @property({ attribute: true, type: Boolean }) + public show_download_control = false; + @property({ attribute: false }) public seek?: Date; @@ -333,34 +378,43 @@ export class FrigateCardThumbnail extends LitElement { // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); + const mediaCapabilities = this.cameraManager?.getMediaCapabilities(this.media); + const shouldShowFavoriteControl = this.show_favorite_control && this.media && this.hass && - this.cameraManager?.getMediaCapabilities(this.media)?.canFavorite; + mediaCapabilities?.canFavorite; + + const shouldShowDownloadControl = + this.show_download_control && + this.hass && + this.media.getID() && + mediaCapabilities?.canDownload; const cameraTitle = this.cameraManager.getCameraMetadata( this.hass, this.media.getCameraID(), )?.title; - return html` ${ViewMediaClassifier.isEvent(this.media) - ? html`` - : ViewMediaClassifier.isRecording(this.media) - ? html`` - : html``} - ${shouldShowFavoriteControl - ? html` ` + : ViewMediaClassifier.isRecording(this.media) + ? html`` + : html``} + ${shouldShowFavoriteControl + ? html` ` - : ``} - ${this.details && ViewMediaClassifier.isEvent(this.media) - ? html`` - : this.details && ViewMediaClassifier.isRecording(this.media) - ? html`` - : html``} - ${shouldShowTimelineControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (!this.view || !this.media) { - return; - } - this.view - .evolve({ - view: 'timeline', - queryResults: this.view.queryResults - ?.clone() - .selectResultIfFound((media) => media === this.media), - }) - .removeContext('timeline') - .dispatchChangeEvent(this); - }} - >` - : ''}`; + : ``} + ${this.details && ViewMediaClassifier.isEvent(this.media) + ? html`` + : this.details && ViewMediaClassifier.isRecording(this.media) + ? html`` + : html``} + ${shouldShowTimelineControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (!this.view || !this.media) { + return; + } + this.view + .evolve({ + view: 'timeline', + queryResults: this.view.queryResults + ?.clone() + .selectResultIfFound((media) => media === this.media), + }) + .removeContext('timeline') + .dispatchChangeEvent(this); + }} + >` + : ''} + ${shouldShowDownloadControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.hass && this.cameraManager && this.media) { + try { + await downloadMedia(this.hass, this.cameraManager, this.media); + } catch (error: unknown) { + dispatchFrigateCardErrorEvent(this, error); + } + } + }} + >` + : ``} + `; } /** diff --git a/src/const.ts b/src/const.ts index 7fbf58dc..4144e69e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -39,7 +39,7 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = `${CONF_CAMERAS}.#.triggers.entities` as const; -export const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; +const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const; export const CONF_CAMERAS_GLOBAL_JSMPEG = `${CONF_CAMERAS_GLOBAL}.jsmpeg` as const; export const CONF_CAMERAS_GLOBAL_WEBRTC_CARD = @@ -58,7 +58,6 @@ export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; -export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; @@ -74,6 +73,8 @@ export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE = `${CONF_MEDIA_GALLERY}.controls.filter.mode` as const; export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_download_control` as const; export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const; export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = @@ -100,6 +101,8 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE = `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_download_control` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = @@ -144,6 +147,8 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = `${CONF_LIVE}.controls.thumbnails.size` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_LIVE}.controls.thumbnails.show_details` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_download_control` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = @@ -192,12 +197,14 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = `${CONF_TIMELINE}.controls.thumbnails.size` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_download_control` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const; -export const CONF_MENU = 'menu' as const; +const CONF_MENU = 'menu' as const; export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const; export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; diff --git a/src/editor.ts b/src/editor.ts index a874bc06..c5208104 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -12,7 +12,6 @@ import { upgradeConfig, } from './config-mgmt.js'; import { - CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, @@ -21,9 +20,13 @@ import { CONF_CAMERAS_ARRAY_FRIGATE_LABELS, CONF_CAMERAS_ARRAY_FRIGATE_URL, CONF_CAMERAS_ARRAY_FRIGATE_ZONES, + CONF_CAMERAS_ARRAY_GO2RTC_MODES, + CONF_CAMERAS_ARRAY_GO2RTC_STREAM, + CONF_CAMERAS_ARRAY_HIDE, CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ID, CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, + CONF_CAMERAS_ARRAY_IMAGE_URL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_TITLE, CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, @@ -31,13 +34,9 @@ import { CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, - CONF_DIMENSIONS_ASPECT_RATIO, + CONF_CAMERAS, CONF_DIMENSIONS_ASPECT_RATIO_MODE, - CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, + CONF_DIMENSIONS_ASPECT_RATIO, CONF_IMAGE_LAYOUT_FIT, CONF_IMAGE_LAYOUT_POSITION_X, CONF_IMAGE_LAYOUT_POSITION_Y, @@ -53,6 +52,7 @@ import { CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, @@ -72,15 +72,21 @@ import { CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_TRANSITION_EFFECT, + CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_MEDIA_VIEWER_AUTO_MUTE, CONF_MEDIA_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PLAY, CONF_MEDIA_VIEWER_AUTO_UNMUTE, - CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, @@ -96,19 +102,22 @@ import { CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, CONF_MEDIA_VIEWER_LAZY_LOAD, + CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTONS, CONF_MENU_BUTTON_SIZE, + CONF_MENU_BUTTONS, CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, + CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, CONF_PERFORMANCE_PROFILE, CONF_PERFORMANCE_STYLE_BORDER_RADIUS, CONF_PERFORMANCE_STYLE_BOX_SHADOW, CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, @@ -118,29 +127,24 @@ import { CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, - CONF_VIEW_SCAN, CONF_VIEW_SCAN_ENABLED, CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_SCAN_UNTRIGGER_RESET, CONF_VIEW_SCAN_UNTRIGGER_SECONDS, + CONF_VIEW_SCAN, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, - CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, MEDIA_CHUNK_SIZE_MAX, - CONF_CAMERAS_ARRAY_GO2RTC_MODES, - CONF_CAMERAS_ARRAY_GO2RTC_STREAM, - CONF_CAMERAS_ARRAY_HIDE, - CONF_CAMERAS_ARRAY_IMAGE_URL, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; import { BUTTON_SIZE_MIN, + FRIGATE_MENU_PRIORITY_MAX, FrigateCardConfig, frigateCardConfigDefaults, - FRIGATE_MENU_PRIORITY_MAX, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -1072,10 +1076,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor configPathShowDetails: string, configPathShowFavoriteControl: string, configPathShowTimelineControl: string, + configPathShowDownloadControl: string, defaults: { show_details: boolean; show_favorite_control: boolean; show_timeline_control: boolean; + show_download_control: boolean; }, options?: { configPathMedia?: string; @@ -1128,6 +1134,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor label: localize('config.common.controls.thumbnails.show_timeline_control'), }, )} + ${this._renderSwitch( + configPathShowDownloadControl, + defaults.show_download_control, + { + label: localize('config.common.controls.thumbnails.show_download_control'), + }, + )} `, ); } @@ -1733,6 +1746,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, this._defaults.live.controls.thumbnails, { configPathMedia: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, @@ -1774,6 +1788,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, this._defaults.media_gallery.controls.thumbnails, )} ${this._renderFilterControls( @@ -1837,6 +1852,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, this._defaults.media_viewer.controls.thumbnails, { configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, @@ -1898,6 +1914,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, this._defaults.timeline.controls.thumbnails, { configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 24653d4c..12931232 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -148,6 +148,7 @@ "right": "Thumbnails in a drawer to the right" }, "show_details": "Show details with thumbnails", + "show_download_control": "Show download control on thumbnails", "show_favorite_control": "Show favorite control on thumbnails", "show_timeline_control": "Show timeline control on thumbnails", "size": "Thumbnails size in pixels" @@ -440,9 +441,10 @@ "start": "Start" }, "thumbnail": { + "download": "Download media", "no_thumbnail": "No thumbnail available", - "retain_indefinitely": "Event will be indefinitely retained", - "timeline": "See event/recording in timeline" + "retain_indefinitely": "Media will be indefinitely retained", + "timeline": "See media in timeline" }, "timeline": { "lock": "Lock timeline to a single event", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 31679192..94040d26 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -148,6 +148,7 @@ "right": "Miniature in un cassetto a destra" }, "show_details": "", + "show_download_control": "", "show_favorite_control": "Mostra il controllo preferito sulle miniature", "show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature", "size": "Dimensione delle miniature in pixel" diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 4cf9f0ec..9a788ea8 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -148,6 +148,7 @@ "right": "Miniaturas em uma gaveta à direita" }, "show_details": "", + "show_download_control": "", "show_favorite_control": "Mostrar controle de favorito nas miniaturas", "show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas", "size": "Tamanho das miniaturas em pixels" diff --git a/src/performance.ts b/src/performance.ts index 4467ff2b..62e54dfb 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -6,12 +6,12 @@ import { } from './types'; import { getConfigValue, setConfigValue } from './config-mgmt.js'; import { - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS, + CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY, CONF_LIVE_AUTO_MUTE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, @@ -20,18 +20,23 @@ import { CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_TRANSITION_EFFECT, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_AUTO_MUTE, CONF_MEDIA_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PLAY, - CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_DRAGGABLE, + CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_BUTTONS_FRIGATE, CONF_MENU_BUTTONS_MEDIA_PLAYER, @@ -43,11 +48,10 @@ import { CONF_PERFORMANCE_STYLE_BOX_SHADOW, CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, - CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY, - CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS, } from './const.js'; // Caution: These values are applied after parsing (since we cannot know the @@ -103,15 +107,19 @@ const LOW_PROFILE_DEFAULTS = { [`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false, // Disable all options in thumbnails. + [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, + [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, + [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, + [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 9dbf577a..23c56eff 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -33,7 +33,6 @@ ha-icon { border-radius: 50%; opacity: 0.5; color: var(--primary-color); - top: 3px; cursor: pointer; @@ -44,6 +43,7 @@ ha-icon:hover { } ha-icon.star { + top: 3px; left: 3px; } ha-icon.star.starred { @@ -51,9 +51,15 @@ ha-icon.star.starred { } ha-icon.timeline { + top: 3px; right: 3px; } +ha-icon.download { + right: 3px; + bottom: 3px; +} + frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording { flex: 1; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 231913ab..6592ed4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -245,7 +245,7 @@ const frigateCardLiveDependencySelectActionSchema = frigate_card_action: z.literal('live_substream_select'), camera: z.string(), }); -const frigateCarMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('media_player'), media_player: z.string(), media_player_action: z.enum(['play', 'stop']), @@ -255,7 +255,7 @@ export const frigateCardCustomActionSchema = z.union([ frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, frigateCardLiveDependencySelectActionSchema, - frigateCarMediaPlayerActionSchema, + frigateCardMediaPlayerActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -747,6 +747,7 @@ const thumbnailControlsDefaults = { show_details: true, show_favorite_control: true, show_timeline_control: true, + show_download_control: true, }; const thumbnailsControlSchema = z.object({ @@ -765,6 +766,9 @@ const thumbnailsControlSchema = z.object({ show_timeline_control: z .boolean() .default(thumbnailControlsDefaults.show_timeline_control), + show_download_control: z + .boolean() + .default(thumbnailControlsDefaults.show_download_control), }); export type ThumbnailsControlConfig = z.infer; diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 00000000..fe93acd9 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,49 @@ +import { CameraManager } from '../camera-manager/manager'; +import { localize } from '../localize/localize'; +import { ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { ViewMedia } from '../view/media'; +import { errorToConsole } from './basic'; +import { homeAssistantSignPath } from './ha'; + +export const downloadMedia = async ( + hass: ExtendedHomeAssistant, + cameraManager: CameraManager, + media: ViewMedia, +): Promise => { + const path = cameraManager.getMediaDownloadPath(media); + if (!path) { + throw new FrigateCardError(localize('error.download_no_media')); + } + + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(hass, path); + } catch (e) { + errorToConsole(e as Error); + } + + if (!response) { + throw new FrigateCardError(localize('error.download_sign_failed')); + } + + if ( + navigator.userAgent.startsWith('Home Assistant/') || + navigator.userAgent.startsWith('HomeAssistant/') + ) { + // Home Assistant companion apps cannot download files without opening a + // new browser window. + // + // User-agents are specified here: + // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 + // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 + window.open(response, '_blank'); + } else { + // 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(); + } +};