/
/
/
1"""Media Assistant Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8from urllib.parse import urlencode
9
10from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
11
12from music_assistant.constants import CONF_ENTRY_HTTP_PROFILE
13from music_assistant.models.player import Player, PlayerMedia
14
15from .constants import CONF_ROKU_APP_ID
16
17if TYPE_CHECKING:
18 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
19 from rokuecp import Roku
20
21 from .provider import MediaAssistantprovider
22
23
24class MediaAssistantPlayer(Player):
25 """MediaAssistantPlayer in Music Assistant."""
26
27 def __init__(
28 self,
29 provider: MediaAssistantprovider,
30 player_id: str,
31 roku_name: str,
32 roku: Roku,
33 queued: PlayerMedia | None = None,
34 ) -> None:
35 """Initialize the Player."""
36 super().__init__(provider, player_id)
37 # init some static variables
38 self.roku = roku
39 self.queued = queued
40 self._attr_name = roku_name
41 self._attr_supported_features = {
42 PlayerFeature.PLAY_MEDIA,
43 PlayerFeature.POWER, # if the player can be turned on/off
44 PlayerFeature.PAUSE,
45 PlayerFeature.VOLUME_MUTE,
46 PlayerFeature.ENQUEUE,
47 }
48 self._attr_volume_muted = False
49 self._attr_volume_level = 100
50 self.lock = asyncio.Lock() # Held when connecting or disconnecting the device
51
52 async def setup(self) -> None:
53 """Set up player in MA."""
54 self._attr_available = False
55 self._attr_powered = False
56 await self.mass.players.register_or_update(self)
57
58 @property
59 def needs_poll(self) -> bool:
60 """Return if the player needs to be polled for state updates."""
61 return True
62
63 @property
64 def poll_interval(self) -> int:
65 """Return the interval in seconds to poll the player for state updates."""
66 return 5 if self.powered else 30
67
68 async def get_config_entries(
69 self,
70 action: str | None = None,
71 values: dict[str, ConfigValueType] | None = None,
72 ) -> list[ConfigEntry]:
73 """Return all (provider/player specific) Config Entries for the player."""
74 default_entries = await super().get_config_entries(action=action, values=values)
75 return [
76 *default_entries,
77 CONF_ENTRY_HTTP_PROFILE,
78 ]
79
80 async def power(self, powered: bool) -> None:
81 """Handle POWER command on the player."""
82 logger = self.provider.logger.getChild(self.player_id)
83 logger.info("Received POWER command on player %s", self.display_name)
84
85 try:
86 device_info = await self.roku.update()
87 app_running = False
88 if device_info.app is not None:
89 app_running = device_info.app.app_id == self.provider.config.get_value(
90 CONF_ROKU_APP_ID
91 )
92 except Exception:
93 self.logger.error("Failed to get app state on: %s", self.name)
94
95 try:
96 # There's no real way to "Power" on the app since device wake up / app start
97 # is handled by The roku once it receives the Play Media request
98 if not powered:
99 if app_running:
100 await self.roku.remote("home")
101 await self.roku.remote("power")
102 except Exception:
103 self.logger.error("Failed to change Power state on: %s", self.name)
104
105 # update the player state in the player manager
106 self.update_state()
107
108 async def volume_mute(self, muted: bool) -> None:
109 """Handle VOLUME MUTE command on the player."""
110 await self.roku.remote("volume_mute")
111
112 logger = self.provider.logger.getChild(self.player_id)
113 logger.info(
114 "Received VOLUME_MUTE command on player %s with muted %s", self.display_name, muted
115 )
116 self._attr_volume_muted = muted
117 self.update_state()
118
119 async def play(self) -> None:
120 """Play command."""
121 await self.roku.remote("play")
122
123 logger = self.provider.logger.getChild(self.player_id)
124 logger.info("Received PLAY command on player %s", self.display_name)
125 self._attr_playback_state = PlaybackState.PLAYING
126 self.update_state()
127
128 async def stop(self) -> None:
129 """Stop command."""
130 try:
131 device_info = await self.roku.update()
132
133 app_running = False
134
135 if device_info.app is not None:
136 app_running = device_info.app.app_id == self.provider.config.get_value(
137 CONF_ROKU_APP_ID
138 )
139
140 if app_running:
141 # The closet thing the app has to playback stop,
142 # is sending a empty media object.
143 # I hope to implement a better solution into the app.
144 await self.roku_input(
145 {
146 "u": " ",
147 "t": "a",
148 "songName": "Music Assistant",
149 "artistName": "Waiting for Playback...",
150 },
151 )
152
153 logger = self.provider.logger.getChild(self.player_id)
154 logger.info("Received STOP command on player %s", self.display_name)
155 self._attr_playback_state = PlaybackState.IDLE
156 self._attr_current_media = None
157 self.update_state()
158 except Exception:
159 self.logger.error("Failed to send stop signal to: %s", self.name)
160
161 async def pause(self) -> None:
162 """Pause command."""
163 await self.roku.remote("play")
164
165 logger = self.provider.logger.getChild(self.player_id)
166 logger.info("Received PAUSE command on player %s", self.display_name)
167 self.update_state()
168
169 async def play_media(self, media: PlayerMedia) -> None:
170 """Play media command."""
171 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
172 try:
173 device_info = await self.roku.update()
174
175 app_running = False
176
177 if device_info.app is not None:
178 app_running = (
179 device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
180 if not device_info.app.screensaver
181 else False
182 )
183
184 f_media = {
185 "u": stream_url,
186 "t": "a",
187 "albumName": media.album or "",
188 "songName": media.title,
189 "artistName": (
190 "Music Assistant Radio"
191 if media.media_type == MediaType.RADIO
192 else media.artist
193 if media.artist is not None
194 else ("Flow Mode" if self.flow_mode else "Music Assistant")
195 ),
196 "albumArt": ("" if self.flow_mode else media.image_url or ""),
197 "songFormat": "flac",
198 "duration": media.duration or "",
199 "isLive": (
200 "true"
201 if media.media_type == MediaType.RADIO
202 or media.duration is None
203 or self.flow_mode
204 else ""
205 ),
206 }
207
208 if app_running:
209 await self.roku_input(f_media)
210 else:
211 await self.roku.launch(
212 cast("str", self.provider.config.get_value(CONF_ROKU_APP_ID)),
213 f_media,
214 )
215
216 logger = self.provider.logger.getChild(self.player_id)
217 logger.info(
218 "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
219 )
220 self._attr_powered = True
221 self._attr_current_media = media
222 self.update_state()
223 except Exception:
224 self.logger.error("Failed to Play Media on: %s", self.name)
225 return
226
227 async def enqueue_next_media(self, media: PlayerMedia) -> None:
228 """Handle enqueuing of the next (queue) item on the player."""
229 try:
230 device_info = await self.roku.update()
231
232 app_running = False
233
234 if device_info.app is not None:
235 app_running = device_info.app.app_id == self.provider.config.get_value(
236 CONF_ROKU_APP_ID
237 )
238
239 if app_running:
240 await self.roku_input(
241 {
242 "u": media.uri,
243 "t": "a",
244 "albumName": media.album,
245 "songName": media.title,
246 "artistName": media.artist,
247 "albumArt": media.image_url,
248 "songFormat": "flac",
249 "duration": media.duration,
250 "enqueue": "true",
251 },
252 )
253 self.queued = media
254 except Exception:
255 self.logger.error("Failed to Enqueue Media on: %s", self.name)
256 return
257
258 async def poll(self) -> None:
259 """Poll player for state updates."""
260 # Pull Device State
261 try:
262 device_info = await self.roku.update()
263 self._attr_available = True
264 except Exception:
265 self._attr_available = False
266 self.logger.error("Failed to retrieve Update from: %s", self.name)
267 self.update_state()
268 return
269
270 app_running = False
271
272 if device_info.app is not None:
273 app_running = device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
274
275 self._attr_powered = app_running
276
277 # If Media's Playing update its state
278 if self.powered and app_running:
279 try:
280 media_state = await self.roku._get_media_state()
281
282 play_states: dict[str, PlaybackState] = {
283 "play": PlaybackState.PLAYING,
284 "pause": PlaybackState.PAUSED,
285 }
286
287 self._attr_playback_state = play_states.get(
288 media_state["@state"], PlaybackState.IDLE
289 )
290
291 if "position" in media_state:
292 try:
293 position = int(media_state["position"].split(" ", 1)[0]) / 1000
294 if self._attr_elapsed_time is not None:
295 if abs(position - self._attr_elapsed_time) > 10:
296 self._attr_current_media = self.queued
297 self._attr_elapsed_time = position
298 self._attr_elapsed_time_last_updated = time.time()
299 except Exception:
300 self.logger.info(
301 "Playback Position received from %s Was Invalid", self.name
302 )
303
304 self.update_state()
305
306 if (
307 not self.state.current_media
308 or self._attr_playback_state != PlaybackState.PLAYING
309 ):
310 return
311
312 image_url = self.state.current_media.image_url or ""
313
314 album_name = self.state.current_media.album or ""
315 song_name = self.state.current_media.title or ""
316 artist_name = self.state.current_media.artist or ""
317 if app_running and self.flow_mode:
318 await self.roku_input(
319 {
320 "u": "",
321 "t": "m",
322 "albumName": album_name,
323 "songName": song_name,
324 "artistName": artist_name,
325 "albumArt": image_url,
326 "isLive": "true",
327 },
328 )
329 except Exception:
330 self.logger.warning("Failed to update media state for: %s", self.name)
331
332 self.update_state()
333
334 async def roku_input(self, params: dict[str, Any] | None = None) -> None:
335 """Send request to the running application on the Roku device."""
336 if params is None:
337 params = {}
338
339 encoded = urlencode(params)
340 await self.roku._request(f"input?{encoded}", method="POST", encoded=True)
341
342 async def on_unload(self) -> None:
343 """Handle logic when the player is unloaded from the Player controller."""
344 self.logger.info("Player %s unloaded", self.name)
345