diff --git a/README.md b/README.md index d18b6a09..4dad4981 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ menu: | `download` | :white_check_mark: | The `download` menu button: allow direct download of the media being displayed.| | `frigate_ui` | :white_check_mark: | The `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| | `fullscreen` | :white_check_mark: | The `fullscreen` menu button: expand the card to consume the fullscreen. | +| `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. | +| `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | ##### Configuration on each button @@ -680,7 +682,7 @@ Parameters for the `custom:frigate-card-conditional` element: | Action name | Description | | - | - | -| `custom:frigate-card-action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`.| +| `custom:frigate-card-action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.| | Value | Description | | - | - | @@ -691,6 +693,8 @@ Parameters for the `custom:frigate-card-conditional` element: |`fullscreen`|Toggle fullscreen.| |`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera.| |`menu_toggle` | Show/hide the menu (for the `hidden` mode style). | +|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. | + diff --git a/src/card.ts b/src/card.ts index 838cf5bc..91e7989d 100644 --- a/src/card.ts +++ b/src/card.ts @@ -51,6 +51,8 @@ import { getCameraIcon, getCameraID, getCameraTitle, + getEntityIcon, + getEntityTitle, homeAssistantSignPath, homeAssistantWSRequest, isValidMediaShowInfo, @@ -345,7 +347,7 @@ export class FrigateCard extends LitElement { state_color: true, title: getCameraTitle(this._hass, config), selected: this._view?.camera === camera, - tap_action: createFrigateCardCustomAction('camera_select', camera), + tap_action: createFrigateCardCustomAction('camera_select', { camera: camera }), }; }); @@ -465,6 +467,44 @@ export class FrigateCard extends LitElement { }); } + const mediaPlayers = Object.keys(this._hass?.states || {}).filter((entity) => + entity.startsWith('media_player.'), + ); + if ( + mediaPlayers.length && + (this._view?.isViewerView() || + (this._view?.is('live') && cameraConfig?.camera_entity)) + ) { + const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { + const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; + const state = this._hass?.states[playerEntityID]; + return { + icon: getEntityIcon(this._hass, playerEntityID) || 'mdi:cast', + entity: playerEntityID, + state_color: false, + title: title, + subtitle: title === playerEntityID ? undefined : playerEntityID, + disabled: !state || state.state === 'unavailable', + tap_action: createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'play', + }), + hold_action: createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'stop', + }), + }; + }); + + buttons.push({ + icon: 'mdi:cast', + ...this._getConfig().menu.buttons.media_player, + type: 'custom:frigate-card-menu-submenu', + title: localize('config.menu.buttons.media_player'), + items: mediaPlayerItems, + }); + } + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(button), ...button, @@ -886,6 +926,51 @@ export class FrigateCard extends LitElement { } } + /** + * Take a media player action. + * @param mediaPlayer The entity ID of the media player. + * @param action The action to take (currently only 'play' is supported). + * @returns + */ + protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void { + if (!['play', 'stop'].includes(action)) { + return; + } + + let media_content_id: string; + let media_content_type: string; + let thumbnail: string | null = null; + const cameraEntity = this._getSelectedCameraConfig()?.camera_entity ?? null; + + if (this._view?.isViewerView() && this._view.media) { + media_content_id = this._view.media.media_content_id; + media_content_type = this._view.media.media_content_type; + thumbnail = this._view.media.thumbnail; + } else if (this._view?.is('live') && cameraEntity) { + if (this._hass?.states && cameraEntity in this._hass.states) { + thumbnail = this._hass.states[cameraEntity].attributes.entity_picture ?? null; + } + media_content_id = `media-source://camera/${cameraEntity}`; + media_content_type = 'application/vnd.apple.mpegurl'; + } else { + return; + } + + if (action === 'play') { + this._hass?.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: media_content_id, + media_content_type: media_content_type, + extra: thumbnail ? { thumb: thumbnail } : {}, + }); + } else if (action === 'stop') { + console.info('stopping'); + this._hass?.callService('media_player', 'media_stop', { + entity_id: mediaPlayer, + }); + } + } + /** * Handle a request for a card action. * @param ev The action requested. @@ -955,6 +1040,12 @@ export class FrigateCard extends LitElement { }); } break; + case 'media_player': + this._mediaPlayerAction( + frigateCardAction.media_player, + frigateCardAction.media_player_action, + ); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } diff --git a/src/common.ts b/src/common.ts index 0ca79446..75a3c70d 100644 --- a/src/common.ts +++ b/src/common.ts @@ -285,20 +285,35 @@ export function convertActionToFrigateCardCustomAction( /** * Create a Frigate card custom action. * @param action The Frigate card action string (e.g. 'fullscreen') - * @returns A FrigateCardCustomAction for that action string. + * @returns A FrigateCardCustomAction for that action string or null. */ export function createFrigateCardCustomAction( action: FrigateCardAction, - camera?: string, -): FrigateCardCustomAction | undefined { - if (action == 'camera_select') { - if (!camera) { - return undefined; + args?: { + camera?: string, + media_player?: string, + media_player_action?: 'play' | 'stop', + } +): FrigateCardCustomAction | null { + if (action === 'camera_select') { + if (!args?.camera) { + return null; } return { action: 'fire-dom-event', frigate_card_action: action, - camera: camera, + camera: args.camera as string, + }; + } + if (action === 'media_player') { + if (!args?.media_player || !args.media_player_action) { + return null; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + media_player: args.media_player, + media_player_action: args.media_player_action, }; } return { diff --git a/src/components/submenu.ts b/src/components/submenu.ts index 2d1cf9c0..2e25a398 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -36,8 +36,10 @@ export class FrigateCardSubmenu extends LitElement { { // Attach the action config so ascendants have access to it. @@ -48,7 +50,10 @@ export class FrigateCardSubmenu extends LitElement { hasDoubleClick: frigateCardHasAction(item.double_tap_action), })} > - ${stateParameters.title || ''} + ${stateParameters.title || ''} + ${item.subtitle + ? html`${item.subtitle}` + : ''} ${stateParameters.icon ? html` ` diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6f9d0db9..df570e84 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -160,6 +160,7 @@ "buttons": { "frigate": "Frigate menu / Default view", "live": "Live", + "media_player": "Media Player", "clips": "Clips", "snapshots": "Snapshots", "image": "Image", diff --git a/src/types.ts b/src/types.ts index 9c0904a0..5b0f2b23 100644 --- a/src/types.ts +++ b/src/types.ts @@ -179,7 +179,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'fullscreen', 'menu_toggle', ] as const; -const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const; +const FRIGATE_CARD_ACTIONS = [ + ...FRIGATE_CARD_GENERAL_ACTIONS, + 'camera_select', + 'media_player', +] as const; export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number]; const frigateCardGeneralActionSchema = frigateCardCustomactionsBaseSchema.extend({ @@ -189,9 +193,16 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomactionsBaseSchema.e frigate_card_action: z.literal('camera_select'), camera: z.string(), }); +const frigateCarMediaPlayerPlayActionSchema = frigateCardCustomactionsBaseSchema.extend({ + frigate_card_action: z.literal('media_player'), + media_player: z.string(), + media_player_action: z.enum(['play', 'stop']), +}); + export const frigateCardCustomActionSchema = z.union([ frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, + frigateCarMediaPlayerPlayActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -401,6 +412,8 @@ const menuSubmenuItemSchema = elementsBaseSchema.extend({ icon: z.string().optional(), state_color: z.boolean().default(true).optional(), selected: z.boolean().default(false).optional(), + subtitle: z.string().optional(), + disabled: z.boolean().optional(), }); export type MenuSubmenuItem = z.infer; @@ -689,6 +702,7 @@ const menuConfigDefault = { download: visibleButtonDefault, frigate_ui: visibleButtonDefault, fullscreen: visibleButtonDefault, + media_player: visibleButtonDefault, }, button_size: 40, }; @@ -719,6 +733,9 @@ const menuConfigSchema = z download: visibleButtonSchema.default(menuConfigDefault.buttons.download), frigate_ui: visibleButtonSchema.default(menuConfigDefault.buttons.frigate_ui), fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), + media_player: visibleButtonSchema.default( + menuConfigDefault.buttons.media_player, + ), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),