object_id=queue_id,
data=queue.elapsed_time,
)
+ # also signal update to the player itself so it can update its current_media
+ self.mass.players.trigger_player_update(queue_id)
if send_update:
self.signal_update(queue_id)
+ # also signal update to the player itself so it can update its current_media
+ self.mass.players.trigger_player_update(queue_id)
# store the new state
if queue.active:
return player.player_id
return None
+ def _on_player_media_updated(self) -> None: # noqa: B027
+ """Handle callback when the current media of the player is updated."""
+ # optional callback for players that want to be informed when the final
+ # current media is updated (after applying group/sync membership logic).
+ # for instance to update any display information on the physical player.
+
# DO NOT OVERWRITE BELOW !
# These properties and methods are either managed by core logic or they
# are used to perform a very specific function. Overwriting these may
# clear the dict for the cached properties
self._cache.clear()
# calculate the new state
+ prev_media_checksum = self._get_player_media_checksum()
changed_values = self.__calculate_state()
+ if prev_media_checksum != self._get_player_media_checksum():
+ # current media changed, call the media updated callback
+ self._on_player_media_updated()
# ignore some values that are not relevant for the state
changed_values.pop("elapsed_time_last_updated", None)
changed_values.pop("extra_attributes.seq_no", None)
),
]
+ def _get_player_media_checksum(self) -> str:
+ """Return a checksum for the current media."""
+ if not (media := self.current_media):
+ return ""
+ return (
+ f"{media.uri}|{media.title}|{media.source_id}|{media.queue_item_id}|"
+ f"{media.image_url}|{media.duration}|{media.elapsed_time}"
+ )
+
def __calculate_state(
self,
) -> dict[str, tuple[Any, Any]]:
# always update the state after modifying group members
self.update_state()
+ def _on_player_media_updated(self) -> None:
+ """Handle callback when the current media of the player is updated."""
+ if not self.stream or not self.stream.running or not self.stream.session:
+ return
+ metadata = self.current_media
+ if not metadata:
+ return
+ progress = int(metadata.corrected_elapsed_time or 0)
+ self.mass.create_task(self.stream.send_metadata(progress, metadata))
+
def update_volume_from_device(self, volume: int) -> None:
"""Update volume from device feedback."""
ignore_volume_report = (
if TYPE_CHECKING:
from music_assistant_models.media_items import AudioFormat
- from music_assistant_models.player import PlayerMedia
from .player import AirPlayPlayer
from .provider import AirPlayProvider
async def _audio_streamer(self, audio_source: AsyncGenerator[bytes, None]) -> None:
"""Stream audio to all players."""
- _last_metadata: str | None = None
- prev_progress_report: float = 0.0
pcm_sample_size = self.pcm_format.pcm_sample_size
stream_start_time = time.time()
first_chunk_received = False
chunk_seconds = len(chunk) / pcm_sample_size
self.seconds_streamed += chunk_seconds
- # send metadata if changed
- # do this in a separate task to not disturb audio streaming
- # NOTE: we should probably move this out of the audio stream task into it's own task
- metadata: PlayerMedia | None
- if (
- self.sync_clients
- and (_leader := self.sync_clients[0])
- and (_leader.corrected_elapsed_time or 0) > 2
- and (metadata := _leader.current_media) is not None
- ):
- now = time.time()
- metadata_checksum = f"{metadata.uri}.{metadata.title}.{metadata.image_url}"
- progress = int(metadata.corrected_elapsed_time or 0)
- if _last_metadata != metadata_checksum:
- _last_metadata = metadata_checksum
- prev_progress_report = now
- self.mass.create_task(self._send_metadata(progress, metadata))
- # send the progress report every 5 seconds
- elif now - prev_progress_report >= 5:
- prev_progress_report = now
- self.mass.create_task(self._send_metadata(progress, None))
# Entire stream consumed: send EOF
self.prov.logger.debug("Audio source stream exhausted")
async with self._lock:
await ffmpeg.wait_with_timeout(30)
del ffmpeg
- async def _send_metadata(self, progress: int | None, metadata: PlayerMedia | None) -> None:
- """Send metadata to all players."""
- async with self._lock:
- await asyncio.gather(
- *[
- x.stream.send_metadata(progress, metadata)
- for x in self.sync_clients
- if x.stream and x.stream.running
- ],
- return_exceptions=True,
- )
-
async def _prepare_client(self, airplay_player: AirPlayPlayer) -> None:
"""Prepare stream for a single client."""
# Stop existing stream if running
if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigValueType
from music_assistant_models.event import MassEvent
+
from music_assistant_models.enums import (
ConfigEntryType,
EventType,
from pychromecast.controllers.multizone import MultizoneController
from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
-from music_assistant.constants import (
- ATTR_ANNOUNCEMENT_IN_PROGRESS,
- MASS_LOGO_ONLINE,
- VERBOSE_LOG_LEVEL,
-)
+from music_assistant.constants import MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL
from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
from .constants import (
# send queue info to the CC
media_controller = self.cc.media_controller
await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)
- if media.media_type in (MediaType.RADIO, MediaType.FLOW_STREAM):
- # in flow/radio mode we want to update the metadata more frequently
- # so we can show the current track info
- self._attr_poll_interval = 2
async def enqueue_next_media(self, media: PlayerMedia) -> None:
"""Handle enqueuing of the next item on the player."""
if (now - self.last_poll) >= 60:
self.last_poll = now
await asyncio.to_thread(self.cc.media_controller.update_status)
- await self.update_flow_metadata()
except ConnectionResetError as err:
raise PlayerUnavailableError from err
self.status_listener.invalidate()
self.status_listener = None
- async def update_flow_metadata(self) -> None:
- """Update the metadata of a cast player running the flow (or radio) stream."""
+ def _on_player_media_updated(self) -> None:
+ """Handle callback when the current media of the player is updated."""
if not self.powered:
- self._attr_poll_interval = 300
return
if not self.cc.media_controller.status.player_is_playing:
return
return
if self.playback_state != PlaybackState.PLAYING:
return
- if self.extra_attributes.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
- return
if not (current_media := self.current_media):
return
if not (
):
# only update metadata for streams without known duration
return
- self._attr_poll_interval = 2
- media_controller = self.cc.media_controller
- # update metadata of current item chromecast
- title = current_media.title or "Music Assistant"
- artist = current_media.artist or ""
- album = current_media.album or ""
- image_url = current_media.image_url or MASS_LOGO_ONLINE
- flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}"
- if self.flow_meta_checksum != flow_meta_checksum:
- # only update if something changed
- self.flow_meta_checksum = flow_meta_checksum
- queuedata = {
- "type": "PLAY",
- "mediaSessionId": media_controller.status.media_session_id,
- "customData": {
- "metadata": {
- "metadataType": 3,
- "albumName": album,
- "songName": title,
- "artist": artist,
- "title": title,
- "images": [{"url": image_url}],
- }
- },
- }
- await asyncio.to_thread(
- media_controller.send_message, data=queuedata, inc_session_id=True
- )
- if len(getattr(media_controller.status, "items", [])) < 2:
- # In flow mode, all queue tracks are sent to the player as continuous stream.
- # add a special 'command' item to the queue
- # this allows for on-player next buttons/commands to still work
- cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next")
- msg = {
- "type": "QUEUE_INSERT",
- "mediaSessionId": media_controller.status.media_session_id,
- "items": [
- {
- "media": {
- "contentId": cmd_next_url,
- "customData": {
- "uri": cmd_next_url,
- "queue_item_id": cmd_next_url,
+ async def update_flow_metadata() -> None:
+ """Update the metadata of a cast player running the flow (or radio) stream."""
+ media_controller = self.cc.media_controller
+ # update metadata of current item chromecast
+ title = current_media.title or "Music Assistant"
+ artist = current_media.artist or ""
+ album = current_media.album or ""
+ image_url = current_media.image_url or MASS_LOGO_ONLINE
+ flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}"
+ if self.flow_meta_checksum != flow_meta_checksum:
+ # only update if something changed
+ self.flow_meta_checksum = flow_meta_checksum
+ queuedata = {
+ "type": "PLAY",
+ "mediaSessionId": media_controller.status.media_session_id,
+ "customData": {
+ "metadata": {
+ "metadataType": 3,
+ "albumName": album,
+ "songName": title,
+ "artist": artist,
+ "title": title,
+ "images": [{"url": image_url}],
+ }
+ },
+ }
+ await asyncio.to_thread(
+ media_controller.send_message, data=queuedata, inc_session_id=True
+ )
+
+ if len(getattr(media_controller.status, "items", [])) < 2:
+ # In flow mode, all queue tracks are sent to the player as continuous stream.
+ # add a special 'command' item to the queue
+ # this allows for on-player next buttons/commands to still work
+ cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next")
+ msg = {
+ "type": "QUEUE_INSERT",
+ "mediaSessionId": media_controller.status.media_session_id,
+ "items": [
+ {
+ "media": {
+ "contentId": cmd_next_url,
+ "customData": {
+ "uri": cmd_next_url,
+ "queue_item_id": cmd_next_url,
+ },
+ "contentType": "audio/flac",
+ "streamType": STREAM_TYPE_LIVE,
+ "metadata": {},
},
- "contentType": "audio/flac",
- "streamType": STREAM_TYPE_LIVE,
- "metadata": {},
- },
- "autoplay": True,
- "startTime": 0,
- "preloadTime": 0,
- }
- ],
- }
- await asyncio.to_thread(media_controller.send_message, data=msg, inc_session_id=True)
+ "autoplay": True,
+ "startTime": 0,
+ "preloadTime": 0,
+ }
+ ],
+ }
+ await asyncio.to_thread(
+ media_controller.send_message, data=msg, inc_session_id=True
+ )
+
+ self.mass.create_task(update_flow_metadata())
async def _launch_app(self) -> None:
"""Launch the default Media Receiver App on a Chromecast."""
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
ContentType,
- EventType,
ImageType,
PlaybackState,
PlayerFeature,
if TYPE_CHECKING:
from aiosendspin.server.client import SendspinClient
from music_assistant_models.config_entries import ConfigValueType
- from music_assistant_models.event import MassEvent
+ from music_assistant_models.player_queue import PlayerQueue
from music_assistant_models.queue_item import QueueItem
from .provider import SendspinProvider
self._attr_volume_level = player_client.volume
self._attr_volume_muted = player_client.muted
self._attr_available = True
- self._on_unload_callbacks.append(
- self.mass.subscribe(
- self._on_queue_update,
- (EventType.QUEUE_UPDATED),
- )
- )
self.is_web_player = sendspin_client.name.startswith(
"Music Assistant Web (" # The regular Web Interface
) or sendspin_client.name.startswith(
# Clear artist artwork if none available
await self.api.group.set_media_art(None, source=ArtworkSource.ARTIST)
- async def _on_queue_update(self, event: MassEvent) -> None:
- """Extract and send current media metadata to sendspin players on queue updates."""
+ def _on_player_media_updated(self) -> None:
+ """Handle callback when the current media of the player is updated."""
if self.synced_to is not None:
# Only leader sends metadata
return
- queue = self.mass.player_queues.get_active_queue(self.player_id)
- if not queue or not queue.current_item:
- # Clear metadata when queue has no current item
+
+ if self.current_media is None:
+ # Clear metadata when no media loaded
self.api.group.set_metadata(Metadata())
return
+ self.mass.create_task(self.send_current_media_metadata())
- current_item = queue.current_item
-
- title = current_item.name
- artist = None
- album_artist = None
- album = None
- track = None
- artwork_url = None
- year = None
-
- if (streamdetails := current_item.streamdetails) and streamdetails.stream_title:
- # stream title/metadata from radio/live stream
- if " - " in streamdetails.stream_title:
- artist, title = streamdetails.stream_title.split(" - ", 1)
- else:
- title = streamdetails.stream_title
- artist = ""
- # set album to radio station name
- album = current_item.name
- elif media_item := current_item.media_item:
- title = media_item.name
- if artist_str := getattr(media_item, "artist_str", None):
- artist = artist_str
- if _album := getattr(media_item, "album", None):
- album = _album.name
- year = getattr(_album, "year", None)
- album_artist = getattr(_album, "artist_str", None)
- if _track_number := getattr(media_item, "track_number", None):
- track = _track_number
+ async def send_current_media_metadata(self) -> None:
+ """Send the current media metadata to the sendspin group."""
+ current_media = self.current_media
+ if current_media is None:
+ return
+ # check if we are playing a MA queue item
+ queue_item: QueueItem | None = None
+ queue: PlayerQueue | None = None
+ if current_media.source_id and current_media.queue_item_id:
+ queue = self.mass.player_queues.get(current_media.source_id)
+ queue_item = self.mass.player_queues.get_item(
+ current_media.source_id, current_media.queue_item_id
+ )
# Send album and artist artwork
- artwork_url = await self._send_album_artwork(current_item)
- await self._send_artist_artwork(current_item)
-
- track_duration = current_item.duration
+ if queue_item:
+ await self._send_album_artwork(queue_item)
+ await self._send_artist_artwork(queue_item)
+ track_duration = current_media.duration or 0
repeat = SendspinRepeatMode.OFF
- if queue.repeat_mode == RepeatMode.ALL:
+ if queue and queue.repeat_mode == RepeatMode.ALL:
repeat = SendspinRepeatMode.ALL
- elif queue.repeat_mode == RepeatMode.ONE:
+ elif queue and queue.repeat_mode == RepeatMode.ONE:
repeat = SendspinRepeatMode.ONE
- shuffle = queue.shuffle_enabled
+ shuffle = queue.shuffle_enabled if queue else False
metadata = Metadata(
- title=title,
- artist=artist,
- album_artist=album_artist,
- album=album,
- artwork_url=artwork_url,
- year=year,
- track=track,
+ title=current_media.title,
+ artist=current_media.artist,
+ album_artist=None, # TODO: extract from optional queue item
+ album=current_media.album,
+ artwork_url=current_media.image_url,
+ year=None, # TODO: extract from optional queue item
+ track=None, # TODO: extract from optional queue item
track_duration=track_duration * 1000 if track_duration is not None else None,
- track_progress=int(queue.corrected_elapsed_time * 1000),
+ track_progress=int(current_media.corrected_elapsed_time * 1000)
+ if current_media.corrected_elapsed_time
+ else 0,
playback_speed=1000,
repeat=repeat,
shuffle=shuffle,