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