/
/
/
1"""Samsung WAM player."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Callable
7from typing import TYPE_CHECKING
8
9from music_assistant_models.enums import IdentifierType, PlaybackState
10from music_assistant_models.player import DeviceInfo, PlayerMedia
11
12from music_assistant.constants import CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN
13from music_assistant.helpers.util import is_valid_mac_address
14from music_assistant.models.player import Player
15
16from .consts import (
17 CONF_ENTRY_HTTP_PROFILE_WAM,
18 MANUFACTURER_NAME,
19 PLAYER_FEATURES_BASE,
20)
21from .features.device_config.handler import DeviceConfigHandler
22from .features.grouping.handler import GroupingHandler
23from .features.playback.handler import PlaybackHandler
24from .features.state_sync.consts import POLL_INTERVAL
25from .features.state_sync.handler import StateSyncHandler
26from .features.volume.handler import VolumeHandler
27
28if TYPE_CHECKING:
29 from music_assistant_models.config_entries import ConfigEntry
30 from pywam.speaker import Speaker
31
32 from .provider import SamsungWamProvider
33
34
35class WamPlayer(Player):
36 """Representation of a Samsung WAM speaker."""
37
38 def __init__(
39 self,
40 prov: SamsungWamProvider,
41 ip_address: str,
42 udn: str,
43 mac: str,
44 speaker: Speaker,
45 ) -> None:
46 """
47 Initialize the WamPlayer.
48
49 :param prov: The parent provider instance.
50 :param ip_address: The IP address of the speaker.
51 :param udn: The Universal Device Name of the speaker.
52 :param mac: The MAC address of the speaker.
53 :param speaker: The underlying pywam Speaker instance.
54 """
55 self.prov = prov
56 self._ip_address = ip_address
57 self._udn = udn
58 self.speaker = speaker
59
60 self.stream_active = False
61 self._state_update_event = asyncio.Event()
62 self.connection_lock = asyncio.Lock()
63 self.synced_to_internal: str | None = None
64
65 super().__init__(provider=prov, player_id=mac)
66
67 self.logger = self.prov.logger.getChild(self.player_id)
68 self._attr_supported_features = set(PLAYER_FEATURES_BASE)
69 self._attr_supported_sample_rates = [
70 (sr, bd) for sr in (44100, 48000, 88200, 96000, 176400, 192000) for bd in (16, 24)
71 ]
72 self._attr_can_group_with = {prov.instance_id}
73 self._attr_needs_poll = True
74 self._attr_poll_interval = POLL_INTERVAL
75
76 self._attr_device_info = DeviceInfo(model="Unknown Model", manufacturer=MANUFACTURER_NAME)
77 if is_valid_mac_address(self.player_id):
78 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, self.player_id)
79 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.ip_address)
80
81 self.state_sync = StateSyncHandler(self)
82 self.playback = PlaybackHandler(self)
83 self.grouping = GroupingHandler(self)
84 self.volume = VolumeHandler(self)
85 self.device_config = DeviceConfigHandler(self)
86
87 # --- Properties ---
88
89 @property
90 def requires_flow_mode(self) -> bool:
91 """Force flow mode for WAM as native gapless queueing is unsupported."""
92 return True
93
94 @property
95 def ip_address(self) -> str:
96 """Return the IP address of the speaker."""
97 return self._ip_address
98
99 @property
100 def udn(self) -> str:
101 """Return the Universal Device Name (UDN) of the speaker."""
102 return self._udn
103
104 @property
105 def connected(self) -> bool:
106 """Return True if the underlying speaker client is connected."""
107 return bool(self.speaker.is_connected)
108
109 @property
110 def synced_to(self) -> str | None:
111 """Return the ID of the player this player is synced to."""
112 return self.synced_to_internal
113
114 @property
115 def log_name(self) -> str:
116 """Return a friendly name for logging output."""
117 return self.display_name or self.ip_address
118
119 # --- Configuration ---
120
121 async def get_config_entries(self) -> list[ConfigEntry]:
122 """
123 Return player-specific configuration entries.
124
125 :return: A list of ConfigEntry objects.
126 """
127 return [CONF_ENTRY_HTTP_PROFILE_WAM, CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN]
128
129 async def on_config_updated(self) -> None:
130 """Handle player config updates."""
131 if new_name := self.config.name:
132 if self.connected and new_name != self._attr_name:
133 self.mass.create_task(self.device_config.set_name(new_name))
134
135 # --- Player Controls ---
136
137 async def poll(self) -> None:
138 """Poll the player."""
139 await self.state_sync.poll()
140
141 async def on_unload(self) -> None:
142 """Handle cleanup when player is removed."""
143 await super().on_unload()
144 await self.state_sync.unload()
145
146 async def play(self) -> None:
147 """Resume playback."""
148 await self.playback.play()
149
150 async def pause(self) -> None:
151 """Pause playback."""
152 await self.playback.pause()
153
154 async def stop(self) -> None:
155 """Stop playback."""
156 await self.playback.stop()
157
158 async def play_media(self, media: PlayerMedia) -> None:
159 """
160 Play media.
161
162 :param media: The media item to play.
163 """
164 await self.playback.play_media(media)
165
166 async def play_announcement(
167 self, announcement: PlayerMedia, volume_level: int | None = None
168 ) -> None:
169 """
170 Play an announcement on the player.
171
172 :param announcement: The announcement media item to play.
173 :param volume_level: The volume level to play the announcement at (0..100).
174 """
175 prev_state = self.playback_state
176 prev_volume = self.volume_level
177
178 if prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
179 await self.playback.stop()
180 await self.await_state_change(
181 lambda: self.playback_state == PlaybackState.IDLE,
182 timeout=5.0,
183 )
184
185 if volume_level is not None and prev_volume is not None and volume_level != prev_volume:
186 await self.volume.set_volume(volume_level)
187
188 await self.playback.play_media(announcement)
189 await self.await_state_change(
190 lambda: self.playback_state == PlaybackState.PLAYING,
191 timeout=10.0,
192 )
193
194 # Samsung speakers auto-resume after a URL stream ends rather than going idle,
195 # so we stop the stream manually at the expected end of the announcement
196 duration = await self.mass.streams.get_announcement_duration(announcement)
197 if duration is not None:
198 await asyncio.sleep(duration + 1.0)
199 else:
200 # Unknown duration â fall back to waiting for idle
201 await self.await_state_change(
202 lambda: self.playback_state == PlaybackState.IDLE,
203 timeout=30.0,
204 )
205
206 # Explicitly stop to cancel any auto-resume before restoring state
207 await self.playback.stop()
208
209 if volume_level is not None and prev_volume is not None and volume_level != prev_volume:
210 await self.volume.set_volume(prev_volume)
211
212 if prev_state == PlaybackState.PLAYING:
213 await self.mass.player_queues.resume(self.player_id)
214
215 async def select_source(self, source: str) -> None:
216 """
217 Select source.
218
219 :param source: The source identifier to select.
220 """
221 await self.playback.select_source(source)
222
223 async def volume_set(self, volume_level: int) -> None:
224 """
225 Set volume level.
226
227 :param volume_level: The volume level to set.
228 """
229 await self.volume.set_volume(volume_level)
230
231 async def volume_mute(self, muted: bool) -> None:
232 """
233 Set mute state.
234
235 :param muted: True to mute, False to unmute.
236 """
237 await self.volume.set_mute(muted)
238
239 async def set_members(
240 self,
241 player_ids_to_add: list[str] | None = None,
242 player_ids_to_remove: list[str] | None = None,
243 ) -> None:
244 """
245 Handle group membership changes.
246
247 :param player_ids_to_add: List of player IDs to add to the group.
248 :param player_ids_to_remove: List of player IDs to remove from the group.
249 """
250 await self.prov.groups.set_members(
251 self.player_id,
252 player_ids_to_add,
253 player_ids_to_remove,
254 )
255
256 # --- Helpers ---
257
258 def signal_state_update_event(self) -> None:
259 """Signal that state has been updated."""
260 self._state_update_event.set()
261
262 async def await_state_change(self, check: Callable[[], bool], timeout: float) -> None:
263 """
264 Wait for a specific condition in the state.
265
266 :param check: A callable returning a boolean indicating if the condition is met.
267 :param timeout: Maximum time in seconds to wait.
268 """
269 try:
270 async with asyncio.timeout(timeout):
271 while True:
272 self._state_update_event.clear()
273 if check():
274 return
275 await self._state_update_event.wait()
276 except TimeoutError:
277 pass
278