Merge pull request #535 from dermotduffy/cast-support

Add native cast/media_player support
This commit is contained in:
Dermot Duffy
2022-05-02 19:46:16 -07:00
committed by GitHub
7 changed files with 160 additions and 11 deletions
+11 -1
View File
@@ -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. |
<a name="views"></a>
@@ -1564,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
+101 -1
View File
@@ -51,6 +51,8 @@ import {
getCameraIcon,
getCameraID,
getCameraTitle,
getEntityIcon,
getEntityTitle,
homeAssistantSignPath,
homeAssistantWSRequest,
isValidMediaShowInfo,
@@ -339,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),
...(action && { tap_action: action }),
};
});
@@ -465,6 +470,47 @@ 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];
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,
state_color: false,
title: title,
subtitle: title === playerEntityID ? undefined : playerEntityID,
disabled: !state || state.state === 'unavailable',
...(playAction && { tap_action: playAction }),
...(stopAction && { hold_action: stopAction }),
};
});
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 +932,54 @@ 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;
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;
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) {
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 {
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: extra,
});
} else if (action === 'stop') {
this._hass?.callService('media_player', 'media_stop', {
entity_id: mediaPlayer,
});
}
}
/**
* Handle a request for a card action.
* @param ev The action requested.
@@ -955,6 +1049,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}`);
}
+22 -7
View File
@@ -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 {
+6 -1
View File
@@ -36,8 +36,10 @@ export class FrigateCardSubmenu extends LitElement {
<mwc-list-item
style="${styleMap(stateParameters.style || {})}"
graphic="icon"
?twoline=${!!item.subtitle}
?selected=${item.selected}
?activated=${item.selected}
?disabled=${!!item.disabled}
aria-label="${stateParameters.title || ''}"
@action=${(ev) => {
// 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 || ''}
<span>${stateParameters.title || ''}</span>
${item.subtitle
? html`<span slot="secondary">${item.subtitle}</span>`
: ''}
${stateParameters.icon
? html` <ha-icon
data-domain=${ifDefined(stateParameters.data_domain)}
+1
View File
@@ -978,6 +978,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('frigate_ui')}
${this._renderMenuButton('fullscreen')}
${this._renderMenuButton('timeline')}
${this._renderMenuButton('media_player')}
</div>
</div>
`
+1
View File
@@ -160,6 +160,7 @@
"buttons": {
"frigate": "Frigate menu / Default view",
"live": "Live",
"media_player": "Send to media player",
"clips": "Clips",
"snapshots": "Snapshots",
"image": "Image",
+18 -1
View File
@@ -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 frigateCarMediaPlayerActionSchema = 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,
frigateCarMediaPlayerActionSchema,
]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
@@ -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<typeof menuSubmenuItemSchema>;
@@ -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),