Skip to content
Open
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
28 changes: 27 additions & 1 deletion music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@
CONF_POWER_CONTROL: Final[str] = "power_control"
CONF_VOLUME_CONTROL: Final[str] = "volume_control"
CONF_MUTE_CONTROL: Final[str] = "mute_control"
CONF_MIN_VOLUME: Final[str] = "min_volume"
CONF_MAX_VOLUME: Final[str] = "max_volume"
CONF_PREFERRED_OUTPUT_PROTOCOL: Final[str] = "preferred_output_protocol"
CONF_LINKED_PROTOCOL_IDS: Final[str] = "linked_protocol_ids" # cached for fast restart
CONF_PROTOCOL_PARENT_ID: Final[str] = (
Expand Down Expand Up @@ -271,6 +273,30 @@ def load_genre_mapping() -> list[dict[str, Any]]:
category="player_controls",
)

CONF_ENTRY_MIN_VOLUME = ConfigEntry(
key=CONF_MIN_VOLUME,
type=ConfigEntryType.INTEGER,
range=(0, 100),
default_value=0,
label="Minimum volume",
description="Minimum device volume. "
"The volume slider (0-100) will be scaled to this as the lower bound.",
category="player_controls",
advanced=True,
)

CONF_ENTRY_MAX_VOLUME = ConfigEntry(
key=CONF_MAX_VOLUME,
type=ConfigEntryType.INTEGER,
range=(0, 100),
default_value=100,
label="Maximum volume",
description="Maximum device volume. "
"The volume slider (0-100) will be scaled to this as the upper bound.",
category="player_controls",
advanced=True,
)

CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry(
key=CONF_OUTPUT_CHANNELS,
type=ConfigEntryType.STRING,
Expand Down Expand Up @@ -906,6 +932,7 @@ def create_sample_rates_config_entry(
ATTR_PREVIOUS_VOLUME: Final[str] = "previous_volume"
ATTR_LAST_POLL: Final[str] = "last_poll"
ATTR_GROUP_MEMBERS: Final[str] = "group_members"
ATTR_GROUP_VOLUME_SNAPSHOT: Final[str] = "group_volume_snapshot"
ATTR_ELAPSED_TIME: Final[str] = "elapsed_time"
ATTR_ENABLED: Final[str] = "enabled"
ATTR_AVAILABLE: Final[str] = "available"
Expand All @@ -917,7 +944,6 @@ def create_sample_rates_config_entry(
ATTR_VOLUME_CONTROL: Final[str] = "volume_control"
ATTR_POWER_CONTROL: Final[str] = "power_control"
ATTR_PLAY_ACTION_IN_PROGRESS: Final[str] = "play_action_in_progress"
ATTR_GROUP_VOLUME_SNAPSHOT: Final[str] = "group_volume_snapshot"

# Album type detection patterns
LIVE_INDICATORS = [
Expand Down
5 changes: 5 additions & 0 deletions music_assistant/controllers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
CONF_ENTRY_LIBRARY_SYNC_RADIOS,
CONF_ENTRY_LIBRARY_SYNC_TRACKS,
CONF_ENTRY_MAX_VOLUME,
CONF_ENTRY_MIN_VOLUME,
CONF_ENTRY_OUTPUT_CHANNELS,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_OUTPUT_LIMITER,
Expand Down Expand Up @@ -1941,6 +1943,9 @@ def _create_player_control_config_entries(self, player: Player) -> list[ConfigEn
],
category="player_controls",
),
# Volume limit entries
CONF_ENTRY_MIN_VOLUME,
CONF_ENTRY_MAX_VOLUME,
# auto-play on power on control config entry
CONF_ENTRY_AUTO_PLAY,
]
Expand Down
160 changes: 124 additions & 36 deletions music_assistant/controllers/players/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from music_assistant_models.errors import (
AlreadyRegisteredError,
InsufficientPermissions,
InvalidDataError,
MusicAssistantError,
PlayerCommandFailed,
PlayerUnavailableError,
Expand Down Expand Up @@ -77,8 +78,12 @@
CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
CONF_ENTRY_MAX_VOLUME,
CONF_ENTRY_MIN_VOLUME,
CONF_ENTRY_TTS_PRE_ANNOUNCE,
CONF_GROUP_MEMBERS,
CONF_MAX_VOLUME,
CONF_MIN_VOLUME,
CONF_PLAYER_DSP,
CONF_PLAYERS,
CONF_PRE_ANNOUNCE_CHIME_URL,
Expand Down Expand Up @@ -1577,6 +1582,97 @@ def delete_player_config(self, player_id: str) -> None:
for key in (conf_key, dsp_conf_key):
self.mass.config.remove(key)

def _get_volume_limits(self, player_id: str) -> tuple[int, int]:
"""Get the configured min/max volume limits for a player."""
min_volume = int(
cast(
"int",
self.mass.config.get_raw_player_config_value(
player_id, CONF_MIN_VOLUME, CONF_ENTRY_MIN_VOLUME.default_value
),
)
)
max_volume = int(
cast(
"int",
self.mass.config.get_raw_player_config_value(
player_id, CONF_MAX_VOLUME, CONF_ENTRY_MAX_VOLUME.default_value
),
)
)
return min_volume, max_volume

def scale_volume_to_device(self, player_id: str, logical_volume: int) -> int:
"""Scale logical volume (0-100) to device volume (min_volume-max_volume)."""
min_volume, max_volume = self._get_volume_limits(player_id)
if min_volume == 0 and max_volume == 100:
return logical_volume
# Scale: logical 0 -> min_volume, logical 100 -> max_volume
return min_volume + (logical_volume * (max_volume - min_volume)) // 100

def scale_volume_from_device(self, player_id: str, device_volume: int) -> int:
"""Scale device volume (min_volume-max_volume) to logical volume (0-100)."""
min_volume, max_volume = self._get_volume_limits(player_id)
if min_volume == 0 and max_volume == 100:
return device_volume
volume_range = max_volume - min_volume
if volume_range == 0:
return 0
# Scale to 0-100 without clamping so that out-of-range device volumes
# produce distinct logical values, ensuring state change detection triggers
# volume limit enforcement
return ((device_volume - min_volume) * 100) // volume_range

def _enforce_volume_limits(self, player: Player) -> None:
"""Clamp device volume to min/max range when changed externally."""
if player.volume_level is None:
return
player_id = player.player_id
min_volume, max_volume = self._get_volume_limits(player_id)
if min_volume == 0 and max_volume == 100:
return
device_volume = player.volume_level
clamped = max(min_volume, min(max_volume, device_volume))
if clamped != device_volume:
# Device volume is outside allowed range, correct it
self.mass.create_task(player.volume_set(clamped))

def _forward_state_updates_to_related_players(
self, player: Player, changed_values: dict[str, tuple[Any, Any]]
) -> None:
"""Forward state updates to group members, parent players, and related players."""
# update/signal group player(s) child's when group updates
for child_player in self.iter_group_members(player, exclude_self=True):
self.trigger_player_update(child_player.player_id)
# update/signal group player(s) when child updates
for group_player in self._get_player_groups(player, powered_only=False):
self.trigger_player_update(group_player.player_id)
# update/signal manually synced to player when child updates
if (synced_to := player.state.synced_to) and (
synced_to_player := self.get_player(synced_to)
):
self.trigger_player_update(synced_to_player.player_id)
# update/signal active groups when a group member updates
if (active_group := player.state.active_group) and (
active_group_player := self.get_player(active_group)
):
self.trigger_player_update(active_group_player.player_id)
# If this is a protocol player, forward the state update to the parent player
if player.protocol_parent_id and (
parent_player := self.mass.players.get_player(player.protocol_parent_id)
):
self.trigger_player_update(parent_player.player_id)
# If this is a parent player with linked protocols, forward state updates
# to linked protocol players so their state reflects parent dependencies
if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
for linked in player.linked_output_protocols:
if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
self.mass.players.trigger_player_update(protocol_player.player_id)
# trigger update of all players in a provider if group related fields changed
if any(key in changed_values for key in ("group_members", "synced_to", "available")):
for prov_player in player.provider.players:
self.trigger_player_update(prov_player.player_id)

def signal_player_state_update(
self,
player: Player,
Expand Down Expand Up @@ -1658,6 +1754,10 @@ def signal_player_state_update(
if became_inactive and (player.state.active_group or player.state.synced_to):
self.mass.create_task(self._cleanup_player_memberships(player.player_id))

# enforce volume limits when volume changes externally
if "volume_level" in changed_values:
self._enforce_volume_limits(player)

# signal player update on the eventbus
if player.state.type != PlayerType.PROTOCOL:
self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
Expand Down Expand Up @@ -1685,37 +1785,7 @@ def signal_player_state_update(
if skip_forward and not force_update:
return

# update/signal group player(s) child's when group updates
for child_player in self.iter_group_members(player, exclude_self=True):
self.trigger_player_update(child_player.player_id)
# update/signal group player(s) when child updates
for group_player in self._get_player_groups(player, powered_only=False):
self.trigger_player_update(group_player.player_id)
# update/signal manually synced to player when child updates
if (synced_to := player.state.synced_to) and (
synced_to_player := self.get_player(synced_to)
):
self.trigger_player_update(synced_to_player.player_id)
# update/signal active groups when a group member updates
if (active_group := player.state.active_group) and (
active_group_player := self.get_player(active_group)
):
self.trigger_player_update(active_group_player.player_id)
# If this is a protocol player, forward the state update to the parent player
if player.protocol_parent_id and (
parent_player := self.mass.players.get_player(player.protocol_parent_id)
):
self.trigger_player_update(parent_player.player_id)
# If this is a parent player with linked protocols, forward state updates
# to linked protocol players so their state reflects parent dependencies
if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
for linked in player.linked_output_protocols:
if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
self.mass.players.trigger_player_update(protocol_player.player_id)
# trigger update of all players in a provider if group related fields changed
if any(key in changed_values for key in ("group_members", "synced_to", "available")):
for prov_player in player.provider.players:
self.trigger_player_update(prov_player.player_id)
self._forward_state_updates_to_related_players(player, changed_values)

async def register_player_control(self, player_control: PlayerControl) -> None:
"""Register a new PlayerControl on the controller."""
Expand Down Expand Up @@ -2037,6 +2107,16 @@ def _on_event(_event: MassEvent) -> None:

async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
"""Call (by config manager) when the configuration of a player changes."""
min_vol_changed = f"values/{CONF_MIN_VOLUME}" in changed_keys
max_vol_changed = f"values/{CONF_MAX_VOLUME}" in changed_keys
if min_vol_changed or max_vol_changed:
raw_min = config.get_value(CONF_MIN_VOLUME)
raw_max = config.get_value(CONF_MAX_VOLUME)
min_vol = int(cast("int", raw_min)) if raw_min is not None else 0
max_vol = int(cast("int", raw_max)) if raw_max is not None else 100
if min_vol > max_vol:
msg = "Minimum volume cannot exceed maximum volume"
raise InvalidDataError(msg)
player = self.get_player(config.player_id)
player_provider = self.mass.get_provider(config.provider)
player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
Expand Down Expand Up @@ -3064,9 +3144,16 @@ async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> Non
Handle Player volume set command.

Skips permission checks and locking (internal use only).

:param player_id: player_id of the player to handle the command.
:param volume_level: logical volume level (0..100) to set on the player.
"""
player = self.get_player(player_id, True)
assert player is not None # for type checker

# Clamp logical volume to 0-100
volume_level = max(0, min(100, volume_level))

if player.type == PlayerType.GROUP:
# redirect to special group volume control
await self.cmd_group_volume(player_id, volume_level)
Expand Down Expand Up @@ -3097,24 +3184,25 @@ async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> Non
# always reset fake mute when controlling volume
player.extra_data.pop(ATTR_FAKE_MUTE, None)

# Scale logical volume (0-100) to device volume (min_volume-max_volume)
device_volume = self.scale_volume_to_device(player_id, volume_level)

# Check if a plugin source is active with a volume callback.
# Only fire if this player is the direct owner of the plugin source,
# not when it merely inherits active_source from a parent group —
# group volume changes handle the callback once at the group level.
if plugin_source := self._get_active_plugin_source(player):
if plugin_source.on_volume and plugin_source.in_use_by == player.player_id:
await plugin_source.on_volume(volume_level)

# Handle native volume control support
if player.volume_control == PLAYER_CONTROL_NATIVE:
# player supports volume command natively: forward to player
await player.volume_set(volume_level)
await player.volume_set(device_volume)
return
# Handle fake volume control support
if player.volume_control == PLAYER_CONTROL_FAKE:
# user wants to use fake volume control - so we (optimistically) update the state
# and store the state in the cache
# Fake volume stores logical volume (no scaling needed)
player.extra_data[ATTR_FAKE_VOLUME] = volume_level
# trigger update
player.update_state()
return
# player has no volume support at all
Expand Down
18 changes: 14 additions & 4 deletions music_assistant/models/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,21 +1538,31 @@ def __final_volume_level(self) -> int | None:
"""Return the FINAL volume level based on the playercontrol which may have been set-up."""
volume_control = self.volume_control
if volume_control == PLAYER_CONTROL_FAKE:
# Fake volume is already stored as logical (0-100)
return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
if volume_control == PLAYER_CONTROL_NATIVE:
return self.volume_level
# Scale device volume back to logical (0-100)
if self.volume_level is None:
return None
return self.mass.players.scale_volume_from_device(self.player_id, self.volume_level)
if volume_control == PLAYER_CONTROL_NONE:
return None
# handle protocol player as volume control
if control := self.mass.players.get_player(volume_control):
if control.volume_level is not None:
return control.volume_level
return self.mass.players.scale_volume_from_device(
self.player_id, control.volume_level
)
# handle player control for volume if set
if player_control := self.mass.players.get_player_control(volume_control):
if player_control.volume_level is not None:
return player_control.volume_level
return self.mass.players.scale_volume_from_device(
self.player_id, player_control.volume_level
)
# control not (yet) available or has no volume, fall back to native
return self.volume_level
if self.volume_level is None:
return None
return self.mass.players.scale_volume_from_device(self.player_id, self.volume_level)

@cached_property
@final
Expand Down
13 changes: 12 additions & 1 deletion tests/core/test_player_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ def mock_mass() -> MagicMock:
mass.loop = None
mass.config = MagicMock()
mass.config.get = MagicMock(return_value=[])
mass.config.get_raw_player_config_value = MagicMock(return_value="auto")

def _get_raw_player_config_value(
_player_id: str, key: str, default: str | int | None = None
) -> str | int | None:
"""Return appropriate defaults for player config values."""
if key == "min_volume":
return 0
if key == "max_volume":
return 100
return default if default is not None else "auto"

mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
# Return "GLOBAL" for log level config (standard default)
mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
mass.config.set = MagicMock()
Expand Down
13 changes: 12 additions & 1 deletion tests/core/test_player_grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,18 @@ def mock_mass() -> MagicMock:
mass.closing = False
mass.config = MagicMock()
mass.config.get = MagicMock(return_value=[])
mass.config.get_raw_player_config_value = MagicMock(return_value="auto")

def _get_raw_player_config_value(
_player_id: str, key: str, default: str | int | None = None
) -> str | int | None:
"""Return appropriate defaults for player config values."""
if key == "min_volume":
return 0
if key == "max_volume":
return 100
return default if default is not None else "auto"

mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
# Return "GLOBAL" for log level config (standard default)
mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
mass.config.set = MagicMock()
Expand Down
Loading
Loading