From 21f94697847f8f6e5e380dc5c43aca17a654e2df Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 8 Feb 2026 16:04:11 +0000 Subject: [PATCH] fix: Expose `gallery` and `media` views as media type agnostic (#2335) --- docs/configuration/actions/custom/README.md | 63 ++++++++ docs/configuration/cameras/README.md | 2 +- docs/configuration/conditions.md | 6 +- docs/configuration/media-gallery.md | 11 +- docs/configuration/media-viewer.md | 4 +- docs/configuration/view.md | 6 +- docs/usage/url-actions.md | 13 +- src/card-controller/actions/factory.ts | 2 + src/card-controller/query-string-manager.ts | 12 ++ .../view/view-query-executor.ts | 21 ++- src/components-lib/menu-button-controller.ts | 66 ++++---- src/config/schema/common/const.ts | 7 +- src/config/schema/menu.ts | 6 +- src/editor.ts | 5 +- src/localize/languages/en.json | 4 + src/scss/themes/light.scss | 1 - src/view/view-support.ts | 5 +- src/view/view.ts | 11 +- .../view/view-query-executor.test.ts | 78 +++++----- .../menu-button-controller.test.ts | 143 ++++++++++-------- tests/config/types.test.ts | 6 +- tests/view/view-support.test.ts | 129 +++++++++------- 22 files changed, 363 insertions(+), 238 deletions(-) diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 57e13aa5..0150b555 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -157,6 +157,15 @@ advanced_camera_card_action: folders | --------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `folder` | The first configured folder (under [`folders`](../../folders.md)). | An optional id of the folder to show, see the `id` parameter under [`folders` configuration](../../folders.md). | +## `gallery` + +Change to the `gallery` view. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: gallery +``` + ## `fullscreen` Toggle fullscreen. @@ -237,6 +246,15 @@ advanced_camera_card_action: live_substream_select | `advanced_camera_card_action` | Must be `live_substream_select`. | | `camera` | The [camera ID](../../cameras/README.md?id=cameras) of the substream to select. | +## `media` + +Change to the `media` view. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: media +``` + ## `log` Log a message to the Javascript console. @@ -463,6 +481,24 @@ action: custom:advanced-camera-card-action advanced_camera_card_action: recordings ``` +## `review` + +Change to the `review` view. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: review +``` + +## `reviews` + +Change to the `reviews` view. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: reviews +``` + ## `reload` Reload the entire dashboard. @@ -591,6 +627,33 @@ elements: tap_action: action: custom:advanced-camera-card-action advanced_camera_card_action: camera_ui + - type: custom:advanced-camera-card-menu-icon + icon: mdi:play-box-multiple + title: Show media gallery + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: gallery + hold_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: media + - type: custom:advanced-camera-card-menu-icon + icon: mdi:filmstrip + title: Show most recent media + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: media + - type: custom:advanced-camera-card-menu-icon + icon: mdi:alpha-r-circle-outline + title: Show most recent review + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: review + - type: custom:advanced-camera-card-menu-icon + icon: mdi:alpha-s-circle-outline + title: Show reviews + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: reviews - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-d-circle title: Show most recent clip diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 8affc6b7..4adcb8e7 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -390,7 +390,7 @@ cameras: | `events` | `[events, clips, snapshots]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). | | `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. | | `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. | -| `reviews` | | Configuration for triggering on Frigate review items. See below. Frigate-specific. | +| `reviews` | | Configuration for triggering on review items. Currently only supported by Frigate. See below. | ### `reviews` diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index cf773411..1ef95799 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -361,9 +361,9 @@ conditions: > [!IMPORTANT] > Internally, views associated with the media viewer (e.g. `clip`, `snapshot`, -> `recording`) are translated to a special view called `media` after the relevant -> media is fetched. When including views as part of a [condition](conditions.md), -> you may need to refer to this special `media` view. +> `review` `recording`) are translated to the `media` view after the relevant +> media is fetched. When including views as part of a +> [condition](conditions.md), you may need to refer to the `media` view. ## Fully expanded reference diff --git a/docs/configuration/media-gallery.md b/docs/configuration/media-gallery.md index 85f06fb4..cf269dfe 100644 --- a/docs/configuration/media-gallery.md +++ b/docs/configuration/media-gallery.md @@ -1,18 +1,17 @@ # `media_gallery` The `media_gallery` is used for providing an overview of all `clips`, -`snapshots`, `recordings`, `reviews` and `folder` contents in a thumbnail -gallery. +`snapshots`, `recordings`, `reviews` and `folders`. ```yaml media_gallery: # [...] ``` -| Option | Default | Description | -| ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `folder`, `snapshots`, `recordings`). | -| `controls` | | Configuration for the Media Gallery controls. See below. | +| Option | Default | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`, `reviews`, `folders`, `gallery`). | +| `controls` | | Configuration for the Media Gallery controls. See below. | ## `controls` diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index cf267269..7f43cd1a 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -1,6 +1,6 @@ # `media_viewer` -The `media_player` section configures viewing all `clip`, `snapshot` or `recording` media, in either a media carousel or grid. +The `media_viewer` section configures viewing all `clip`, `snapshot`, `recording`, `review`, `folder` or `media` view, in either a media carousel or grid. ```yaml media_viewer: @@ -9,7 +9,7 @@ media_viewer: | Option | Default | Description | | --------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). | +| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `media`, `clip`, `snapshot`, `recording`, `review`, `folder`). | | `auto_mute` | `[unselected, hidden]` | A list of conditions in which media items are muted. `unselected` will automatically mute when a media item is unselected in the carousel or grid. `hidden` will automatically mute when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically mute. | | `auto_pause` | `[unselected, hidden]` | A list of conditions in which media items are automatically paused. `unselected` will automatically pause when a media item is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. | | `auto_play` | `[selected, visible]` | A list of conditions in which media items are automatically played. `selected` will automatically play when a media item is selected in a carousel or grid. `visible` will automatically play when a media item becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. | diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 290d7ef8..080fd2aa 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -130,7 +130,7 @@ action will be taken on card startup. | Option | Default | Description | | ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. | -| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | +| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip`, `snapshot`, `review`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | | `untrigger` | `none` | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. | ## Supported views @@ -143,12 +143,14 @@ This card supports several different views. | `clips` | Shows a gallery of clips for this camera. | | `folder` | Shows a viewer for the media from the default [`folder`](./folders.md). | | `folders` | Shows a gallery of media and subfolders from the default [`folder`](./folders.md). | +| `gallery` | Shows a gallery of media for this camera's default [media type](./cameras/README.md?id=media). | | `image` | Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`). | | `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). | +| `media` | Shows a viewer for the most recent media for this camera. | | `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. | | `recordings` | Shows a gallery of recent (last day) recordings for this camera and its dependents. | | `review` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). | -| `reviews` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). | +| `reviews` | Shows a gallery of reviews for this camera and its dependents. | | `snapshot` | Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon. | | `snapshots` | Shows a gallery of snapshots for this camera. | | `timeline` | Shows an event timeline. | diff --git a/docs/usage/url-actions.md b/docs/usage/url-actions.md index 620ad46c..75683d34 100644 --- a/docs/usage/url-actions.md +++ b/docs/usage/url-actions.md @@ -4,7 +4,7 @@ It is possible to pass the Advanced Camera Card one or more [actions](../configuration/actions/README.md) from the URL (e.g. select a particular camera, open the live view in expanded mode, etc). -### When actions are executed +## When actions are executed The Advanced Camera Card will execute these actions in the following circumstances: @@ -19,13 +19,13 @@ The Advanced Camera Card will execute these actions in the following circumstanc To send an action to _all_ Advanced Camera Cards on a dashboard: -``` +```text [PATH_TO_YOUR_HA_DASHBOARD]?advanced-camera-card-action.[ACTION]=[VALUE] ``` To send an action to a specific named Advanced Camera Card: -``` +```text [PATH_TO_YOUR_HA_DASHBOARD]?advanced-camera-card-action.[CARD_ID].[ACTION]=[VALUE] ``` @@ -58,12 +58,17 @@ Only a subset of all [actions](../configuration/actions/README.md) are supported | `clip` | :white_check_mark: | | | `clips` | :white_check_mark: | | | `default` | :white_check_mark: | | +| `diagnostics` | :white_check_mark: | | | `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. | | `expand` | :white_check_mark: | | +| `folder` | :white_check_mark: | | +| `folders` | :white_check_mark: | | | `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | +| `gallery` | :white_check_mark: | | | `image` | :white_check_mark: | | | `live_substream_select` | :white_check_mark: | | | `live` | :white_check_mark: | | +| `media` | :white_check_mark: | | | `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `menu_toggle` | :white_check_mark: | | | `microphone_connect`, `microphone_disconnect`, `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | | @@ -72,6 +77,8 @@ Only a subset of all [actions](../configuration/actions/README.md) are supported | `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `recording` | :white_check_mark: | | | `recordings` | :white_check_mark: | | +| `review` | :white_check_mark: | | +| `reviews` | :white_check_mark: | | | `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. | | `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `snapshot` | :white_check_mark: | | diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 409ae770..dd5a11e4 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -91,8 +91,10 @@ export class ActionFactory { case 'clips': case 'folder': case 'folders': + case 'gallery': case 'image': case 'live': + case 'media': case 'recording': case 'recordings': case 'review': diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 0783165e..4665f070 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -131,10 +131,16 @@ export class QueryStringManager { case 'clip': case 'clips': case 'diagnostics': + case 'folder': + case 'folders': + case 'gallery': case 'image': case 'live': + case 'media': case 'recording': case 'recordings': + case 'review': + case 'reviews': case 'snapshot': case 'snapshots': case 'timeline': @@ -159,10 +165,16 @@ export class QueryStringManager { case 'clip': case 'clips': case 'diagnostics': + case 'folder': + case 'folders': + case 'gallery': case 'image': case 'live': + case 'media': case 'recording': case 'recordings': + case 'review': + case 'reviews': case 'snapshot': case 'snapshots': case 'timeline': diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts index 2d101e0a..ae790121 100644 --- a/src/card-controller/view/view-query-executor.ts +++ b/src/card-controller/view/view-query-executor.ts @@ -105,23 +105,30 @@ export class ViewQueryExecutor { const cameraForQuery = view.isGrid() ? undefined : view.camera; + const getDefaultQueryModifiers = async () => { + const query = builder.buildDefaultCameraQuery(cameraForQuery, { + limit: this._getLimit(), + }); + return await executeQuery(query); + }; + switch (view.view) { case 'live': if (config.live.controls.thumbnails.mode !== 'none') { - const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery); - if (defaultQuery) { - viewModifiers.push(...(await executeQuery(defaultQuery))); - } + viewModifiers.push(...(await getDefaultQueryModifiers())); } break; case 'timeline': // Timeline view always queries all cameras with media capabilities. viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery()))); break; + case 'gallery': case 'media': - // If the user is looking at media in the `media` view and then - // changes camera (via the menu) it should default to showing clips - // for the new camera. + // If the user is looking at media in the `media` view and then changes + // camera (via the menu) it should default to the default camera media + // type. + viewModifiers.push(...(await getDefaultQueryModifiers())); + break; case 'clip': case 'clips': viewModifiers.push( diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index c6ef7f4a..58600fc8 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -9,7 +9,6 @@ import { AdvancedCameraCardView, VIEWS_USER_SPECIFIED, } from '../config/schema/common/const'; -import { MenuItemBase } from '../config/schema/elements/custom/menu/base'; import { MenuItem } from '../config/schema/elements/custom/menu/types'; import { AdvancedCameraCardConfig } from '../config/schema/types'; import { getEntityTitle } from '../ha/get-entity-title'; @@ -82,6 +81,7 @@ export class MenuButtonController { this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view), this._getRecordingsButton(config, cameraManager, foldersManager, options?.view), this._getReviewsButton(config, cameraManager, foldersManager, options?.view), + this._getGalleryButton(config, cameraManager, foldersManager, options?.view), this._getImageButton(config, cameraManager, foldersManager, options?.view), this._getTimelineButton(config, cameraManager, foldersManager, options?.view), this._getDownloadButton(config, cameraManager, options?.view), @@ -252,45 +252,17 @@ export class MenuButtonController { : null; } - /** - * Determine if a media button (clips/snapshots) should be shown. These are - * hidden by default if reviews are supported. - */ - protected _shouldShowEventMediaButton( - viewName: 'clips' | 'snapshots', - buttonConfig: MenuItemBase, - cameraManager: CameraManager, - foldersManager: FoldersManager, - view?: View | null, - ): boolean { - const supportsEventView = - view && - isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera); - const supportsReviewsView = - view && - isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera); - - // Show if: explicitly enabled OR (supports view AND reviews not supported) - return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView); - } - protected _getClipsButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return this._shouldShowEventMediaButton( - 'clips', - config.menu.buttons.clips, - cameraManager, - foldersManager, - view, - ) + return view && + isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:filmstrip', ...config.menu.buttons.clips, - enabled: true, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, @@ -306,17 +278,11 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return this._shouldShowEventMediaButton( - 'snapshots', - config.menu.buttons.snapshots, - cameraManager, - foldersManager, - view, - ) + return view && + isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:camera', ...config.menu.buttons.snapshots, - enabled: true, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, @@ -355,7 +321,7 @@ export class MenuButtonController { return view && isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera) ? { - icon: 'mdi:play-box-multiple', + icon: 'mdi:play-box-edit-outline', ...config.menu.buttons.reviews, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.reviews'), @@ -366,6 +332,26 @@ export class MenuButtonController { : null; } + protected _getGalleryButton( + config: AdvancedCameraCardConfig, + cameraManager: CameraManager, + foldersManager: FoldersManager, + view?: View | null, + ): MenuItem | null { + return view && + isViewSupportedByCamera('gallery', cameraManager, foldersManager, view.camera) + ? { + icon: 'mdi:play-box-multiple', + ...config.menu.buttons.gallery, + type: 'custom:advanced-camera-card-menu-icon', + title: localize('config.menu.buttons.gallery'), + style: view.is('gallery') ? this._getEmphasizedStyle() : {}, + tap_action: createViewAction('gallery'), + hold_action: createViewAction('media'), + } + : null; + } + protected _getImageButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, diff --git a/src/config/schema/common/const.ts b/src/config/schema/common/const.ts index 572bd437..aa07b1c8 100644 --- a/src/config/schema/common/const.ts +++ b/src/config/schema/common/const.ts @@ -18,6 +18,8 @@ export const VIEWS_USER_SPECIFIED = [ 'clips', 'folder', 'folders', + 'gallery', + 'media', 'snapshot', 'snapshots', 'recording', @@ -28,7 +30,4 @@ export const VIEWS_USER_SPECIFIED = [ 'timeline', ] as const; export type AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number]; -export type AdvancedCameraCardView = - | AdvancedCameraCardUserSpecifiedView - | 'media' - | 'diagnostics'; +export type AdvancedCameraCardView = AdvancedCameraCardUserSpecifiedView | 'diagnostics'; diff --git a/src/config/schema/menu.ts b/src/config/schema/menu.ts index 232b0609..25eb7358 100644 --- a/src/config/schema/menu.ts +++ b/src/config/schema/menu.ts @@ -39,6 +39,7 @@ export const menuConfigDefault = { fullscreen: visibleButtonDefault, image: hiddenButtonDefault, info: visibleButtonDefault, + gallery: visibleButtonDefault, live: visibleButtonDefault, media_player: visibleButtonDefault, microphone: { @@ -49,7 +50,7 @@ export const menuConfigDefault = { play: hiddenButtonDefault, ptz_controls: hiddenButtonDefault, recordings: hiddenButtonDefault, - reviews: visibleButtonDefault, + reviews: hiddenButtonDefault, set_review: visibleButtonDefault, screenshot: hiddenButtonDefault, snapshots: hiddenButtonDefault, @@ -91,6 +92,7 @@ export const menuConfigSchema = z fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), info: visibleButtonSchema.default(menuConfigDefault.buttons.info), + gallery: visibleButtonSchema.default(menuConfigDefault.buttons.gallery), live: visibleButtonSchema.default(menuConfigDefault.buttons.live), media_player: visibleButtonSchema.default( menuConfigDefault.buttons.media_player, @@ -106,7 +108,7 @@ export const menuConfigSchema = z play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), - reviews: visibleButtonSchema.default(menuConfigDefault.buttons.reviews), + reviews: hiddenButtonSchema.default(menuConfigDefault.buttons.reviews), set_review: visibleButtonSchema.default(menuConfigDefault.buttons.set_review), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots), diff --git a/src/editor.ts b/src/editor.ts index 0d6ca4a5..02240d27 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -368,7 +368,7 @@ const options: EditorOptions = { secondary: localize('editor.folders'), }, media_gallery: { - icon: 'grid', + icon: 'play-box-multiple', name: localize('editor.media_gallery'), secondary: localize('editor.media_gallery_secondary'), }, @@ -432,8 +432,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: 'clips', label: localize('config.view.views.clips') }, { value: 'folder', label: localize('config.view.views.folder') }, { value: 'folders', label: localize('config.view.views.folders') }, + { value: 'gallery', label: localize('config.view.views.gallery') }, { value: 'image', label: localize('config.view.views.image') }, { value: 'live', label: localize('config.view.views.live') }, + { value: 'media', label: localize('config.view.views.media') }, { value: 'recording', label: localize('config.view.views.recording') }, { value: 'recordings', label: localize('config.view.views.recordings') }, { value: 'review', label: localize('config.view.views.review') }, @@ -2927,6 +2929,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderMenuButton('expand') /* */} ${this._renderMenuButton('folders')} ${this._renderMenuButton('fullscreen')} + ${this._renderMenuButton('gallery')} ${this._renderMenuButton('image') /* */} ${this._renderMenuButton('info') /* */} ${this._renderMenuButton('live')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index fbbeea5c..f44511a1 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -459,11 +459,13 @@ "expand": "Expand", "folders": "Folders", "fullscreen": "Fullscreen", + "gallery": "Gallery", "icon": "Icon", "image": "Image", "info": "Info", "iris": "Iris / Default View / Unhide menu", "live": "Live", + "media": "Media", "media_player": "Send to media player", "microphone": "Microphone", "mute": "Mute / Unmute", @@ -631,8 +633,10 @@ "current": "Current view", "folder": "Folder media", "folders": "Folders gallery", + "gallery": "Default media gallery", "image": "Static image", "live": "Live view", + "media": "Most recent default media item", "recording": "Most recent recording", "recordings": "Recordings gallery", "review": "Most recent media to review", diff --git a/src/scss/themes/light.scss b/src/scss/themes/light.scss index 8566d1d7..1f7df76c 100644 --- a/src/scss/themes/light.scss +++ b/src/scss/themes/light.scss @@ -538,5 +538,4 @@ --advanced-camera-card-overlay-message-scrollbar-color: rgba(0, 0, 0, 0.5); --advanced-camera-card-overlay-message-close-color: #000000; --app-header-text-color: white; - } diff --git a/src/view/view-support.ts b/src/view/view-support.ts index 14304692..e53b8375 100644 --- a/src/view/view-support.ts +++ b/src/view/view-support.ts @@ -18,7 +18,6 @@ export const getCameraIDsForViewName = ( switch (viewName) { case 'diagnostics': case 'image': - case 'media': return cameraManager.getStore().getCameraIDs(); case 'folder': @@ -51,11 +50,13 @@ export const getCameraIDsForViewName = ( ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options) : cameraManager.getStore().getCameraIDsWithCapability(capability, options); + case 'gallery': + case 'media': case 'timeline': return folder ? cameraManager.getStore().getCameraIDs() : cameraManager.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings'], + anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'], }); } }; diff --git a/src/view/view.ts b/src/view/view.ts index 5ab1d751..53bfe0d8 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -127,9 +127,14 @@ export class View { * Determine if a view is a gallery. */ public isGalleryView(): boolean { - return ['clips', 'folders', 'snapshots', 'recordings', 'reviews'].includes( - this.view, - ); + return [ + 'clips', + 'folders', + 'gallery', + 'snapshots', + 'recordings', + 'reviews', + ].includes(this.view); } /** diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts index 79b33c37..14931557 100644 --- a/tests/card-controller/view/view-query-executor.test.ts +++ b/tests/card-controller/view/view-query-executor.test.ts @@ -568,48 +568,54 @@ describe('ViewQueryExecutor', () => { }); }); - describe('with a media view', () => { - it('should set query and queryResults for events', async () => { - const api = createPopulatedAPI(); - const cameraManager = api.getCameraManager(); - if (cameraManager) { - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - new Capabilities({ clips: true }), - ); - } + describe('with a media or gallery view', () => { + it.each(['media' as const, 'gallery' as const])( + 'should set query and queryResults for events with %s view', + async (viewName) => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ clips: true }), + ); + } - const viewQueryExecutor = new ViewQueryExecutor(api); - const view = new View({ - view: 'media', - camera: 'camera.office', - }); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: viewName, + camera: 'camera.office', + }); - const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); - applyViewModifiers(view, modifiers); + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); - expect(view.query).toBeInstanceOf(UnifiedQuery); - const queries = view.query?.getMediaQueries({ type: QueryType.Event }); - expect(queries?.length).toBeGreaterThan(0); - expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); - expect(view.queryResults).toBeDefined(); - }); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + expect(view.queryResults).toBeDefined(); + }, + ); - it('should query all cameras in media view grid mode', async () => { - const api = createPopulatedAPI(); - const viewQueryExecutor = new ViewQueryExecutor(api); - const view = createView({ - view: 'media', - camera: 'camera.office', - displayMode: 'grid', - }); + it.each(['media' as const, 'gallery' as const])( + 'should query all cameras in %s view grid mode', + async (viewName) => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: viewName, + camera: 'camera.office', + displayMode: 'grid', + }); - const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); - applyViewModifiers(view, modifiers); + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); - expect(view.query).toBeInstanceOf(UnifiedQuery); - const allCameraIDs = view.query?.getAllCameraIDs(); - expect(allCameraIDs?.size).toBeGreaterThan(0); - }); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const allCameraIDs = view.query?.getAllCameraIDs(); + expect(allCameraIDs?.size).toBeGreaterThan(0); + }, + ); }); describe('with an events-based view', () => { diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 1a8d4c6f..d0b5417d 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -561,9 +561,7 @@ describe('MenuButtonController', () => { describe('should have clips menu button', () => { it('when in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view !== 'reviews', - ); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -571,7 +569,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ icon: 'mdi:filmstrip', - enabled: true, + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Clips gallery', @@ -583,16 +581,14 @@ describe('MenuButtonController', () => { it('when not in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view !== 'reviews', - ); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ icon: 'mdi:filmstrip', - enabled: true, + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Clips gallery', @@ -613,30 +609,6 @@ describe('MenuButtonController', () => { expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]), ); }); - - it('should be hidden when reviews are supported', () => { - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view === 'clips' || view === 'reviews', - ); - const buttons = calculateButtons(controller); - - expect(buttons).not.toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]), - ); - }); - - it('should be shown when reviews are supported but button is explicitly enabled', () => { - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view === 'clips' || view === 'reviews', - ); - const buttons = calculateButtons(controller, { - config: createConfig({ menu: { buttons: { clips: { enabled: true } } } }), - }); - - expect(buttons).toContainEqual( - expect.objectContaining({ title: 'Clips gallery', enabled: true }), - ); - }); }); describe('should have snapshots menu button', () => { @@ -652,7 +624,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ icon: 'mdi:camera', - enabled: true, + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Snapshots gallery', @@ -670,16 +642,14 @@ describe('MenuButtonController', () => { it('when not in snapshots view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view !== 'reviews', - ); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ icon: 'mdi:camera', - enabled: true, + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Snapshots gallery', @@ -708,32 +678,6 @@ describe('MenuButtonController', () => { ]), ); }); - - it('should be hidden when reviews are supported', () => { - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view === 'snapshots' || view === 'reviews', - ); - const buttons = calculateButtons(controller); - - expect(buttons).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ title: 'Snapshots gallery' }), - ]), - ); - }); - - it('should be shown when reviews are supported but button is explicitly enabled', () => { - vi.mocked(isViewSupportedByCamera).mockImplementation( - (view) => view === 'snapshots' || view === 'reviews', - ); - const buttons = calculateButtons(controller, { - config: createConfig({ menu: { buttons: { snapshots: { enabled: true } } } }), - }); - - expect(buttons).toContainEqual( - expect.objectContaining({ title: 'Snapshots gallery', enabled: true }), - ); - }); }); describe('should have reviews menu button', () => { @@ -746,8 +690,8 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ - icon: 'mdi:play-box-multiple', - enabled: true, + icon: 'mdi:play-box-edit-outline', + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Reviews gallery', @@ -771,8 +715,8 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ - icon: 'mdi:play-box-multiple', - enabled: true, + icon: 'mdi:play-box-edit-outline', + enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Reviews gallery', @@ -801,6 +745,71 @@ describe('MenuButtonController', () => { }); }); + describe('should have gallery menu button', () => { + it('when in gallery view', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'gallery' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:play-box-multiple', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Gallery', + style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'gallery', + }, + hold_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'media', + }, + }); + }); + + it('when not in gallery view', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:play-box-multiple', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Gallery', + style: {}, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'gallery', + }, + hold_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'media', + }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Gallery' })]), + ); + }); + }); + describe('should have recordings menu button', () => { it('when in recordings view', () => { const viewManager = mock(); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index e238423e..91c28b22 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -231,6 +231,10 @@ describe('config defaults', () => { enabled: true, priority: 50, }, + gallery: { + enabled: true, + priority: 50, + }, image: { enabled: false, priority: 50, @@ -277,7 +281,7 @@ describe('config defaults', () => { priority: 50, }, reviews: { - enabled: true, + enabled: false, priority: 50, }, set_review: { diff --git a/tests/view/view-support.test.ts b/tests/view/view-support.test.ts index 757392e7..e9b62c33 100644 --- a/tests/view/view-support.test.ts +++ b/tests/view/view-support.test.ts @@ -17,7 +17,7 @@ import { describe('getCameraIDsForViewName', () => { describe('views that are always supported', () => { - it.each([['diagnostics' as const], ['image' as const], ['media' as const]])( + it.each([['diagnostics' as const], ['image' as const]])( '%s', (viewName: AdvancedCameraCardView) => { const cameraManager = createCameraManager(); @@ -51,6 +51,15 @@ describe('getCameraIDsForViewName', () => { ['timeline' as const, 'clips' as const], ['timeline' as const, 'recordings' as const], ['timeline' as const, 'snapshots' as const], + ['timeline' as const, 'reviews' as const], + ['media' as const, 'clips' as const], + ['media' as const, 'recordings' as const], + ['media' as const, 'snapshots' as const], + ['media' as const, 'reviews' as const], + ['gallery' as const, 'clips' as const], + ['gallery' as const, 'recordings' as const], + ['gallery' as const, 'snapshots' as const], + ['gallery' as const, 'reviews' as const], ])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( @@ -111,67 +120,73 @@ describe('getCameraIDsForViewName', () => { describe('views that respect a folder', () => { describe('should return cameras when a folder is present', () => { - it.each([['folder' as const], ['folders' as const], ['timeline' as const]])( - '%s', - (viewName: AdvancedCameraCardView) => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { - cameraID: 'camera-2', - }, - ]), - ); - const foldersManager = mock(); - vi.mocked(foldersManager.getFolder).mockReturnValue(createFolder()); + it.each([ + ['folder' as const], + ['folders' as const], + ['timeline' as const], + ['media' as const], + ['gallery' as const], + ])('%s', (viewName: AdvancedCameraCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + }, + ]), + ); + const foldersManager = mock(); + vi.mocked(foldersManager.getFolder).mockReturnValue(createFolder()); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager), - ).toEqual(new Set(['camera-1', 'camera-2'])); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), - ).toEqual(new Set(['camera-1', 'camera-2'])); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), - ).toEqual(new Set(['camera-1', 'camera-2'])); - }, - ); + expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( + new Set(['camera-1', 'camera-2']), + ); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + }); }); describe('should not return cameras when a folder is absent', () => { - it.each([['folder' as const], ['folders' as const], ['timeline' as const]])( - '%s', - (viewName: AdvancedCameraCardView) => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { - cameraID: 'camera-2', - }, - ]), - ); - const foldersManager = mock(); - vi.mocked(foldersManager.getFolder).mockReturnValue(null); + it.each([ + ['folder' as const], + ['folders' as const], + ['timeline' as const], + ['media' as const], + ['gallery' as const], + ])('%s', (viewName: AdvancedCameraCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + }, + ]), + ); + const foldersManager = mock(); + vi.mocked(foldersManager.getFolder).mockReturnValue(null); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager), - ).toEqual(new Set()); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), - ).toEqual(new Set()); - expect( - getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), - ).toEqual(new Set()); - }, - ); + expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( + new Set(), + ); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set()); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set()); + }); }); }); });