Repository: dermotduffy/frigate-hass-card
Pull request #1605 (2024-10-02 18:35:12)

fix: `media_stop` not supported on Google Cast devices

felipecrs: @dermotduffy, I'm struggling to adjust the tests for this fix.

https://github.com/dermotduffy/frigate-hass-card/blob/f048a36f72e3a9a77471eabd9db9f846dc1e4bff/tests/card-controller/media-player-manager.test.ts#L182-L197

I would appreciate if you can handle it (you can push commits to this PR itself).

Patch:
diff --git a/docs/usage/casting.md b/docs/usage/casting.md
index 960e3b34..8a92b5d3 100644
--- a/docs/usage/casting.md
+++ b/docs/usage/casting.md
@@ -1,57 +1,121 @@
-# Casting the Card
+# Casting
 
-This card can be (Chrome) casted to a device (such as a [Nest
-Hub](https://store.google.com/us/product/nest_hub_2nd_gen)) through the use of
-[Home Assistant Cast](https://cast.home-assistant.io/).
+This card allows casting a camera stream to a Google Cast device. You can enable the cast button with:
 
-## Instructions
+```yaml
+type: custom:frigate-card
+cameras:
+  - camera_entity: camera.office
+    live_provider: go2rtc
+menu:
+  media_player:
+    enabled: true
+```
 
-- Visit [Home Assistant Cast](https://cast.home-assistant.io/) and click `Start Casting`
-- Enter your Home Assistant URL, and authorize your account.
-- Click `Start Casting` and choose the device to cast to from the browser menu.
-- Choose which view/dashboard to display.
-- If successful, the view will be cast to the device.
+Clicking this button will allow you to choose a Google Cast device to cast the camera stream to, or the clip that is currently playing.
 
-## Limitations
+There are two different casting methods for cameras: `standard` and `dashboard` (see below).
 
-Casting Home Assistant dashboards comes with a number of caveats:
+## Standard Casting
 
-- Home Assistant Casting does not support the HA `streaming` component
-  ([source](https://cast.home-assistant.io/faq.html)). This means clips playing
-  and the `ha` live provider can not work. Other live providers such as `jsmpeg`
-  and `webrtc-card` function correctly.
-- The Javascript fullscreen API does not work (so the fullscreen button does not
-  work, but see below for an equivalent).
+This is the default casting method.
+
+```yaml
+type: custom:frigate-card
+cameras:
+  - camera_entity: camera.office
+    live_provider: go2rtc
+    cast:
+      method: standard
+menu:
+  media_player:
+    enabled: true
+```
 
-## The `casting` profile
+This will send the camera stream to the Google Cast device through the use of the [`media_player.play_media` Home Assistant action](https://www.home-assistant.io/integrations/media_source/#playing-media-from-a-media-source).
 
-The optional [casting profile](../configuration/profiles.md?id=casting) provides
-some defaults to improve your casting experience. Use it like:
+The main disadvantage of this method is that the stream will be played through HLS which has a **delay of around 10 seconds**, making it sub-optimal for live monitoring.
+
+## Dashboard Casting
+
+This method is powered by [Home Assistant Cast](https://cast.home-assistant.io) and requires additional configuration, but allows **low latency streaming** with go2rtc.
 
 ```yaml
-profiles:
-  - casting
+type: custom:frigate-card
+cameras:
+  - camera_entity: camera.office
+    live_provider: go2rtc
+    cast:
+      method: dashboard
+      dashboard:
+        dashboard_path: dashboard-cast
+        view_path: office
+```
+
+When the cast button is clicked, the card will perform the following Home Assistant action:
+
+```yaml
+action: cast.show_lovelace_view
+data:
+  entity_id: media_player.kitchen
+  dashboard_path: dashboard-cast
+  view_path: office
+```
+
+Which in turn will cast the specified Home Assistant dashboard (and view) to the Google Cast device.
+
+For the example above to work, a dashboard with `dashboard-cast` URL must exist:
+
+![image](https://github.com/user-attachments/assets/67f0e145-df05-412a-8c6d-897feb5439d2)
+
+Within that dashboard, create an `office` view and finally within that view you can place the Frigate card configured for that camera for a proper full-screen casting experience:
+
+```yaml
+views:
+  - title: Office
+    type: panel
+    path: office
+    cards:
+      - type: custom:frigate-card
+        cameras:
+          - camera_entity: camera.office
+            live_provider: go2rtc
+        profiles:
+          - low-performance
+          - casting
 ```
 
-## Recommended configuration for Nest Hub
+The `casting` profile pre-configures the card to be casted to a 16:9 screen (such as a TV), including hiding all interactive elements and the menu. The `low-performance` profile is also recommended, as normally casting devices have limited hardware capabilities.
 
-Using a `panel` dashboard with the following base configuration will result in
-the card consuming the entire device screen:
+![](https://github.com/user-attachments/assets/bd96c4ad-36f5-4501-9018-23b496e7edc5)
 
-### Configuration
+When casting to a Google Nest Hub, the following configuration can be used:
 
 ```yaml
 type: custom:frigate-card
 cameras:
   - camera_entity: camera.office
     live_provider: go2rtc
+profiles:
+  - low-performance
+  - casting
+menu:
+  style: hidden
 dimensions:
   aspect_ratio: 1024:600
-  aspect_ratio_mode: static
-profile:
-  - casting
 ```
 
-### Result
+This restores the menu and sets the aspect ratio to 1024:600, which is the resolution of the Nest Hub.
 
 ![](../images/card-on-nest-hub.jpg 'Casting on a Nest Hub :size=400')
+
+### Limitations
+
+Casting Home Assistant dashboards comes with a number of caveats:
+
+- Home Assistant Casting does not support the HA `streaming` component
+  ([source](https://cast.home-assistant.io/faq.html)). This means clips playing
+  and the `ha` live provider can not work. Other live providers such as `jsmpeg`
+  and `webrtc-card` function correctly.
+- The Javascript fullscreen API does not work, so the fullscreen button does not
+  work (use a `panel` view instead).
diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts
index 76095eab..023415c7 100644
--- a/src/card-controller/media-player-manager.ts
+++ b/src/card-controller/media-player-manager.ts
@@ -1,5 +1,9 @@
 import { CameraConfig, FrigateCardConfig } from '../config/types';
-import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
+import {
+  MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
+  MEDIA_PLAYER_SUPPORT_STOP,
+  MEDIA_PLAYER_SUPPORT_TURN_OFF,
+} from '../const';
 import { localize } from '../localize/localize';
 import { errorToConsole } from '../utils/basic';
 import { Entity } from '../utils/ha/registry/entity/types';
@@ -85,12 +89,26 @@ export class MediaPlayerManager {
   }
 
   public async stop(mediaPlayer: string): Promise<void> {
-    await this._api
-      .getHASSManager()
-      .getHASS()
-      ?.callService('media_player', 'media_stop', {
-        entity_id: mediaPlayer,
-      });
+    const hass = this._api.getHASSManager().getHASS();
+    const stateObj = hass?.states[mediaPlayer];
+    if (!stateObj) {
+      return;
+    }
+
+    let service: string;
+    if (supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_STOP)) {
+      service = 'media_stop';
+    } else if (supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_TURN_OFF)) {
+      // Google Cast devices don't support media_stop, but turning off has the
+      // same effect.
+      service = 'turn_off';
+    } else {
+      return;
+    }
+
+    await hass.callService('media_player', service, {
+      entity_id: mediaPlayer,
+    });
   }
 
   public async playLive(mediaPlayer: string, cameraID: string): Promise<void> {
diff --git a/src/config/profiles/casting.ts b/src/config/profiles/casting.ts
index 6537700a..65f11faa 100644
--- a/src/config/profiles/casting.ts
+++ b/src/config/profiles/casting.ts
@@ -1,18 +1,37 @@
 import {
   CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS,
   CONF_DIMENSIONS_ASPECT_RATIO,
+  CONF_DIMENSIONS_ASPECT_RATIO_MODE,
   CONF_LIVE_AUTO_UNMUTE,
   CONF_LIVE_CONTROLS_BUILTIN,
   CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
   CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
+  CONF_MENU_BUTTONS_FULLSCREEN,
+  CONF_MENU_BUTTONS_MEDIA_PLAYER,
+  CONF_MENU_BUTTONS_MUTE,
+  CONF_MENU_BUTTONS_PLAY,
+  CONF_MENU_STYLE,
 } from '../../const.js';
 
 export const CASTING_PROFILE = {
-  [CONF_LIVE_AUTO_UNMUTE]: ['selected', 'visible'],
-  [CONF_DIMENSIONS_ASPECT_RATIO]: '16:9',
   [CONF_LIVE_CONTROLS_BUILTIN]: false,
   [CONF_MEDIA_VIEWER_CONTROLS_BUILTIN]: false,
 
+  // TVs are generally not touch-enabled, so we don't want to show the menu
+  [CONF_MENU_STYLE]: 'none',
+
+  // But in case the user enables the menu, let's make sure to enable the
+  // buttons that make sense and disable the ones that don't
+  [`${CONF_MENU_BUTTONS_PLAY}.enabled`]: true,
+  [`${CONF_MENU_BUTTONS_MUTE}.enabled`]: true,
+  [`${CONF_MENU_BUTTONS_FULLSCREEN}.enabled`]: false,
+  [`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false,
+
+  [CONF_LIVE_AUTO_UNMUTE]: ['selected', 'visible'],
+
+  [CONF_DIMENSIONS_ASPECT_RATIO_MODE]: 'static',
+  [CONF_DIMENSIONS_ASPECT_RATIO]: '16:9',
+
   // These values are defaults anyway unless another profile (e.g.
   // low-performance) is also selected, but at pretty important to a good
   // experience so are reset here.
diff --git a/src/const.ts b/src/const.ts
index 2b851ffa..cf504b42 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -328,11 +328,14 @@ export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
 export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
 export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
 
-export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
-export const CONF_MENU_BUTTONS_CAMERA_UI = `${CONF_MENU}.buttons.camera_ui` as const;
+export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU_BUTTONS}.frigate` as const;
+export const CONF_MENU_BUTTONS_CAMERA_UI = `${CONF_MENU_BUTTONS}.camera_ui` as const;
+export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU_BUTTONS}.fullscreen` as const;
+export const CONF_MENU_BUTTONS_PLAY = `${CONF_MENU_BUTTONS}.play` as const;
+export const CONF_MENU_BUTTONS_MUTE = `${CONF_MENU_BUTTONS}.mute` as const;
 export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
-  `${CONF_MENU}.buttons.media_player` as const;
-export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
+  `${CONF_MENU_BUTTONS}.media_player` as const;
+export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const;
 
 export const CONF_STATUS_BAR = 'status_bar' as const;
 export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const;
@@ -360,7 +363,9 @@ export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.b
 
 export const CONF_PROFILES = 'profiles' as const;
 
-// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
+// Taken from https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82
+export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256;
+export const MEDIA_PLAYER_SUPPORT_STOP = 4096;
 export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
 
 // The number of media items to fetch at a time (for clips/snapshot views, and


Test patch:
diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts
index 633f0539..7140a283 100644
--- a/tests/card-controller/media-player-manager.test.ts
+++ b/tests/card-controller/media-player-manager.test.ts
@@ -1,7 +1,11 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { mock } from 'vitest-mock-extended';
 import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
-import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../src/const';
+import {
+  MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
+  MEDIA_PLAYER_SUPPORT_STOP,
+  MEDIA_PLAYER_SUPPORT_TURN_OFF,
+} from '../../src/const';
 import { ExtendedHomeAssistant } from '../../src/types';
 import { EntityRegistryManager } from '../../src/utils/ha/registry/entity';
 import {
@@ -179,21 +183,84 @@ describe('MediaPlayerManager', () => {
     });
   });
 
-  it('should stop', async () => {
-    const api = createCardAPI();
-    vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
+  describe('should stop', () => {
+    it('should call media_stop when device supports it', async () => {
+      const api = createCardAPI();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
+        createHASS({
+          'media_player.foo': createStateEntity({
+            entity_id: 'media_player.foo',
+            attributes: {
+              supported_features: MEDIA_PLAYER_SUPPORT_STOP,
+            },
+          }),
+        }),
+      );
+
+      const manager = new MediaPlayerManager(api);
+
+      await manager.stop('media_player.foo');
+
+      expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
+        'media_player',
+        'media_stop',
+        {
+          entity_id: 'media_player.foo',
+        },
+      );
+    });
+
+    it('should call turn_off when device does supports it', async () => {
+      const api = createCardAPI();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
+        createHASS({
+          'media_player.foo': createStateEntity({
+            entity_id: 'media_player.foo',
+            attributes: {
+              supported_features: MEDIA_PLAYER_SUPPORT_TURN_OFF,
+            },
+          }),
+        }),
+      );
+
+      const manager = new MediaPlayerManager(api);
 
-    const manager = new MediaPlayerManager(api);
+      await manager.stop('media_player.foo');
 
-    await manager.stop('media_player.foo');
+      expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
+        'media_player',
+        'turn_off',
+        {
+          entity_id: 'media_player.foo',
+        },
+      );
+    });
 
-    expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
-      'media_player',
-      'media_stop',
-      {
-        entity_id: 'media_player.foo',
-      },
-    );
+    it('should do nothing without some supported feature', async () => {
+      const api = createCardAPI();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
+        createHASS({
+          'media_player.foo': createStateEntity({
+            entity_id: 'media_player.foo',
+          }),
+        }),
+      );
+      const manager = new MediaPlayerManager(api);
+
+      await manager.stop('media_player.foo');
+
+      expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled();
+    });
+
+    it('should do nothing without hass state', async () => {
+      const api = createCardAPI();
+      vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
+      const manager = new MediaPlayerManager(api);
+
+      await manager.stop('media_player.foo');
+
+      expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled();
+    });
   });
 
   describe('should play', () => {
diff --git a/tests/config/profiles/casting.test.ts b/tests/config/profiles/casting.test.ts
index cad2188d..d4a77515 100644
--- a/tests/config/profiles/casting.test.ts
+++ b/tests/config/profiles/casting.test.ts
@@ -7,11 +7,17 @@ import { CASTING_PROFILE } from '../../../src/config/profiles/casting';
 it('should contain expected defaults', () => {
   expect(CASTING_PROFILE).toEqual({
     'cameras_global.image.refresh_seconds': 1,
+    'dimensions.aspect_ratio_mode': 'static',
     'dimensions.aspect_ratio': '16:9',
     'live.auto_unmute': ['selected', 'visible'],
     'live.controls.builtin': false,
     'live.show_image_during_load': true,
     'media_viewer.controls.builtin': false,
+    'menu.buttons.fullscreen.enabled': false,
+    'menu.buttons.media_player.enabled': false,
+    'menu.buttons.mute.enabled': true,
+    'menu.buttons.play.enabled': true,
+    'menu.style': 'none',
   });
 });