/
/
/
1"""Playback handler."""
2
3from __future__ import annotations
4
5import time
6from typing import TYPE_CHECKING
7
8from music_assistant_models.errors import PlayerCommandFailed
9from pywam.lib.exceptions import PywamError
10from pywam.lib.url import UrlMediaItem
11
12from music_assistant.providers.samsung_wam.features.base import (
13 WamPlayerFeatureBase,
14 handle_pywam_errors,
15 retry_command,
16)
17
18from .consts import SOURCE_CHANGE_TIMEOUT
19from .models import WamSource
20
21if TYPE_CHECKING:
22 from music_assistant_models.player import PlayerMedia
23
24
25class PlaybackHandler(WamPlayerFeatureBase):
26 """Encapsulates playback and media-related commands."""
27
28 @retry_command()
29 @handle_pywam_errors
30 async def stop(self) -> None:
31 """Stop playback on the speaker."""
32 # WAM does not have a discrete stop, so we treat pause as stop
33 self.player.stream_active = False
34 await self.speaker.cmd_pause()
35
36 # Clear elapsed time tracking
37 self.player._attr_elapsed_time = None
38 self.player._attr_elapsed_time_last_updated = None
39
40 # Refresh state; the speaker will report either paused or idle after a pause command
41 self.player.state_sync.refresh_state(notify_provider=True)
42
43 @retry_command()
44 @handle_pywam_errors
45 async def play(self) -> None:
46 """Resume playback on the speaker."""
47 if self.player.active_source == self.player.player_id:
48 await self.mass.player_queues.resume(self.player.player_id)
49 return
50
51 await self.speaker.cmd_play()
52
53 # Resuming from a pause â reset the elapsed time base so it doesn't include paused time
54 if self.player.elapsed_time is not None:
55 self.player._attr_elapsed_time = self.player.corrected_elapsed_time
56 self.player._attr_elapsed_time_last_updated = time.time()
57 self.player.update_state()
58
59 @retry_command()
60 @handle_pywam_errors
61 async def pause(self) -> None:
62 """Pause playback on the speaker."""
63 await self.speaker.cmd_pause()
64
65 # Fetch the exact play position to keep elapsed time accurate while paused
66 await self.player.state_sync.update_play_time()
67 self.player.update_state()
68
69 @retry_command()
70 @handle_pywam_errors
71 async def play_media(self, media: PlayerMedia) -> None:
72 """
73 Stream a media URI to the speaker.
74
75 :param media: The details of the media stream to play.
76 """
77 if self.player.synced_to:
78 raise PlayerCommandFailed(
79 f"Cannot play media: {self.player.log_name} is a group member"
80 )
81
82 if self.player.active_source != WamSource.WIFI:
83 await self.select_source(WamSource.WIFI)
84
85 stream_url = await self.mass.streams.resolve_stream_url(self.player.player_id, media)
86
87 duration = media.stream_duration or media.duration
88 item = UrlMediaItem(
89 url=stream_url,
90 title=media.title,
91 description=media.artist,
92 duration=str(int(duration)) if duration else "0",
93 thumbnail=media.image_url,
94 )
95
96 try:
97 self.player.stream_active = True
98
99 self.player._attr_elapsed_time = 0.0
100 self.player._attr_elapsed_time_last_updated = time.time()
101 self.player.update_state()
102
103 await self.speaker.play_url(item)
104
105 except (PywamError, PlayerCommandFailed) as err:
106 self.player.stream_active = False
107 raise PlayerCommandFailed(f"Failed to play media on WAM speaker: {err}") from err
108
109 @retry_command()
110 @handle_pywam_errors
111 async def select_source(self, source: WamSource | str) -> None:
112 """
113 Change the input source on the speaker.
114
115 :param source: The target source identifier.
116 """
117 try:
118 target_source = WamSource.WIFI if source == self.player.player_id else WamSource(source)
119 except ValueError as err:
120 raise PlayerCommandFailed(
121 f"'{source}' is not a valid source for {self.player.log_name}"
122 ) from err
123
124 if target_source != WamSource.WIFI:
125 self.player.stream_active = False
126 # The player will briefly appear idle during the source switch; setting the active
127 # source here prevents the UI from momentarily reverting to the queue name
128 self.player.set_active_mass_source(str(target_source))
129
130 if self.player.active_source == str(target_source):
131 # Already on the target source; refresh to push current state through
132 self.player.state_sync.refresh_state(notify_provider=True)
133 return
134
135 await self.speaker.select_source(str(target_source))
136
137 def check_source() -> bool:
138 return self.player.active_source == str(target_source)
139
140 await self.player.await_state_change(check_source, SOURCE_CHANGE_TIMEOUT)
141
142 # Push final state regardless of whether the source change was confirmed
143 self.player.state_sync.refresh_state(notify_provider=True)
144