Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Player Controls feature #1925

Merged
merged 6 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
CONF_VOLUME_NORMALIZATION_TRACKS: Final[str] = "volume_normalization_tracks"
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO: Final[str] = "volume_normalization_fixed_gain_radio"
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS: Final[str] = "volume_normalization_fixed_gain_tracks"
CONF_POWER_CONTROL: Final[str] = "power_control"
CONF_VOLUME_CONTROL: Final[str] = "volume_control"
CONF_MUTE_CONTROL: Final[str] = "mute_control"

# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
Expand Down
6 changes: 3 additions & 3 deletions music_assistant/controllers/player_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ async def resume(self, queue_id: str, fade_in: bool | None = None) -> None:
if resume_item is not None:
resume_pos = resume_pos if resume_pos > 10 else 0
queue_player = self.mass.players.get(queue_id)
if fade_in is None and not queue_player.powered:
if fade_in is None and queue_player.state == PlayerState.IDLE:
fade_in = resume_pos > 0
if resume_item.media_type == MediaType.RADIO:
# we're not able to skip in online radio so this is pointless
Expand Down Expand Up @@ -827,8 +827,8 @@ async def transfer_queue(
# edge case: the user wants to move playback from the group as a whole, to a single
# player in the group or it is grouped and the command targeted at the single player.
# We need to dissolve the group first.
await self.mass.players.cmd_power(
target_player.active_group or target_player.synced_to, False
await self.mass.players.cmd_ungroup(
target_player.active_group or target_player.synced_to
)
await asyncio.sleep(3)

Expand Down
265 changes: 206 additions & 59 deletions music_assistant/controllers/players.py

Large diffs are not rendered by default.

93 changes: 92 additions & 1 deletion music_assistant/models/player_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
from abc import abstractmethod
from typing import TYPE_CHECKING

from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
from music_assistant_models.constants import (
PLAYER_CONTROL_FAKE,
PLAYER_CONTROL_NATIVE,
PLAYER_CONTROL_NONE,
)
from music_assistant_models.enums import ConfigEntryType, PlayerFeature
from music_assistant_models.errors import UnsupportedFeaturedException
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceInfo
Expand All @@ -15,12 +22,15 @@
CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
CONF_MUTE_CONTROL,
CONF_POWER_CONTROL,
CONF_VOLUME_CONTROL,
)

from .provider import Provider

if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigEntry, PlayerConfig
from music_assistant_models.config_entries import PlayerConfig
from music_assistant_models.player import Player, PlayerMedia

# ruff: noqa: ARG001, ARG002
Expand All @@ -45,6 +55,8 @@ async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry,
CONF_ENTRY_ANNOUNCE_VOLUME,
CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
# add player control entries
*self._create_player_control_config_entries(self.mass.players.get(player_id)),
)

async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
Expand Down Expand Up @@ -257,3 +269,82 @@ def players(self) -> list[Player]:
for player in self.mass.players
if player.provider in (self.instance_id, self.domain)
]

def _create_player_control_config_entries(
self, player: Player | None
) -> tuple[ConfigEntry, ...]:
"""Create config entries for player controls."""
all_controls = self.mass.players.player_controls()
power_controls = [x for x in all_controls if x.supports_power]
volume_controls = [x for x in all_controls if x.supports_volume]
mute_controls = [x for x in all_controls if x.supports_mute]
# work out player supported features
supports_power = PlayerFeature.POWER in player.supported_features if player else False
supports_volume = PlayerFeature.VOLUME_SET in player.supported_features if player else False
supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features if player else False
# create base options per control type (and add defaults like native and fake)
base_power_options: list[ConfigValueOption] = [
ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
]
if supports_power:
base_power_options.append(
ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
)
base_volume_options: list[ConfigValueOption] = [
ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
]
if supports_volume:
base_volume_options.append(
ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
)
base_mute_options: list[ConfigValueOption] = [
ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
]
if supports_mute:
base_mute_options.append(
ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
)
# return final config entries for all options
return (
# Power control config entry
ConfigEntry(
key=CONF_POWER_CONTROL,
type=ConfigEntryType.STRING,
label="Power Control",
default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
required=True,
options=(
*base_power_options,
*(ConfigValueOption(x.name, x.id) for x in power_controls),
),
category="player_controls",
),
# Volume control config entry
ConfigEntry(
key=CONF_VOLUME_CONTROL,
type=ConfigEntryType.STRING,
label="Volume Control",
default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
required=True,
options=(
*base_volume_options,
*(ConfigValueOption(x.name, x.id) for x in volume_controls),
),
category="player_controls",
),
# Mute control config entry
ConfigEntry(
key=CONF_MUTE_CONTROL,
type=ConfigEntryType.STRING,
label="Mute Control",
default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
required=True,
options=(
*base_mute_options,
*(ConfigValueOption(x.name, x.id) for x in mute_controls),
),
category="player_controls",
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async def on_mdns_service_state_change(
# Instantiate the MA Player object and register it with the player manager
mass_player = Player(
player_id=player_id,
provider=self.instance_id,
provider=self.lookup_key,
type=PlayerType.PLAYER,
name=name,
available=True,
Expand Down
17 changes: 7 additions & 10 deletions music_assistant/providers/airplay/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,6 @@ async def cmd_group(self, player_id: str, target_player: str) -> None:
parent_player.group_childs.append(parent_player.player_id)
parent_player.group_childs.append(child_player.player_id)
child_player.synced_to = parent_player.player_id
# mark players as powered
parent_player.powered = True
child_player.powered = True
# check if we should (re)start or join a stream session
active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id)
if active_queue.state == PlayerState.PLAYING:
Expand Down Expand Up @@ -482,11 +479,10 @@ async def _setup_player(
volume = FALLBACK_VOLUME
mass_player = Player(
player_id=player_id,
provider=self.instance_id,
provider=self.lookup_key,
type=PlayerType.PLAYER,
name=display_name,
available=True,
powered=False,
device_info=DeviceInfo(
model=model,
manufacturer=manufacturer,
Expand All @@ -499,7 +495,7 @@ async def _setup_player(
PlayerFeature.VOLUME_SET,
},
volume_level=volume,
can_group_with={self.instance_id},
can_group_with={self.lookup_key},
enabled_by_default=not is_broken_raop_model(manufacturer, model),
)
await self.mass.players.register_or_update(mass_player)
Expand Down Expand Up @@ -655,7 +651,8 @@ async def monitor_prevent_playback(self, player_id: str) -> None:
return
await asyncio.sleep(0.5)

airplay_player.logger.info(
"Player has been in prevent playback mode for too long, powering off.",
)
await self.mass.players.cmd_power(airplay_player.player_id, False)
if airplay_player.raop_stream and airplay_player.raop_stream.session:
airplay_player.logger.info(
"Player has been in prevent playback mode for too long, aborting playback.",
)
await airplay_player.raop_stream.session.remove_client(airplay_player)
5 changes: 2 additions & 3 deletions music_assistant/providers/bluesound/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,10 @@ async def on_mdns_service_state_change(

bluos_player.mass_player = mass_player = Player(
player_id=self.player_id,
provider=self.instance_id,
provider=self.lookup_key,
type=PlayerType.PLAYER,
name=name,
available=True,
powered=True,
device_info=DeviceInfo(
model="BluOS speaker",
manufacturer="Bluesound",
Expand All @@ -295,7 +294,7 @@ async def on_mdns_service_state_change(
},
needs_poll=True,
poll_interval=30,
can_group_with={self.instance_id},
can_group_with={self.lookup_key},
)
await self.mass.players.register(mass_player)

Expand Down
2 changes: 1 addition & 1 deletion music_assistant/providers/chromecast/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ def _on_chromecast_discovered(self, uuid, _) -> None:
),
player=Player(
player_id=player_id,
provider=self.instance_id,
provider=self.lookup_key,
type=player_type,
name=cast_info.friendly_name,
available=False,
Expand Down
10 changes: 2 additions & 8 deletions music_assistant/providers/dlna/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@
from async_upnp_client.profiles.dlna import DmrDevice, TransportState
from async_upnp_client.search import async_search
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
from music_assistant_models.enums import (
ConfigEntryType,
PlayerFeature,
PlayerState,
PlayerType,
)
from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType
from music_assistant_models.errors import PlayerUnavailableError
from music_assistant_models.player import DeviceInfo, Player, PlayerMedia

Expand Down Expand Up @@ -490,11 +485,10 @@ async def _device_discovered(self, udn: str, description_url: str) -> None:
udn=udn,
player=Player(
player_id=udn,
provider=self.instance_id,
provider=self.lookup_key,
type=PlayerType.PLAYER,
name=udn,
available=False,
powered=False,
# device info will be discovered later after connect
device_info=DeviceInfo(
model="unknown",
Expand Down
3 changes: 1 addition & 2 deletions music_assistant/providers/fully_kiosk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,10 @@ async def loaded_in_mass(self) -> None:
if not player:
player = Player(
player_id=player_id,
provider=self.instance_id,
provider=self.lookup_key,
type=PlayerType.PLAYER,
name=self._fully.deviceInfo["deviceName"],
available=True,
powered=False,
device_info=DeviceInfo(
model=self._fully.deviceInfo["deviceModel"],
manufacturer=self._fully.deviceInfo["deviceManufacturer"],
Expand Down
Loading
Loading