From d733498445a2c7566ceed83dad89b64d4cc6519e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 1 May 2022 22:34:48 -0700 Subject: [PATCH 1/6] Initial media_player (cast) support. --- README.md | 6 ++- src/card.ts | 93 +++++++++++++++++++++++++++++++++- src/common.ts | 29 ++++++++--- src/components/submenu.ts | 7 ++- src/editor.ts | 1 + src/localize/languages/en.json | 1 + src/types.ts | 19 ++++++- 7 files changed, 145 insertions(+), 11 deletions(-) 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), From 14fb1c65eb7f967f6f1aaff0dcaf949579f10b4f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 1 May 2022 22:40:29 -0700 Subject: [PATCH 2/6] Codereview fixes. --- src/card.ts | 1 - src/types.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/card.ts b/src/card.ts index 91e7989d..98cd91f2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -964,7 +964,6 @@ export class FrigateCard extends LitElement { extra: thumbnail ? { thumb: thumbnail } : {}, }); } else if (action === 'stop') { - console.info('stopping'); this._hass?.callService('media_player', 'media_stop', { entity_id: mediaPlayer, }); diff --git a/src/types.ts b/src/types.ts index 5b0f2b23..03fe6866 100644 --- a/src/types.ts +++ b/src/types.ts @@ -193,7 +193,7 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomactionsBaseSchema.e frigate_card_action: z.literal('camera_select'), camera: z.string(), }); -const frigateCarMediaPlayerPlayActionSchema = frigateCardCustomactionsBaseSchema.extend({ +const frigateCarMediaPlayerActionSchema = frigateCardCustomactionsBaseSchema.extend({ frigate_card_action: z.literal('media_player'), media_player: z.string(), media_player_action: z.enum(['play', 'stop']), @@ -202,7 +202,7 @@ const frigateCarMediaPlayerPlayActionSchema = frigateCardCustomactionsBaseSchema export const frigateCardCustomActionSchema = z.union([ frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, - frigateCarMediaPlayerPlayActionSchema, + frigateCarMediaPlayerActionSchema, ]); export type FrigateCardCustomAction = z.infer; From e90d56371c7f1ccd25d4169d8f0a0e62d287e327 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 1 May 2022 22:50:18 -0700 Subject: [PATCH 3/6] Break out actions for type safety. --- src/card.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/card.ts b/src/card.ts index 98cd91f2..c3486b8c 100644 --- a/src/card.ts +++ b/src/card.ts @@ -341,13 +341,16 @@ export class FrigateCard extends LitElement { if (this._cameras && this._cameras.size > 1) { const menuItems = Array.from(this._cameras, ([camera, config]) => { + const action = createFrigateCardCustomAction('camera_select', { + camera: camera, + }); return { icon: getCameraIcon(this._hass, config), entity: config.camera_entity, state_color: true, title: getCameraTitle(this._hass, config), selected: this._view?.camera === camera, - tap_action: createFrigateCardCustomAction('camera_select', { camera: camera }), + ...(action && { tap_action: action }), }; }); @@ -478,6 +481,15 @@ export class FrigateCard extends LitElement { const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; const state = this._hass?.states[playerEntityID]; + const playAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'play', + }); + const stopAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'stop', + }); + return { icon: getEntityIcon(this._hass, playerEntityID) || 'mdi:cast', entity: playerEntityID, @@ -485,14 +497,8 @@ export class FrigateCard extends LitElement { 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', - }), + ...(playAction && { tap_action: playAction }), + ...(stopAction && { hold_action: stopAction }), }; }); From 320948f4752078e4d3b48bc4ba66de82cfeaa694 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 May 2022 12:50:38 -0700 Subject: [PATCH 4/6] Set title on casted media. --- src/card.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/card.ts b/src/card.ts index c3486b8c..3e9d31e0 100644 --- a/src/card.ts +++ b/src/card.ts @@ -945,17 +945,21 @@ export class FrigateCard extends LitElement { let media_content_id: string; let media_content_type: string; - let thumbnail: string | null = null; - const cameraEntity = this._getSelectedCameraConfig()?.camera_entity ?? null; + const extra = {}; + const cameraConfig = this._getSelectedCameraConfig(); + const cameraEntity = cameraConfig?.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; + extra['thumb'] = this._view.media.thumbnail; + extra['title'] = this._view.media.title; } 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; + extra['thumb'] = + this._hass.states[cameraEntity].attributes.entity_picture ?? null; } + extra['title'] = getCameraTitle(this._hass, cameraConfig); media_content_id = `media-source://camera/${cameraEntity}`; media_content_type = 'application/vnd.apple.mpegurl'; } else { @@ -967,7 +971,7 @@ export class FrigateCard extends LitElement { entity_id: mediaPlayer, media_content_id: media_content_id, media_content_type: media_content_type, - extra: thumbnail ? { thumb: thumbnail } : {}, + extra: extra, }); } else if (action === 'stop') { this._hass?.callService('media_player', 'media_stop', { From 8dbd0107beac0ec9905b0df3da0f9155f437b010 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 May 2022 19:42:34 -0700 Subject: [PATCH 5/6] Add README troubleshooting FAQ. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 4dad4981..1ba85316 100644 --- a/README.md +++ b/README.md @@ -1568,6 +1568,12 @@ Try resetting the app frontend cache: * `Configuration -> Companion App -> Debugging -> Reset frontend cache` +### Casting to a remote media player does not work + +This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there. + +**NOTE**: In particular, for Frigate to support casting of clips, the default ffmpeg settings for Frigate must be modified, i.e. Frigate does not encode clips in a Chromecast compatible format out of the box (specifically: audio must be enabled in the AAC codec, whether your camera supports audio or not). See the [Frigate Home Assistant documentation](https://docs.frigate.video/integrations/home-assistant) or [this issue](https://github.com/blakeblackshear/frigate/issues/3175) for more. + ## Development ### Building From 28e94b610d5d1fc624fdd107b98ffd06b3af9825 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 May 2022 19:44:28 -0700 Subject: [PATCH 6/6] Rename the button to make it a little more obvious how this works. --- src/localize/languages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index df570e84..2217ac3c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -160,7 +160,7 @@ "buttons": { "frigate": "Frigate menu / Default view", "live": "Live", - "media_player": "Media Player", + "media_player": "Send to media player", "clips": "Clips", "snapshots": "Snapshots", "image": "Image",