music-assistant-server

40.6 KBPY
player.py
40.6 KB968 lines • python
1"""Chromecast Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8from uuid import UUID
9
10from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
11
12if TYPE_CHECKING:
13    from music_assistant_models.config_entries import ConfigValueType
14    from music_assistant_models.event import MassEvent
15
16from music_assistant_models.enums import (
17    ConfigEntryType,
18    EventType,
19    MediaType,
20    PlaybackState,
21    PlayerFeature,
22    PlayerType,
23)
24from music_assistant_models.errors import PlayerUnavailableError
25from music_assistant_models.player import PlayerSource
26from pychromecast import IDLE_APP_ID
27from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE
28from pychromecast.controllers.multizone import MultizoneController
29from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
30
31from music_assistant.constants import MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL
32from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
33
34from .constants import (
35    APP_MEDIA_RECEIVER,
36    CAST_PLAYER_CONFIG_ENTRIES,
37    CONF_ENTRY_SAMPLE_RATES_CAST,
38    CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
39    CONF_SENDSPIN_CODEC,
40    CONF_SENDSPIN_SYNC_DELAY,
41    CONF_USE_MASS_APP,
42    CONF_USE_SENDSPIN_MODE,
43    DEFAULT_SENDSPIN_CODEC,
44    DEFAULT_SENDSPIN_SYNC_DELAY,
45    MASS_APP_ID,
46    SENDSPIN_CAST_APP_ID,
47    SENDSPIN_CAST_NAMESPACE,
48)
49from .helpers import CastStatusListener, ChromecastInfo
50
51if TYPE_CHECKING:
52    from pychromecast import Chromecast
53    from pychromecast.controllers.media import MediaStatus
54    from pychromecast.controllers.receiver import CastStatus
55    from pychromecast.socket_client import ConnectionStatus
56
57    from .provider import ChromecastProvider
58
59
60class ChromecastPlayer(Player):
61    """Chromecast Player."""
62
63    active_cast_group: str | None = None
64
65    def __init__(
66        self,
67        provider: ChromecastProvider,
68        player_id: str,
69        cast_info: ChromecastInfo,
70        chromecast: Chromecast,
71    ) -> None:
72        """Init."""
73        super().__init__(provider, player_id)
74        if cast_info.is_audio_group and cast_info.is_multichannel_group:
75            player_type = PlayerType.STEREO_PAIR
76        elif cast_info.is_audio_group:
77            player_type = PlayerType.GROUP
78        else:
79            player_type = PlayerType.PLAYER
80        self.cc = chromecast
81        self.status_listener: CastStatusListener | None
82        self.cast_info = cast_info
83        self.mz_controller: MultizoneController | None = None
84        self.last_poll = 0.0
85        self.flow_meta_checksum: str | None = None
86        # set static variables
87        self._attr_supported_features = {
88            PlayerFeature.POWER,
89            PlayerFeature.VOLUME_SET,
90            PlayerFeature.PAUSE,
91            PlayerFeature.NEXT_PREVIOUS,
92            PlayerFeature.ENQUEUE,
93            PlayerFeature.SEEK,
94        }
95        self._attr_name = self.cast_info.friendly_name
96        self._attr_available = False
97        self._attr_powered = False
98        self._attr_needs_poll = True
99        self._attr_type = player_type
100        # Disable TV's by default
101        # (can be enabled manually by the user)
102        enabled_by_default = True
103        for exclude in ("tv", "/12", "PUS", "OLED"):
104            if exclude.lower() in cast_info.friendly_name.lower():
105                enabled_by_default = False
106        self._attr_enabled_by_default = enabled_by_default
107
108        self._attr_device_info = DeviceInfo(
109            model=self.cast_info.model_name,
110            manufacturer=self.cast_info.manufacturer or "",
111        )
112        self._attr_device_info.ip_address = self.cast_info.host
113        assert provider.mz_mgr is not None  # for type checking
114        status_listener = CastStatusListener(self, provider.mz_mgr)
115        self.status_listener = status_listener
116        if player_type == PlayerType.GROUP:
117            mz_controller = MultizoneController(cast_info.uuid)
118            self.cc.register_handler(mz_controller)
119            self.mz_controller = mz_controller
120        self.cc.start()
121
122        # Chromecast players can optionally use Sendspin for streaming
123        # when the sendspin-over-cast receiver app is used.
124        # Generate a predictable sendspin player id from the chromecast uuid.
125        # Format: "cast-XXXXXXXX" where X is derived from the UUID
126        uuid_str = player_id.replace("-", "")
127        self.sendspin_player_id = f"cast-{uuid_str[:8].lower()}"
128        self._last_sent_sync_delay: int | None = None
129        self._last_sent_codec: str | None = None
130
131        # Subscribe to sendspin player events for state syncing
132        self._on_unload_callbacks.append(
133            self.mass.subscribe(
134                self._on_sendspin_player_event,
135                (EventType.PLAYER_UPDATED,),
136                self.sendspin_player_id,
137            )
138        )
139
140    @property
141    def sendspin_mode_enabled(self) -> bool:
142        """Return if sendspin mode is enabled for the player."""
143        return bool(
144            self.mass.config.get_raw_player_config_value(
145                self.player_id, CONF_USE_SENDSPIN_MODE, False
146            )
147        )
148
149    def get_linked_sendspin_player(self, enabled_only: bool = True) -> Player | None:
150        """Return the linked sendspin player if available/enabled."""
151        if enabled_only and not self.sendspin_mode_enabled:
152            return None
153        if not (sendspin_player := self.mass.players.get(self.sendspin_player_id)):
154            return None
155        if not sendspin_player.available:
156            return None
157        return sendspin_player
158
159    @property
160    def supported_features(self) -> set[PlayerFeature]:
161        """Return the supported features for this player."""
162        try:
163            if self.sendspin_mode_enabled:
164                # Features for Sendspin mode - grouping happens via Sendspin player
165                return {
166                    PlayerFeature.POWER,
167                    PlayerFeature.VOLUME_SET,
168                    PlayerFeature.VOLUME_MUTE,
169                    PlayerFeature.PAUSE,
170                }
171        except Exception:  # noqa: S110
172            pass  # May fail during early initialization
173        return self._attr_supported_features
174
175    def _translate_from_sendspin_player_id(self, sendspin_player_id: str) -> str | None:
176        """Translate a Sendspin player ID back to its Chromecast player ID if applicable."""
177        # Sendspin player IDs for Chromecast are "cast-XXXXXXXX" where X is from UUID
178        if not sendspin_player_id.startswith("cast-"):
179            return None
180        # Search for a Chromecast player with matching sendspin_player_id
181        for player in self.mass.players.all():
182            if hasattr(player, "sendspin_player_id"):
183                if player.sendspin_player_id == sendspin_player_id:
184                    return player.player_id
185        return None
186
187    async def _on_sendspin_player_event(self, event: MassEvent) -> None:
188        """Handle incoming event from linked sendspin player."""
189        if not self.sendspin_mode_enabled:
190            return
191        if event.object_id != self.sendspin_player_id:
192            return
193        # Sync state from sendspin player to this player
194        if sendspin_player := self.get_linked_sendspin_player(False):
195            self._attr_playback_state = sendspin_player.playback_state
196            self._attr_current_media = sendspin_player.current_media
197            self._attr_elapsed_time = sendspin_player.elapsed_time
198            self._attr_elapsed_time_last_updated = sendspin_player.elapsed_time_last_updated
199            # Sync active_source so queue lookup works correctly
200            self._attr_active_source = sendspin_player.active_source
201            # Translate group_members from Sendspin player IDs to Chromecast player IDs
202            translated_members = []
203            for member_id in sendspin_player.group_members:
204                if cc_id := self._translate_from_sendspin_player_id(member_id):
205                    translated_members.append(cc_id)
206                else:
207                    # Keep original if no translation (e.g. non-Chromecast Sendspin player)
208                    translated_members.append(member_id)
209            self._attr_group_members = translated_members
210            # Translate synced_to from Sendspin player ID to Chromecast player ID
211            if sendspin_player.synced_to:
212                self._attr_synced_to = (
213                    self._translate_from_sendspin_player_id(sendspin_player.synced_to)
214                    or sendspin_player.synced_to
215                )
216            else:
217                self._attr_synced_to = None
218            self.update_state()
219            # Check if sync delay config changed and resend if needed
220            current_sync_delay = int(
221                self.mass.config.get_raw_player_config_value(
222                    self.player_id, CONF_SENDSPIN_SYNC_DELAY, DEFAULT_SENDSPIN_SYNC_DELAY
223                )
224            )
225            if self._last_sent_sync_delay != current_sync_delay:
226                # Update immediately to prevent duplicate sends from concurrent events
227                self._last_sent_sync_delay = current_sync_delay
228                self.mass.create_task(self._send_sendspin_sync_delay(current_sync_delay))
229
230    async def get_config_entries(
231        self,
232        action: str | None = None,
233        values: dict[str, ConfigValueType] | None = None,
234    ) -> list[ConfigEntry]:
235        """Return all (provider/player specific) Config Entries for the given player (if any)."""
236        # Sendspin mode config entry
237        sendspin_config = ConfigEntry(
238            key=CONF_USE_SENDSPIN_MODE,
239            type=ConfigEntryType.BOOLEAN,
240            label="Enable experimental Sendspin mode",
241            description="When enabled, Music Assistant will use the Sendspin protocol "
242            "for synchronized audio streaming instead of the standard Chromecast protocol. "
243            "This allows grouping Chromecast devices with other Sendspin-compatible players "
244            "for multi-room synchronized playback.\n\n"
245            "NOTE: Requires the Sendspin provider to be enabled.",
246            required=False,
247            default_value=False,
248            hidden=self.type == PlayerType.GROUP,
249        )
250
251        # Sync delay config entry (only visible when sendspin provider is available)
252        sendspin_sync_delay_config = ConfigEntry(
253            key=CONF_SENDSPIN_SYNC_DELAY,
254            type=ConfigEntryType.INTEGER,
255            label="Sendspin sync delay (ms)",
256            description="Static delay in milliseconds to adjust audio synchronization. "
257            "Positive values delay playback, negative values advance it. "
258            "Use this to compensate for device-specific audio latency. "
259            "Changes take effect immediately.",
260            required=False,
261            default_value=DEFAULT_SENDSPIN_SYNC_DELAY,
262            range=(-1000, 1000),
263            hidden=self.type == PlayerType.GROUP,
264            immediate_apply=True,
265        )
266
267        # Codec config entry (only visible when sendspin provider is available)
268        sendspin_codec_config = ConfigEntry(
269            key=CONF_SENDSPIN_CODEC,
270            type=ConfigEntryType.STRING,
271            label="Sendspin audio codec",
272            description="Audio codec used for the experimental Sendspin mode. "
273            "FLAC offers good compression with lossless quality. "
274            "Opus provides better compression but may have compatibility issues. "
275            "PCM is uncompressed and uses more bandwidth.",
276            required=False,
277            default_value=DEFAULT_SENDSPIN_CODEC,
278            options=[
279                ConfigValueOption("FLAC (lossless, compressed)", "flac"),
280                ConfigValueOption("Opus (lossy, experimental)", "opus"),
281                ConfigValueOption("PCM (lossless, uncompressed)", "pcm"),
282            ],
283            hidden=self.type == PlayerType.GROUP,
284        )
285
286        if self.type == PlayerType.GROUP:
287            return [
288                *CAST_PLAYER_CONFIG_ENTRIES,
289                CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
290            ]
291
292        return [
293            *CAST_PLAYER_CONFIG_ENTRIES,
294            CONF_ENTRY_SAMPLE_RATES_CAST,
295            sendspin_config,
296            sendspin_sync_delay_config,
297            sendspin_codec_config,
298        ]
299
300    async def on_config_updated(self) -> None:
301        """Handle config updates - resend Sendspin config if needed."""
302        if not self.sendspin_mode_enabled:
303            return
304
305        # Get current config values
306        current_sync_delay = int(
307            self.mass.config.get_raw_player_config_value(
308                self.player_id, CONF_SENDSPIN_SYNC_DELAY, DEFAULT_SENDSPIN_SYNC_DELAY
309            )
310        )
311        current_codec = str(
312            self.mass.config.get_raw_player_config_value(
313                self.player_id, CONF_SENDSPIN_CODEC, DEFAULT_SENDSPIN_CODEC
314            )
315        )
316
317        sync_delay_changed = self._last_sent_sync_delay != current_sync_delay
318        codec_changed = self._last_sent_codec != current_codec
319
320        if sync_delay_changed or codec_changed:
321            # Store old values for logging before updating state
322            old_codec = self._last_sent_codec
323            # Update immediately to prevent duplicate sends from concurrent events
324            self._last_sent_sync_delay = current_sync_delay
325            self._last_sent_codec = current_codec
326            try:
327                if codec_changed:
328                    # Codec changed - need full reconnection
329                    self.logger.debug(
330                        "Sendspin codec changed (%s -> %s), sending full config",
331                        old_codec,
332                        current_codec,
333                    )
334                    await self._send_sendspin_server_url()
335                else:
336                    # Only sync delay changed, don't reconnect, just send updated delay
337                    await self._send_sendspin_sync_delay(current_sync_delay)
338            except Exception as err:
339                self.logger.warning("Failed to send updated Sendspin config to Chromecast: %s", err)
340
341    async def stop(self) -> None:
342        """Send STOP command to given player."""
343        if sendspin_player := self.get_linked_sendspin_player(True):
344            # Sendspin mode is active - direct call to stop (NOT cmd_stop to avoid recursion)
345            self.logger.debug("Redirecting STOP command to linked sendspin player.")
346            await sendspin_player.stop()
347            return
348        await asyncio.to_thread(self.cc.media_controller.stop)
349
350    async def play(self) -> None:
351        """Send PLAY command to given player."""
352        await asyncio.to_thread(self.cc.media_controller.play)
353
354    async def pause(self) -> None:
355        """Send PAUSE command to given player."""
356        if self.sendspin_mode_enabled:
357            # In Sendspin mode, there's no native Cast media session to pause.
358            # Sendspin doesn't support pause, so stop the stream instead.
359            if sendspin_player := self.get_linked_sendspin_player(True):
360                self.logger.debug("Sendspin mode: stopping stream (pause not supported)")
361                await sendspin_player.stop()
362            return
363        await asyncio.to_thread(self.cc.media_controller.pause)
364
365    async def next_track(self) -> None:
366        """Handle NEXT TRACK command for given player."""
367        await asyncio.to_thread(self.cc.media_controller.queue_next)
368
369    async def previous_track(self) -> None:
370        """Handle PREVIOUS TRACK command for given player."""
371        await asyncio.to_thread(self.cc.media_controller.queue_prev)
372
373    async def seek(self, position: int) -> None:
374        """Handle SEEK command on the player."""
375        await asyncio.to_thread(self.cc.media_controller.seek, position)
376
377    async def power(self, powered: bool) -> None:
378        """Send POWER command to given player."""
379        if powered:
380            if self.sendspin_mode_enabled:
381                # Launch Sendspin app and connect to server
382                self.logger.info("Powering on with Sendspin mode enabled.")
383                launch_success = await self._launch_sendspin_app()
384                if launch_success:
385                    await asyncio.sleep(1)  # Give app time to initialize
386                    await self._send_sendspin_server_url()
387                    # Wait for the Sendspin player to connect
388                    sendspin_player = await self._wait_for_sendspin_player()
389                    if sendspin_player:
390                        self.logger.info(
391                            "Sendspin player %s connected successfully.",
392                            sendspin_player.player_id,
393                        )
394                    else:
395                        self.logger.warning("Sendspin player did not connect, but app is running.")
396                else:
397                    raise PlayerUnavailableError("Failed to launch Sendspin Cast App")
398            else:
399                await self._launch_app()
400            self._attr_active_source = self.player_id
401        else:
402            self._attr_active_source = None
403            await asyncio.to_thread(self.cc.quit_app)
404        # optimistically update the state
405        self.mass.loop.call_soon_threadsafe(self.update_state)
406
407    async def volume_set(self, volume_level: int) -> None:
408        """Send VOLUME_SET command to given player."""
409        # Round to 2 decimal places to avoid floating-point precision issues
410        await asyncio.to_thread(self.cc.set_volume, round(volume_level / 100, 2))
411
412    async def volume_mute(self, muted: bool) -> None:
413        """Send VOLUME MUTE command to given player."""
414        await asyncio.to_thread(self.cc.set_volume_muted, muted)
415
416    async def play_media(
417        self,
418        media: PlayerMedia,
419    ) -> None:
420        """Handle PLAY MEDIA on given player."""
421        if self.sendspin_mode_enabled:
422            # Sendspin mode is enabled, launch sendspin-over-cast app and redirect
423            self.logger.info("Redirecting PLAY_MEDIA command to sendspin mode.")
424            await self._play_media_sendspin(media)
425            return
426
427        queuedata = {
428            "type": "LOAD",
429            "media": self._create_cc_media_item(media),
430        }
431        # make sure that our media controller app is launched
432        await self._launch_app()
433        # send queue info to the CC
434        media_controller = self.cc.media_controller
435        await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)
436
437    async def enqueue_next_media(self, media: PlayerMedia) -> None:
438        """Handle enqueuing of the next item on the player."""
439        next_item_id = None
440        status = self.cc.media_controller.status
441        # lookup position of current track in cast queue
442        cast_current_item_id = getattr(status, "current_item_id", 0)
443        cast_queue_items = getattr(status, "items", [])
444        cur_item_found = False
445        for item in cast_queue_items:
446            if item["itemId"] == cast_current_item_id:
447                cur_item_found = True
448                continue
449            if not cur_item_found:
450                continue
451            next_item_id = item["itemId"]
452            # check if the next queue item isn't already queued
453            if item.get("media", {}).get("customData", {}).get("uri") == media.uri:
454                return
455        queuedata = {
456            "type": "QUEUE_INSERT",
457            "insertBefore": next_item_id,
458            "items": [
459                {
460                    "autoplay": True,
461                    "startTime": 0,
462                    "preloadTime": 0,
463                    "media": self._create_cc_media_item(media),
464                }
465            ],
466        }
467        media_controller = self.cc.media_controller
468        queuedata["mediaSessionId"] = media_controller.status.media_session_id
469        await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)
470
471    async def poll(self) -> None:
472        """Poll player for state updates."""
473        # only update status of media controller if player is on
474        if not self.powered:
475            return
476        if not self.cc.media_controller.is_active:
477            return
478        try:
479            now = time.time()
480            if (now - self.last_poll) >= 60:
481                self.last_poll = now
482                await asyncio.to_thread(self.cc.media_controller.update_status)
483        except ConnectionResetError as err:
484            raise PlayerUnavailableError from err
485
486    async def on_unload(self) -> None:
487        """Handle logic when the player is unloaded from the Player controller."""
488        await super().on_unload()
489        self.logger.debug("Disconnecting from chromecast socket %s", self.display_name)
490        await self.mass.loop.run_in_executor(None, self.cc.disconnect, 10)
491        self.mz_controller = None
492        if self.status_listener is not None:
493            self.status_listener.invalidate()
494        self.status_listener = None
495
496    def _on_player_media_updated(self) -> None:
497        """Handle callback when the current media of the player is updated."""
498        if not self.powered:
499            return
500        if not self.cc.media_controller.status.player_is_playing:
501            return
502        if self.active_cast_group:
503            return
504        if self.playback_state != PlaybackState.PLAYING:
505            return
506        if not (current_media := self.current_media):
507            return
508        if not (
509            "/flow/" in self._attr_current_media.uri
510            or self.current_media.media_type
511            in (
512                MediaType.RADIO,
513                MediaType.PLUGIN_SOURCE,
514            )
515        ):
516            # only update metadata for streams without known duration
517            return
518
519        async def update_flow_metadata() -> None:
520            """Update the metadata of a cast player running the flow (or radio) stream."""
521            media_controller = self.cc.media_controller
522            # update metadata of current item chromecast
523            title = current_media.title or "Music Assistant"
524            artist = current_media.artist or ""
525            album = current_media.album or ""
526            image_url = current_media.image_url or MASS_LOGO_ONLINE
527            flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}"
528            if self.flow_meta_checksum != flow_meta_checksum:
529                # only update if something changed
530                self.flow_meta_checksum = flow_meta_checksum
531                queuedata = {
532                    "type": "PLAY",
533                    "mediaSessionId": media_controller.status.media_session_id,
534                    "customData": {
535                        "metadata": {
536                            "metadataType": 3,
537                            "albumName": album,
538                            "songName": title,
539                            "artist": artist,
540                            "title": title,
541                            "images": [{"url": image_url}],
542                        }
543                    },
544                }
545                await asyncio.to_thread(
546                    media_controller.send_message, data=queuedata, inc_session_id=True
547                )
548
549            if len(getattr(media_controller.status, "items", [])) < 2:
550                # In flow mode, all queue tracks are sent to the player as continuous stream.
551                # add a special 'command' item to the queue
552                # this allows for on-player next buttons/commands to still work
553                cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next")
554                msg = {
555                    "type": "QUEUE_INSERT",
556                    "mediaSessionId": media_controller.status.media_session_id,
557                    "items": [
558                        {
559                            "media": {
560                                "contentId": cmd_next_url,
561                                "customData": {
562                                    "uri": cmd_next_url,
563                                    "queue_item_id": cmd_next_url,
564                                },
565                                "contentType": "audio/flac",
566                                "streamType": STREAM_TYPE_LIVE,
567                                "metadata": {},
568                            },
569                            "autoplay": True,
570                            "startTime": 0,
571                            "preloadTime": 0,
572                        }
573                    ],
574                }
575                await asyncio.to_thread(
576                    media_controller.send_message, data=msg, inc_session_id=True
577                )
578
579        self.mass.create_task(update_flow_metadata())
580
581    async def _launch_app(self) -> None:
582        """Launch the default Media Receiver App on a Chromecast."""
583        event = asyncio.Event()
584
585        if self.config.get_value(CONF_USE_MASS_APP, True):
586            app_id = MASS_APP_ID
587        else:
588            app_id = APP_MEDIA_RECEIVER
589
590        if self.cc.app_id == app_id:
591            return  # already active
592
593        def launched_callback(success: bool, response: dict[str, Any] | None) -> None:  # noqa: ARG001
594            self.mass.loop.call_soon_threadsafe(event.set)
595
596        def launch() -> None:
597            # Quit the previous app before starting splash screen or media player
598            if self.cc.app_id is not None:
599                self.cc.quit_app()
600            self.logger.debug("Launching App %s.", app_id)
601            self.cc.socket_client.receiver_controller.launch_app(
602                app_id,
603                force_launch=True,
604                callback_function=launched_callback,
605            )
606
607        await self.mass.loop.run_in_executor(None, launch)
608        await event.wait()
609
610    ### Callbacks from Chromecast Statuslistener
611
612    def on_new_cast_status(self, status: CastStatus) -> None:
613        """Handle updated CastStatus."""
614        if status is None:
615            return  # guard
616        self.logger.log(
617            VERBOSE_LOG_LEVEL,
618            "Received cast status for %s - app_id: %s - volume: %s",
619            self.display_name,
620            status.app_id,
621            status.volume_level,
622        )
623        # handle stereo pairs
624        if self.cast_info.is_multichannel_group:
625            self._attr_type = PlayerType.STEREO_PAIR
626            self.group_members.clear()
627        # handle cast groups
628        if self.cast_info.is_audio_group and not self.cast_info.is_multichannel_group:
629            assert self.mz_controller is not None  # for type checking
630            self._attr_type = PlayerType.GROUP
631            self._attr_group_members = [str(UUID(x)) for x in self.mz_controller.members]
632            self._attr_supported_features = {
633                PlayerFeature.POWER,
634                PlayerFeature.VOLUME_SET,
635                PlayerFeature.PAUSE,
636                PlayerFeature.ENQUEUE,
637            }
638
639        # update player status
640        self._attr_name = self.cast_info.friendly_name
641        self._attr_volume_level = round(status.volume_level * 100)
642        self._attr_volume_muted = status.volume_muted
643        new_powered = self.cc.app_id is not None and self.cc.app_id != IDLE_APP_ID
644        self._attr_powered = new_powered
645        if self._attr_powered and not new_powered and self._attr_type == PlayerType.GROUP:
646            # group is being powered off, update group childs
647            for child_id in self.group_members:
648                if child := self.mass.players.get(child_id):
649                    self.mass.loop.call_soon_threadsafe(child.update_state)
650        self.mass.loop.call_soon_threadsafe(self.update_state)
651
652    def on_new_media_status(self, status: MediaStatus) -> None:  # noqa: PLR0915
653        """Handle updated MediaStatus."""
654        self.logger.log(
655            VERBOSE_LOG_LEVEL,
656            "Received media status for %s update: %s",
657            self.display_name,
658            status.player_state,
659        )
660        # In Sendspin mode, state is synced from the Sendspin player - skip Cast media status
661        if self.sendspin_mode_enabled:
662            return
663        # handle player playing from a group
664        group_player: ChromecastPlayer | None = None
665        if self.active_cast_group is not None:
666            if not (group_player := self.mass.players.get(self.active_cast_group)):
667                return
668            if not isinstance(group_player, ChromecastPlayer):
669                return
670            status = group_player.cc.media_controller.status
671
672        # player state
673        self._attr_elapsed_time_last_updated = time.time()
674        if status.player_is_playing:
675            self._attr_playback_state = PlaybackState.PLAYING
676            self.set_current_media(uri=status.content_id or "", clear_all=True)
677        elif status.player_is_paused:
678            self._attr_playback_state = PlaybackState.PAUSED
679            self._attr_current_media = None
680            self._attr_active_source = None
681        else:
682            self._attr_playback_state = PlaybackState.IDLE
683            self._attr_current_media = None
684            self._attr_active_source = None
685
686        # elapsed time
687        self._attr_elapsed_time_last_updated = time.time()
688        self._attr_elapsed_time = status.adjusted_current_time
689        if status.player_is_playing:
690            self._attr_elapsed_time = status.adjusted_current_time
691        else:
692            self._attr_elapsed_time = status.current_time
693
694        # active source
695        if group_player:
696            self._attr_active_source = group_player.active_source or group_player.player_id
697        elif self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER):
698            self._attr_active_source = self.player_id
699        else:
700            app_name = self.cc.app_display_name or "Unknown App"
701            app_id = app_name.lower().replace(" ", "_")
702            self._attr_active_source = app_id
703            has_controls = app_name in ("Spotify", "Qobuz", "YouTube Music", "Deezer", "Tidal")
704            if not any(source.id == app_id for source in self._attr_source_list):
705                self._attr_source_list.append(
706                    PlayerSource(
707                        id=app_id,
708                        name=app_name,
709                        passive=True,
710                        can_play_pause=has_controls,
711                        can_seek=has_controls,
712                        can_next_previous=has_controls,
713                    )
714                )
715
716        if status.content_id and not status.player_is_idle:
717            self.set_current_media(
718                uri=status.content_id,
719                title=status.title,
720                artist=status.artist,
721                album=status.album_name,
722                image_url=status.images[0].url if status.images else None,
723                duration=int(status.duration) if status.duration is not None else None,
724                media_type=MediaType.TRACK,
725            )
726        else:
727            self._attr_current_media = None
728
729        # weird workaround which is needed for multichannel group childs
730        # (e.g. a stereo pair within a cast group)
731        # where it does not receive updates from the group,
732        # so we need to update the group child(s) manually
733        if self.type == PlayerType.GROUP and self.powered:
734            for child_id in self.group_members:
735                if child := self.mass.players.get(child_id):
736                    assert isinstance(child, ChromecastPlayer)  # for type checking
737                    if not child.cast_info.is_multichannel_group:
738                        continue
739                    child._attr_playback_state = self.playback_state
740                    child._attr_current_media = self.current_media
741                    child._attr_elapsed_time = self.elapsed_time
742                    child._attr_elapsed_time_last_updated = self.elapsed_time_last_updated
743                    child._attr_active_source = self.active_source
744                    self.mass.loop.call_soon_threadsafe(child.update_state)
745        self.mass.loop.call_soon_threadsafe(self.update_state)
746
747    def on_new_connection_status(self, status: ConnectionStatus) -> None:
748        """Handle updated ConnectionStatus."""
749        self.logger.log(
750            VERBOSE_LOG_LEVEL,
751            "Received connection status update for %s - status: %s",
752            self.display_name,
753            status.status,
754        )
755
756        if status.status == CONNECTION_STATUS_DISCONNECTED:
757            self._attr_available = False
758            self.mass.loop.call_soon_threadsafe(self.update_state)
759            return
760
761        new_available = status.status == CONNECTION_STATUS_CONNECTED
762        if new_available != self.available:
763            self.logger.debug(
764                "[%s] Cast device availability changed: %s",
765                self.cast_info.friendly_name,
766                status.status,
767            )
768            self._attr_available = new_available
769            self._attr_device_info = DeviceInfo(
770                model=self.cast_info.model_name,
771                manufacturer=self.cast_info.manufacturer or "",
772            )
773            self._attr_device_info.ip_address = self.cast_info.host
774            self.mass.loop.call_soon_threadsafe(self.update_state)
775
776            if new_available and self.type == PlayerType.PLAYER:
777                # Poll current group status
778                provider = cast("ChromecastProvider", self.provider)
779                mz_mgr = provider.mz_mgr
780                assert mz_mgr is not None  # for type checking
781                for group_uuid in mz_mgr.get_multizone_memberships(self.cast_info.uuid):
782                    group_media_controller = mz_mgr.get_multizone_mediacontroller(UUID(group_uuid))
783                    if not group_media_controller:
784                        continue
785
786    def _create_cc_media_item(self, media: PlayerMedia) -> dict[str, Any]:
787        """Create CC media item from MA PlayerMedia."""
788        if media.media_type == MediaType.TRACK:
789            stream_type = STREAM_TYPE_BUFFERED
790        else:
791            stream_type = STREAM_TYPE_LIVE
792        metadata = {
793            "metadataType": 3,
794            "albumName": media.album or "",
795            "songName": media.title or "",
796            "artist": media.artist or "",
797            "title": media.title or "",
798            "images": [{"url": media.image_url}] if media.image_url else None,
799        }
800        return {
801            "contentId": media.uri,
802            "customData": {
803                "uri": media.uri,
804                "queue_item_id": media.uri,
805            },
806            "contentType": "audio/flac",
807            "streamType": stream_type,
808            "metadata": metadata,
809            "duration": media.duration,
810        }
811
812    async def _launch_sendspin_app(self) -> bool:
813        """Launch the sendspin-over-cast receiver app on the Chromecast.
814
815        :return: True if app launched successfully, False otherwise.
816        """
817        event = asyncio.Event()
818        launch_success = False
819
820        if self.cc.app_id == SENDSPIN_CAST_APP_ID:
821            self.logger.debug("Sendspin Cast App already active.")
822            return True
823
824        def launched_callback(success: bool, response: dict[str, Any] | None) -> None:
825            nonlocal launch_success
826            launch_success = success
827            if not success:
828                self.logger.warning("Failed to launch Sendspin Cast App: %s", response)
829            else:
830                self.logger.debug("Sendspin Cast App launched successfully.")
831            self.mass.loop.call_soon_threadsafe(event.set)
832
833        def launch() -> None:
834            # Quit the previous app before starting sendspin receiver
835            if self.cc.app_id is not None:
836                self.cc.quit_app()
837            self.logger.info(
838                "Launching Sendspin Cast App %s on %s.",
839                SENDSPIN_CAST_APP_ID,
840                self.display_name,
841            )
842            self.cc.socket_client.receiver_controller.launch_app(
843                SENDSPIN_CAST_APP_ID,
844                force_launch=True,
845                callback_function=launched_callback,
846            )
847
848        await self.mass.loop.run_in_executor(None, launch)
849        try:
850            await asyncio.wait_for(event.wait(), timeout=10.0)
851        except TimeoutError:
852            self.logger.error("Timeout waiting for Sendspin Cast App to launch.")
853            return False
854        return launch_success
855
856    async def _send_sendspin_server_url(self) -> None:
857        """Send the Sendspin server URL to the Cast receiver via custom messaging."""
858        # Get the Sendspin server URL from the streams controller
859        server_url = f"http://{self.mass.streams.publish_ip}:8927"
860        # Player name with (Sendspin) suffix for the Sendspin player
861        player_name = f"{self._attr_name} (Sendspin)"
862        # Get sync delay from config (in milliseconds)
863        sync_delay = int(
864            self.mass.config.get_raw_player_config_value(
865                self.player_id, CONF_SENDSPIN_SYNC_DELAY, DEFAULT_SENDSPIN_SYNC_DELAY
866            )
867        )
868        # Get codec from config (default to flac)
869        codec = str(
870            self.mass.config.get_raw_player_config_value(
871                self.player_id, CONF_SENDSPIN_CODEC, DEFAULT_SENDSPIN_CODEC
872            )
873        )
874        codecs = [codec]
875
876        def send_message() -> None:
877            # Send custom message to receiver with server URL, player ID, name, sync delay, codecs
878            self.cc.socket_client.send_app_message(
879                SENDSPIN_CAST_NAMESPACE,
880                {
881                    "serverUrl": server_url,
882                    "playerId": self.sendspin_player_id,
883                    "playerName": player_name,
884                    "syncDelay": sync_delay,
885                    "codecs": codecs,
886                },
887            )
888
889        self.logger.debug(
890            "Sending Sendspin config to Cast receiver: url=%s, name=%s, syncDelay=%dms, codecs=%s",
891            server_url,
892            player_name,
893            sync_delay,
894            codecs,
895        )
896        await self.mass.loop.run_in_executor(None, send_message)
897        self._last_sent_sync_delay = sync_delay
898        self._last_sent_codec = codec
899
900    async def _send_sendspin_sync_delay(self, sync_delay: int) -> None:
901        """Send only the sync delay update to the Cast receiver (no reconnection)."""
902
903        def send_message() -> None:
904            self.cc.socket_client.send_app_message(
905                SENDSPIN_CAST_NAMESPACE,
906                {"syncDelay": sync_delay},
907            )
908
909        self.logger.debug(
910            "Sending Sendspin sync delay update to Cast receiver: syncDelay=%dms",
911            sync_delay,
912        )
913        await self.mass.loop.run_in_executor(None, send_message)
914        self._last_sent_sync_delay = sync_delay
915
916    async def _wait_for_sendspin_player(self, timeout: float = 15.0) -> Player | None:
917        """Wait for the Sendspin player to connect and become available."""
918        start_time = time.time()
919        while (time.time() - start_time) < timeout:
920            if sendspin_player := self.mass.players.get(self.sendspin_player_id):
921                if sendspin_player.available:
922                    self.logger.debug(
923                        "Sendspin player %s is now available", self.sendspin_player_id
924                    )
925                    return sendspin_player
926            await asyncio.sleep(0.5)
927        self.logger.warning(
928            "Timeout waiting for Sendspin player %s to become available",
929            self.sendspin_player_id,
930        )
931        return None
932
933    async def _play_media_sendspin(self, media: PlayerMedia) -> None:
934        """Handle PLAY MEDIA using the Sendspin protocol via sendspin-over-cast."""
935        self.logger.info(
936            "Starting Sendspin playback on %s (sendspin_player_id=%s)",
937            self.display_name,
938            self.sendspin_player_id,
939        )
940
941        # Check if the sendspin player is already connected (available)
942        if sendspin_player := self.get_linked_sendspin_player(False):
943            # Sendspin player is already connected, just redirect the media
944            self.logger.debug(
945                "Sendspin player already connected (state=%s), redirecting media.",
946                sendspin_player.playback_state,
947            )
948            await self.mass.players.play_media(sendspin_player.player_id, media)
949            return
950
951        # Sendspin player not connected yet - launch app and connect
952        launch_success = await self._launch_sendspin_app()
953        if not launch_success:
954            raise PlayerUnavailableError("Failed to launch Sendspin Cast App")
955
956        # Give the app a moment to initialize
957        await asyncio.sleep(1)
958        # Send the Sendspin server URL to the receiver
959        await self._send_sendspin_server_url()
960        # Wait for the Sendspin player to connect
961        sendspin_player = await self._wait_for_sendspin_player()
962        if not sendspin_player:
963            raise PlayerUnavailableError("Failed to establish Sendspin connection")
964
965        # Redirect playback to the Sendspin player
966        self.logger.info("Starting playback on Sendspin player %s", sendspin_player.player_id)
967        await self.mass.players.play_media(sendspin_player.player_id, media)
968