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