/
/
/
1"""
2Announcements Mixin for the Player Controller.
3
4Handles playback of announcements (such as TTS messages) on a player: preparing the
5player, handing it the announcement, waiting for it to finish and restoring whatever
6the player was doing before.
7
8This module provides the AnnouncementsMixin class which is inherited by
9PlayerController to add announcement capabilities. The audio itself is rendered and
10served by the streams controller (see controllers/streams/announcements.py).
11"""
12
13from __future__ import annotations
14
15import asyncio
16import logging
17import time
18from math import ceil
19from typing import TYPE_CHECKING, cast
20
21from music_assistant_models.auth import Scope
22from music_assistant_models.constants import PLAYER_CONTROL_NONE
23from music_assistant_models.enums import (
24 MediaType,
25 PlaybackState,
26 PlayerFeature,
27 PlayerType,
28)
29from music_assistant_models.errors import PlayerCommandFailed
30from music_assistant_models.player import PlayerMedia
31
32from music_assistant.constants import (
33 ANNOUNCE_ALERT_FILE,
34 ATTR_ANNOUNCEMENT_IN_PROGRESS,
35 CONF_ENTRY_ANNOUNCE_VOLUME,
36 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
37 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
38 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
39 CONF_ENTRY_TTS_PRE_ANNOUNCE,
40 CONF_PRE_ANNOUNCE_CHIME_URL,
41)
42from music_assistant.controllers.streams.announcements import MAX_CLIP_SECONDS
43from music_assistant.helpers.api import api_command
44from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
45from music_assistant.models.player import Player
46
47from .constants import PlayerLockPurpose
48from .helpers import AnnounceData, handle_player_command
49
50if TYPE_CHECKING:
51 from collections.abc import Iterator
52
53 from music_assistant import MusicAssistant
54
55
56class AnnouncementsMixin:
57 """
58 Mixin class providing announcement playback for PlayerController.
59
60 Handles:
61 - Resolving the pre-announce chime and announcement volume from configuration
62 - Forwarding a group announcement to its individual members
63 - Native announcement support (on the player itself or a linked protocol)
64 - The fallback implementation for players without native support
65
66 This mixin expects to be mixed with a class that provides:
67 - mass: MusicAssistant instance
68 - logger: logging.Logger instance
69 - get_player(): method to get a player by ID
70 - iter_group_members(): method to iterate the members of a group player
71 - _get_control_target(): method to resolve the player to send a command to
72 - the _handle_cmd_* / cmd_* playback and grouping commands used below
73 """
74
75 # Type hints for attributes provided by the class this mixin is used with
76 if TYPE_CHECKING:
77 mass: MusicAssistant
78 logger: logging.Logger
79 _players: dict[str, Player]
80
81 def get_player( # noqa: D102
82 self, player_id: str, raise_unavailable: bool = False
83 ) -> Player | None: ...
84
85 def iter_group_members( # noqa: D102
86 self,
87 group_player: Player,
88 only_powered: bool = False,
89 only_playing: bool = False,
90 active_only: bool = False,
91 exclude_self: bool = True,
92 ) -> Iterator[Player]: ...
93
94 def _get_control_target(
95 self,
96 player: Player,
97 required_feature: PlayerFeature,
98 require_active: bool = False,
99 ) -> Player | None: ...
100
101 async def _wait_for_playback_state(
102 self,
103 player: Player,
104 wanted_state: PlaybackState,
105 timeout: float,
106 minimal_time: float = 0,
107 ) -> None: ...
108
109 async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None: ...
110
111 async def _handle_cmd_stop(self, player_id: str) -> None: ...
112
113 async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None: ...
114
115 async def _handle_cmd_volume_mute(
116 self, player: Player, mute_control: str, muted: bool
117 ) -> None: ...
118
119 async def _handle_cmd_power(
120 self, player_id: str, powered: bool, skip_auto_play: bool = False
121 ) -> None: ...
122
123 async def _handle_cmd_resume(
124 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
125 ) -> None: ...
126
127 async def cmd_play(self, player_id: str) -> None: ... # noqa: D102
128
129 async def cmd_ungroup(self, player_id: str) -> None: ... # noqa: D102
130
131 async def cmd_set_members( # noqa: D102
132 self,
133 target_player: str,
134 player_ids_to_add: list[str] | None = None,
135 player_ids_to_remove: list[str] | None = None,
136 ) -> None: ...
137
138 # handle_player_command is typed against PlayerController, which this mixin only
139 # becomes once mixed in; the attributes it needs are declared in the block above.
140 # mypy reports that on the outermost decorator, hence the ignore below.
141 @api_command("players/cmd/play_announcement", required_scope=Scope.PLAYERS_CONTROL) # type: ignore[type-var]
142 @handle_player_command(lock=PlayerLockPurpose.PLAYBACK)
143 async def play_announcement(
144 self,
145 player_id: str,
146 url: str,
147 pre_announce: bool | None = None,
148 volume_level: int | None = None,
149 pre_announce_url: str | None = None,
150 ) -> None:
151 """
152 Handle playback of an announcement (url) on given player.
153
154 :param player_id: Player ID of the player to handle the command.
155 :param url: URL of the announcement to play.
156 :param pre_announce: Optional bool if pre-announce should be used.
157 :param volume_level: Optional volume level to set for the announcement.
158 :param pre_announce_url: Optional custom URL to use for the pre-announce chime.
159 """
160 player = self.get_player(player_id, True)
161 assert player is not None # for type checking
162 if not url.startswith("http"):
163 raise PlayerCommandFailed("Only URLs are supported for announcements")
164 if (
165 pre_announce
166 and pre_announce_url
167 and not validate_announcement_chime_url(pre_announce_url)
168 ):
169 raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
170 # determine pre-announce from (group)player config
171 if pre_announce is None and "tts" in url:
172 conf_pre_announce = self.mass.config.get_raw_player_config_value(
173 player_id,
174 CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
175 CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
176 )
177 pre_announce = cast("bool", conf_pre_announce)
178 if pre_announce_url is None:
179 if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
180 player_id,
181 CONF_PRE_ANNOUNCE_CHIME_URL,
182 ):
183 # player default custom chime url
184 pre_announce_url = cast("str", conf_pre_announce_url)
185 else:
186 # use global default chime url
187 pre_announce_url = ANNOUNCE_ALERT_FILE
188 announce_data = AnnounceData(
189 announcement_url=url,
190 pre_announce=bool(pre_announce),
191 pre_announce_url=pre_announce_url,
192 # filled in below, once we know which player fetches the stream
193 announce_player_id=None,
194 )
195 # Register right away, so the audio is (nearly always fully) rendered by the time
196 # the player is ready for it. The render is shared by everything that consumes
197 # this announcement, including all members of a group.
198 render = self.mass.streams.announcement_renderer.register(player_id, announce_data)
199 try:
200 # mark announcement_in_progress on player
201 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
202 # if player type is group with all members supporting announcements,
203 # we forward the request to each individual player
204 if player.state.type == PlayerType.GROUP and (
205 all(
206 PlayerFeature.PLAY_ANNOUNCEMENT in x.state.supported_features
207 for x in self.iter_group_members(player)
208 )
209 ):
210 # forward the request to each individual player
211 async with TaskManager(self.mass) as tg:
212 for group_member in player.state.group_members:
213 tg.create_task(
214 self.play_announcement(
215 group_member,
216 url=url,
217 pre_announce=pre_announce,
218 volume_level=volume_level,
219 pre_announce_url=pre_announce_url,
220 )
221 )
222 return
223 self.logger.info(
224 "Playback announcement to player %s (with pre-announce: %s): %s",
225 player.state.name,
226 pre_announce,
227 url,
228 )
229 # determine if the player has native announcements support
230 # or if any linked protocol has announcement support
231 native_announce_support = False
232 if announce_player := self._get_control_target(
233 player,
234 required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
235 require_active=False,
236 ):
237 native_announce_support = True
238 else:
239 announce_player = player
240 # create a PlayerMedia object for the announcement so
241 # we can send a regular play-media call downstream
242 announce_data["announce_player_id"] = (
243 announce_player.player_id if native_announce_support else None
244 )
245 announcement = PlayerMedia(
246 uri=self.mass.streams.get_announcement_url(player_id),
247 media_type=MediaType.ANNOUNCEMENT,
248 title="Announcement",
249 custom_data=dict(announce_data),
250 )
251 # handle native announce support (player or linked protocol)
252 if native_announce_support:
253 # hand the url to the player as soon as there is audio to serve from;
254 # its exact length is resolved further downstream, while it plays
255 if not await render.wait_ready():
256 self.logger.warning(
257 "Announcement to player %s - no audio available for %s",
258 player.state.name,
259 url,
260 )
261 await self._play_native_announcement(
262 player, announce_player, announcement, volume_level
263 )
264 return
265 # use fallback/default implementation
266 await self._play_announcement(player, announcement, volume_level)
267 finally:
268 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
269 await self.mass.streams.announcement_renderer.unregister(player_id, render)
270
271 def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
272 """
273 Get the (player specific) volume for a announcement.
274
275 :param player_id: The player the announcement is played on.
276 :param volume_override: Volume level that overrides the configured strategy.
277 """
278 volume_strategy = self.mass.config.get_raw_player_config_value(
279 player_id,
280 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
281 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
282 )
283 volume_strategy_volume = self.mass.config.get_raw_player_config_value(
284 player_id,
285 CONF_ENTRY_ANNOUNCE_VOLUME.key,
286 CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
287 )
288 if volume_strategy == "none":
289 return None
290 volume_level = volume_override
291 if volume_level is None and volume_strategy == "absolute":
292 volume_level = int(cast("float", volume_strategy_volume))
293 elif volume_level is None and volume_strategy == "relative":
294 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
295 volume_level = int(
296 player.state.volume_level + cast("float", volume_strategy_volume)
297 )
298 elif volume_level is None and volume_strategy == "percentual":
299 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
300 percentual = (player.state.volume_level / 100) * cast(
301 "float", volume_strategy_volume
302 )
303 volume_level = int(player.state.volume_level + percentual)
304 if volume_level is not None:
305 announce_volume_min = cast(
306 "float",
307 self.mass.config.get_raw_player_config_value(
308 player_id,
309 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
310 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
311 ),
312 )
313 volume_level = max(int(announce_volume_min), volume_level)
314 announce_volume_max = cast(
315 "float",
316 self.mass.config.get_raw_player_config_value(
317 player_id,
318 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
319 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
320 ),
321 )
322 volume_level = min(int(announce_volume_max), volume_level)
323 return None if volume_level is None else int(volume_level)
324
325 async def _play_native_announcement(
326 self,
327 player: Player,
328 announce_player: Player,
329 announcement: PlayerMedia,
330 volume_level: int | None,
331 ) -> None:
332 """
333 Hand an announcement to a player that plays it natively.
334
335 :param player: The player the announcement is played on.
336 :param announce_player: The player (or linked protocol) that plays the announcement.
337 :param announcement: The announcement to play.
338 :param volume_level: Optional volume level override for the announcement.
339 """
340 # an announcement is always meant to be heard, so a deliberate mute is lifted for
341 # its duration. this happens before the announcement volume is resolved below,
342 # since a fake mute control parks the player at volume 0 to mute it.
343 # in case of a (sync) group, this covers all child players.
344 muted_players = [
345 muted_player
346 for member_id in player.state.group_members or (player.player_id,)
347 if (muted_player := self.get_player(member_id)) and muted_player.state.volume_muted
348 ]
349 try:
350 async with TaskManager(self.mass) as tg:
351 for muted_player in muted_players:
352 tg.create_task(self._set_announcement_mute(muted_player, False))
353 announcement_volume = self.get_announcement_volume(player.player_id, volume_level)
354 await announce_player.play_announcement(announcement, announcement_volume)
355 finally:
356 # the provider only returns once the announcement finished playing
357 async with TaskManager(self.mass) as tg:
358 for muted_player in muted_players:
359 tg.create_task(self._set_announcement_mute(muted_player, True))
360
361 async def _play_announcement(
362 self,
363 player: Player,
364 announcement: PlayerMedia,
365 volume_level: int | None = None,
366 ) -> None:
367 """
368 Handle (default/fallback) implementation of the play announcement feature.
369
370 This default implementation will;
371 - stop playback of the current media (if needed)
372 - power on the player (if needed)
373 - raise the volume a bit
374 - play the announcement (from given url)
375 - wait for the player to finish playing
376 - restore the previous power and volume
377 - restore playback (if needed and if possible)
378
379 This default implementation will only be used if the player
380 (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
381 """
382 prev_state = player.state.playback_state
383 # A player without power control has no power state to restore, so it counts as
384 # powered here - otherwise the restore below would be skipped altogether for it,
385 # leaving the player ungrouped from its (sync)group.
386 prev_power = (
387 player.state.power_control == PLAYER_CONTROL_NONE
388 or bool(player.state.powered)
389 or prev_state != PlaybackState.IDLE
390 )
391 prev_synced_to = player.state.synced_to
392 prev_group = (
393 self.get_player(player.state.active_group) if player.state.active_group else None
394 )
395 prev_source = player.state.active_source
396 prev_media = player.state.current_media
397 prev_media_name = prev_media.title or prev_media.uri if prev_media else None
398 # An announcement is transient: a player that is still busy with an earlier
399 # announcement holds no user content, so there is nothing to restore for it.
400 # The raw media attribute is read here (instead of state.current_media, which
401 # reports the active queue item) since it tells what the device is playing.
402 restore_playback = prev_state == PlaybackState.PLAYING and not (
403 player.current_media is not None
404 and player.current_media.media_type == MediaType.ANNOUNCEMENT
405 )
406 # filled while the players are unmuted and the temporary volume is applied below
407 prev_volumes: dict[str, int] = {}
408 prev_muted: set[str] = set()
409 # everything from here on alters the player state, so the restore in the finally
410 # block must run even when the announcement itself fails halfway through
411 try:
412 await self._prepare_for_announcement(
413 player,
414 volume_level=volume_level,
415 prev_state=prev_state,
416 prev_synced_to=prev_synced_to,
417 prev_group=prev_group,
418 prev_media_name=prev_media_name,
419 prev_volumes=prev_volumes,
420 prev_muted=prev_muted,
421 )
422 # play the announcement
423 self.logger.debug(
424 "Announcement to player %s - playing the announcement on the player...",
425 player.state.name,
426 )
427 render = (
428 self.mass.streams.announcement_renderer.get(
429 cast("AnnounceData", announcement.custom_data)
430 )
431 if announcement.custom_data
432 else None
433 )
434 if render is not None and not await render.wait_ready():
435 # the render has been filling while the player was prepared above; play on
436 # regardless when it came up empty, so the restore still runs
437 self.logger.warning(
438 "Announcement to player %s - no audio available for %s",
439 player.state.name,
440 announcement.uri,
441 )
442 await self._handle_play_media(player.player_id, announcement)
443 # wait for the player(s) to play
444 await self._wait_for_playback_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
445 playback_started = time.time()
446 # wait for the player to stop playing
447 duration = float(announcement.duration) if announcement.duration else None
448 if duration is None and render is not None:
449 # the render knows the exact length of the audio it produced
450 duration = await render.wait_finished()
451 if duration:
452 announcement.duration = ceil(duration)
453 if duration is None:
454 # length unknown (e.g. the source stalled): wait for the player to report it
455 # finished, bounded by the longest clip an announcement can produce
456 await self._wait_for_playback_state(
457 player, PlaybackState.IDLE, timeout=MAX_CLIP_SECONDS + 10
458 )
459 else:
460 # waiting for the length above already consumed part of the announcement
461 elapsed = time.time() - playback_started
462 await self._wait_for_playback_state(
463 player,
464 PlaybackState.IDLE,
465 timeout=max(duration + 10 - elapsed, 1),
466 minimal_time=max(duration + 2 - elapsed, 0),
467 )
468 finally:
469 await self._restore_after_announcement(
470 player,
471 prev_power=prev_power,
472 prev_volumes=prev_volumes,
473 prev_muted=prev_muted,
474 prev_synced_to=prev_synced_to,
475 prev_group=prev_group,
476 prev_source=prev_source,
477 prev_media=prev_media,
478 restore_playback=restore_playback,
479 )
480
481 async def _prepare_for_announcement(
482 self,
483 player: Player,
484 *,
485 volume_level: int | None,
486 prev_state: PlaybackState,
487 prev_synced_to: str | None,
488 prev_group: Player | None,
489 prev_media_name: str | None,
490 prev_volumes: dict[str, int],
491 prev_muted: set[str],
492 ) -> None:
493 """
494 Free up the player for an announcement and apply the temporary announcement volume.
495
496 :param player: The player the announcement will be played on.
497 :param volume_level: Optional volume level override for the announcement.
498 :param prev_state: The playback state the player had before the announcement.
499 :param prev_synced_to: Player ID of the sync leader the player is synced to (if any).
500 :param prev_group: The group player the player is a member of (if any).
501 :param prev_media_name: Name of the media the player was playing (for logging).
502 :param prev_volumes: Mapping that is filled in-place with the previous volume level
503 per player id, so the caller can restore the volumes even if this call fails.
504 :param prev_muted: Set that is filled in-place with the ids of the players that were
505 muted, so the caller can restore the mute state even if this call fails.
506 """
507 if prev_synced_to:
508 # ungroup player if its currently synced
509 self.logger.debug(
510 "Announcement to player %s - ungrouping player from %s...",
511 player.state.name,
512 prev_synced_to,
513 )
514 await self.cmd_ungroup(player.player_id)
515 elif prev_group:
516 # if the player is part of a group player, we need to ungroup it
517 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
518 self.logger.debug(
519 "Announcement to player %s - ungrouping from group player %s...",
520 player.state.name,
521 prev_group.display_name,
522 )
523 await prev_group.set_members(player_ids_to_remove=[player.player_id])
524 else:
525 # if the player is part of a group player that does not support ungrouping,
526 # we need to power off the groupplayer instead
527 self.logger.debug(
528 "Announcement to player %s - turning off group player %s...",
529 player.state.name,
530 prev_group.display_name,
531 )
532 await self._handle_cmd_power(prev_group.player_id, False)
533 elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
534 # normal/standalone player: stop player if its currently playing
535 self.logger.debug(
536 "Announcement to player %s - stop existing content (%s)...",
537 player.state.name,
538 prev_media_name,
539 )
540 await self._handle_cmd_stop(player.player_id)
541 # wait for the player to stop
542 await self._wait_for_playback_state(player, PlaybackState.IDLE, 10, 0.4)
543 # unmute and adjust volume if needed
544 # in case of a (sync) group, we need to do this for all child players
545 async with TaskManager(self.mass) as tg:
546 for volume_player_id in player.state.group_members or (player.player_id,):
547 if not (volume_player := self.get_player(volume_player_id)):
548 continue
549 # catch any players that have a different source active
550 if (
551 volume_player.state.active_source
552 not in (
553 player.state.active_source,
554 volume_player.player_id,
555 None,
556 )
557 and volume_player.state.playback_state == PlaybackState.PLAYING
558 ):
559 self.logger.warning(
560 "Detected announcement to playergroup %s while group member %s is playing "
561 "other content, this may lead to unexpected behavior.",
562 player.state.name,
563 volume_player.state.name,
564 )
565 tg.create_task(self._handle_cmd_stop(volume_player.player_id))
566 tg.create_task(
567 self._unmute_and_set_announcement_volume(
568 volume_player, volume_level, prev_volumes, prev_muted
569 )
570 )
571
572 async def _restore_after_announcement(
573 self,
574 player: Player,
575 *,
576 prev_power: bool,
577 prev_volumes: dict[str, int],
578 prev_muted: set[str],
579 prev_synced_to: str | None,
580 prev_group: Player | None,
581 prev_source: str | None,
582 prev_media: PlayerMedia | None,
583 restore_playback: bool,
584 ) -> None:
585 """
586 Restore the player state that was captured before an announcement was played.
587
588 This also runs when the announcement failed halfway through, so a failing restore
589 step is logged instead of raised: it may never mask the error that caused it.
590
591 :param player: The player the announcement was played on.
592 :param prev_power: Whether the player was powered before the announcement.
593 :param prev_volumes: The previous volume level per player id.
594 :param prev_muted: The ids of the players that were muted before the announcement.
595 :param prev_synced_to: Player ID of the sync leader the player was synced to (if any).
596 :param prev_group: The group player the player was a member of (if any).
597 :param prev_source: The source that was active before the announcement.
598 :param prev_media: The media that was loaded before the announcement.
599 :param restore_playback: Whether playback needs to be resumed.
600 """
601 self.logger.debug(
602 "Announcement to player %s - restore previous state...", player.state.name
603 )
604 # restore volume
605 async with TaskManager(self.mass) as tg:
606 for volume_player_id, prev_volume in prev_volumes.items():
607 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
608 # restore mute after the volume, because setting a volume unmutes the player again
609 async with TaskManager(self.mass) as tg:
610 for muted_player_id in prev_muted:
611 if not (muted_player := self.get_player(muted_player_id)):
612 continue
613 tg.create_task(self._set_announcement_mute(muted_player, True))
614 await asyncio.sleep(0.2)
615 try:
616 # either power off the player or resume playing
617 if not prev_power:
618 # prev_power is always True for a player without power control,
619 # so there is an actual power control to switch off here
620 self.logger.debug(
621 "Announcement to player %s - turning player off again...", player.state.name
622 )
623 await self._handle_cmd_power(player.player_id, False)
624 return
625 if prev_synced_to:
626 self.logger.debug(
627 "Announcement to player %s - syncing back to %s...",
628 player.state.name,
629 prev_synced_to,
630 )
631 await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
632 elif prev_group:
633 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
634 self.logger.debug(
635 "Announcement to player %s - grouping back to group player %s...",
636 player.state.name,
637 prev_group.display_name,
638 )
639 await prev_group.set_members(player_ids_to_add=[player.player_id])
640 elif restore_playback:
641 # if the player is part of a group player that does not support set_members,
642 # we need to restart the groupplayer
643 self.logger.debug(
644 "Announcement to player %s - restarting playback on group player %s...",
645 player.state.name,
646 prev_group.display_name,
647 )
648 await self.cmd_play(prev_group.player_id)
649 elif restore_playback:
650 # player was playing something before the announcement - try to resume that here
651 await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
652 except Exception as err:
653 # deliberately broad: set_members is a raw provider call that is not wrapped
654 # into a MusicAssistantError, so it can surface anything its client library
655 # raises. CancelledError is a BaseException and still propagates.
656 self.logger.warning(
657 "Announcement to player %s - restoring the previous state failed: %s",
658 player.state.name,
659 err,
660 )
661
662 async def _unmute_and_set_announcement_volume(
663 self,
664 volume_player: Player,
665 volume_level: int | None,
666 prev_volumes: dict[str, int],
667 prev_muted: set[str],
668 ) -> None:
669 """
670 Make a single player ready to be heard: unmute it and set the announcement volume.
671
672 :param volume_player: The player to prepare.
673 :param volume_level: Optional volume level override for the announcement.
674 :param prev_volumes: Mapping that is filled in-place with the previous volume level
675 per player id.
676 :param prev_muted: Set that is filled in-place with the ids of the players that
677 were muted.
678 """
679 if volume_player.state.volume_muted:
680 # an announcement is always meant to be heard, so a deliberate mute is lifted
681 # for its duration. this happens before the volume is read below, since a
682 # fake mute control parks the player at volume 0 to mute it.
683 prev_muted.add(volume_player.player_id)
684 await self._set_announcement_mute(volume_player, False)
685 if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
686 return
687 if (prev_volume := volume_player.state.volume_level) is None:
688 return
689 announcement_volume = self.get_announcement_volume(volume_player.player_id, volume_level)
690 # get_announcement_volume already returns None when the volume must be left
691 # alone, so any number it does return is the volume to announce at - including
692 # 0, which must not be mistaken for 'no volume configured'
693 if announcement_volume is None or announcement_volume == prev_volume:
694 return
695 prev_volumes[volume_player.player_id] = prev_volume
696 self.logger.debug(
697 "Announcement to player %s - setting temporary volume (%s)...",
698 volume_player.state.name,
699 announcement_volume,
700 )
701 await self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
702
703 async def _set_announcement_mute(self, player: Player, muted: bool) -> None:
704 """
705 Mute or unmute a player for the duration of an announcement.
706
707 :param player: The player to mute or unmute.
708 :param muted: bool if the player should be muted.
709 """
710 # the internal handler is used instead of cmd_volume_mute so the player keeps the
711 # mute lock it earned as a group member: the public command clears that lock on
712 # unmute and the player may well be ungrouped by the time it is muted back.
713 mute_control = player.mute_control
714 if mute_control == PLAYER_CONTROL_NONE:
715 return
716 await self._handle_cmd_volume_mute(player, mute_control, muted)
717