import type { CameraConfig } from '../config/schema/cameras'; import type { AdvancedCameraCardConfig } from '../config/schema/types'; import type { Entity } from '../ha/registry/entity/types'; import { supportsFeature } from '../ha/supports-feature'; import { errorToConsole } from '../utils/basic'; import type { ViewMedia } from '../view/item'; import { ViewItemClassifier } from '../view/item-classifier'; import type { CardMediaPlayerAPI } from './types'; // Media player support bitmask flags. 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; export class MediaPlayerManager { private _mediaPlayers: string[] = []; private _api: CardMediaPlayerAPI; constructor(api: CardMediaPlayerAPI) { this._api = api; } public getMediaPlayers(): string[] { return this._mediaPlayers; } public hasMediaPlayers(): boolean { return this._mediaPlayers.length > 0; } public async initializeIfNecessary( previousConfig: AdvancedCameraCardConfig | null, ): Promise { if ( previousConfig?.menu.buttons.media_player.enabled !== this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled ) { await this.initialize(); } } public async initialize(): Promise { const hass = this._api.getHASSManager().getHASS(); if ( !hass || !this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled ) { return false; } const isValidMediaPlayer = (entityID: string): boolean => { if (entityID.startsWith('media_player.')) { const stateObj = hass.states[entityID]; if ( stateObj && stateObj.state !== 'unavailable' && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) ) { return true; } } return false; }; const mediaPlayers = Object.keys(hass.states).filter(isValidMediaPlayer); let mediaPlayerEntities: Map | null = null; try { mediaPlayerEntities = await this._api .getEntityRegistryManager() .getEntities(hass, mediaPlayers); } catch (e) { // Failing to fetch media player information is not considered // sufficiently serious to block card startup -- it is just logged and we // move on. errorToConsole(e); } // Filter out entities that are marked as hidden (this information is not // available in the HA state, only in the registry). this._mediaPlayers = mediaPlayers.filter((entityID) => { // Specifically allow for media players that are not found in the entity registry: // See: https://github.com/dermotduffy/advanced-camera-card/issues/1016 const entity = mediaPlayerEntities?.get(entityID); return !entity || !entity.hidden_by; }); return true; } public async stop(mediaPlayer: string): Promise { 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 { const cameraConfig = this._api .getCameraManager() .getStore() .getCameraConfig(cameraID); if (!cameraConfig) { return; } if (cameraConfig.cast?.method === 'dashboard') { await this._playLiveDashboard(mediaPlayer, cameraConfig); } else { await this._playLiveStandard(mediaPlayer, cameraID, cameraConfig); } } private async _playLiveStandard( mediaPlayer: string, cameraID: string, cameraConfig: CameraConfig, ): Promise { const hass = this._api.getHASSManager().getHASS(); const cameraEntity = cameraConfig?.camera_entity ?? null; if (!hass || !cameraEntity) { return; } const title = this._api.getCameraManager().getCameraMetadata(cameraID)?.title ?? null; const thumbnail = hass.states[cameraEntity]?.attributes?.entity_picture ?? null; await hass.callService('media_player', 'play_media', { entity_id: mediaPlayer, media_content_id: `media-source://camera/${cameraEntity}`, media_content_type: 'application/vnd.apple.mpegurl', extra: { ...(title && { title: title }), ...(thumbnail && { thumb: thumbnail }), }, }); } private async _playLiveDashboard( mediaPlayer: string, cameraConfig: CameraConfig, ): Promise { const hass = this._api.getHASSManager().getHASS(); if (!hass) { return; } const dashboardConfig = cameraConfig.cast?.dashboard; // Guaranteed by schema refinement when cast.method is 'dashboard', but // needed for TypeScript narrowing since refine() doesn't narrow types. if (!dashboardConfig?.dashboard_path || !dashboardConfig?.view_path) { return; } // When this bug is closed, a query string could be included: // https://github.com/home-assistant/core/issues/98316 await hass.callService('cast', 'show_lovelace_view', { entity_id: mediaPlayer, dashboard_path: dashboardConfig.dashboard_path, view_path: dashboardConfig.view_path, }); } public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise { const hass = this._api.getHASSManager().getHASS(); if (!hass || !media) { return; } const title = media.getTitle(); const thumbnail = media.getThumbnail(); await hass.callService('media_player', 'play_media', { entity_id: mediaPlayer, media_content_id: media.getContentID(), media_content_type: ViewItemClassifier.isVideo(media) ? 'video' : 'image', extra: { ...(title && { title: title }), ...(thumbnail && { thumb: thumbnail }), }, }); } }