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