/
/
/
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