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),