/
/
/
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, PlayerType
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_type = PlayerType.PLAYER
42 self._attr_supported_features = {
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 try:
172 device_info = await self.roku.update()
173
174 app_running = False
175
176 if device_info.app is not None:
177 app_running = (
178 device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
179 if not device_info.app.screensaver
180 else False
181 )
182
183 f_media = {
184 "u": media.uri,
185 "t": "a",
186 "albumName": media.album or "",
187 "songName": media.title,
188 "artistName": (
189 "Music Assistant Radio"
190 if media.media_type == MediaType.RADIO
191 else media.artist
192 if media.artist is not None
193 else ("Flow Mode" if self.flow_mode else "Music Assistant")
194 ),
195 "albumArt": ("" if self.flow_mode else media.image_url or ""),
196 "songFormat": "flac",
197 "duration": media.duration or "",
198 "isLive": (
199 "true"
200 if media.media_type == MediaType.RADIO
201 or media.duration is None
202 or self.flow_mode
203 else ""
204 ),
205 }
206
207 if app_running:
208 await self.roku_input(f_media)
209 else:
210 await self.roku.launch(
211 cast("str", self.provider.config.get_value(CONF_ROKU_APP_ID)),
212 f_media,
213 )
214
215 logger = self.provider.logger.getChild(self.player_id)
216 logger.info(
217 "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
218 )
219 self._attr_powered = True
220 self._attr_current_media = media
221 self.update_state()
222 except Exception:
223 self.logger.error("Failed to Play Media on: %s", self.name)
224 return
225
226 async def enqueue_next_media(self, media: PlayerMedia) -> None:
227 """Handle enqueuing of the next (queue) item on the player."""
228 try:
229 device_info = await self.roku.update()
230
231 app_running = False
232
233 if device_info.app is not None:
234 app_running = device_info.app.app_id == self.provider.config.get_value(
235 CONF_ROKU_APP_ID
236 )
237
238 if app_running:
239 await self.roku_input(
240 {
241 "u": media.uri,
242 "t": "a",
243 "albumName": media.album,
244 "songName": media.title,
245 "artistName": media.artist,
246 "albumArt": media.image_url,
247 "songFormat": "flac",
248 "duration": media.duration,
249 "enqueue": "true",
250 },
251 )
252 self.queued = media
253 except Exception:
254 self.logger.error("Failed to Enqueue Media on: %s", self.name)
255 return
256
257 async def poll(self) -> None:
258 """Poll player for state updates."""
259 # Pull Device State
260 try:
261 device_info = await self.roku.update()
262 self._attr_available = True
263 except Exception:
264 self._attr_available = False
265 self.logger.error("Failed to retrieve Update from: %s", self.name)
266 self.update_state()
267 return
268
269 app_running = False
270
271 if device_info.app is not None:
272 app_running = device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
273
274 self._attr_powered = app_running
275
276 # If Media's Playing update its state
277 if self.powered and app_running:
278 try:
279 media_state = await self.roku._get_media_state()
280
281 play_states: dict[str, PlaybackState] = {
282 "play": PlaybackState.PLAYING,
283 "pause": PlaybackState.PAUSED,
284 }
285
286 self._attr_playback_state = play_states.get(
287 media_state["@state"], PlaybackState.IDLE
288 )
289
290 if "position" in media_state:
291 try:
292 position = int(media_state["position"].split(" ", 1)[0]) / 1000
293 if self.elapsed_time is not None:
294 if abs(position - self.elapsed_time) > 10:
295 self._attr_current_media = self.queued
296 self._attr_elapsed_time = position
297 self._attr_elapsed_time_last_updated = time.time()
298 except Exception:
299 self.logger.info(
300 "Playback Position received from %s Was Invalid", self.name
301 )
302
303 self.update_state()
304
305 if not self.current_media or self._attr_playback_state != PlaybackState.PLAYING:
306 return
307
308 image_url = self.current_media.image_url or ""
309
310 album_name = self.current_media.album or ""
311 song_name = self.current_media.title or ""
312 artist_name = self.current_media.artist or ""
313 if app_running and self.flow_mode:
314 await self.roku_input(
315 {
316 "u": "",
317 "t": "m",
318 "albumName": album_name,
319 "songName": song_name,
320 "artistName": artist_name,
321 "albumArt": image_url,
322 "isLive": "true",
323 },
324 )
325 except Exception:
326 self.logger.warning("Failed to update media state for: %s", self.name)
327
328 self.update_state()
329
330 async def roku_input(self, params: dict[str, Any] | None = None) -> None:
331 """Send request to the running application on the Roku device."""
332 if params is None:
333 params = {}
334
335 encoded = urlencode(params)
336 await self.roku._request(f"input?{encoded}", method="POST", encoded=True)
337
338 async def on_unload(self) -> None:
339 """Handle logic when the player is unloaded from the Player controller."""
340 self.logger.info("Player %s unloaded", self.name)
341