This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
All changes have been tested and verified with `dart analyze` showing no new issues.
---
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.
**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
---
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
---
During sync group testing, fixed these UI bugs:
**File:** `lib/main.dart`
**Issue:** Console warnings during init.
**Root Cause:** Init code outside `runZonedGuarded()`.
**Fix:** Move init inside `runZonedGuarded()`.
---
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
**Issue:** Crash on nav interaction.
**Root Cause:** Nav in Stack without Overlay.
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
---
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
**Issue:** Warnings causing corruption.
**Root Cause:** Service calls in initState trigger setState during build.
**Fix:** Defer with addPostFrameCallback, add mounted guard.
---
**File:** `lib/screens/new_library_screen.dart`
**Issue:** Crash on library tabs.
**Root Cause:** context.select in sliver context.
**Fix:** Use Consumer wrappers for tabs.
---
**File:** `lib/widgets/player/queue_panel.dart`
**Issue:** Crash on long-press buttons.
**Root Cause:** Tooltips require Overlay, but in Stack.
**Fix:** Remove tooltip properties.
---
**File:** `lib/screens/settings_screen.dart`
**Issue:** Back button crashes to black screen.
**Root Cause:** Navigator.pop pops home screen.
**Fix:** Use showPlayer + setSelectedIndex(0).
---
**Issue:** Intermittent empty homepage on cold start.
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
---
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
1. **Detects group players:**
```dart
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
```
2. **Computes effective volume:**
- Recursively resolves nested groups (max depth 3)
- Averages volumes across all leaf members
- Skips self-references in `groupMembers`
- Returns `null` if no members have valid volumes
3. **Caches pending volumes:**
- When user sets volume: stored with timestamp
- Returns cached value if within 30s timeout
- Clears cache when member average converges (±3 tolerance)
- Falls back to computed average after timeout
4. **Integrates with provider:**
- Called from `_applyVolumeProtection()` in provider
- Receives fresh `allPlayers` list for accurate member volumes
- Injects effective volume into `_selectedPlayer` via `copyWith()`
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
- Leader has `groupMembers` with 2+ entries
- Leader has its own non-null `volumeLevel`
- NOT detected as a group player (GroupVolumeManager handles those)
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
```dart
if (leaderCurrentVol > 0) {
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
} else {
memberNewVol = newVol // Can't ratio from zero
}
```
3. **Execution:**
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
- Update `_availablePlayers[idx]` optimistically
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
- Continue to send leader's own `volume_set` command
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
```
WebSocket connects → MAConnectionState.connected
↓
Auth required?
↙ ↘
YES NO
↓ ↓
Don't notify Set state + notify
Try auth ↓
↓ Initialize
Success?
↙ ↘
YES NO
↓ ↓
auth error state
event + notify
↓
Set state
+ notify
↓
Initialize
```
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
lib/main.dart | 207 ±
lib/providers/music_assistant_provider.dart | 165 ±
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
lib/screens/settings_screen.dart | 9 ± (back button fix)
lib/services/group_volume_manager.dart | 162 + (NEW)
lib/services/hardware_volume_service.dart | 65 + (NEW)
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
**Total:** ~650 added, ~300 removed/refactored.
176 lines
6.9 KiB
Dart
176 lines
6.9 KiB
Dart
import '../models/player.dart';
|
|
import 'debug_logger.dart';
|
|
|
|
/// Manages volume state for group players where the MA API returns null volume.
|
|
///
|
|
/// Both pre-configured speaker groups (provider == 'player_group') and dynamic
|
|
/// sync groups (ID typically starts with 'syncgroup_') don't have their own
|
|
/// volume_level in the MA API. This manager computes an effective volume from
|
|
/// group members and caches recently-set volumes to prevent slider snap-back.
|
|
class GroupVolumeManager {
|
|
final _logger = DebugLogger();
|
|
|
|
// Cache of last-set volume per group player ID.
|
|
// Prevents slider snap-back between setVolume() and next player_updated event.
|
|
final Map<String, int> _pendingVolumes = {};
|
|
|
|
// Timestamps for pending volumes - used to expire stale entries
|
|
final Map<String, DateTime> _pendingTimestamps = {};
|
|
|
|
// How long to trust a pending volume before falling back to computed average.
|
|
// For group players the API never returns a volume, so we keep the pending
|
|
// value until the computed member average converges (within tolerance) or
|
|
// until a generous timeout to avoid leaking state forever.
|
|
static const Duration _pendingTimeout = Duration(seconds: 30);
|
|
|
|
// If the computed member average is within this many points of the pending
|
|
// value, we consider the server to have confirmed it and clear the pending.
|
|
static const int _confirmationTolerance = 3;
|
|
|
|
/// Check if a player is a group player that needs computed volume.
|
|
///
|
|
/// Detects both:
|
|
/// - Pre-configured MA speaker groups (provider == 'player_group')
|
|
/// - Dynamic sync groups (have group members but API returns null volume)
|
|
bool isGroupPlayer(Player player) {
|
|
// Pre-configured MA speaker groups
|
|
if (player.provider == 'player_group') return true;
|
|
// Dynamic sync groups: have multiple group members but MA returns null volume
|
|
if (player.groupMembers != null &&
|
|
player.groupMembers!.length > 1 &&
|
|
player.volumeLevel == null) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Get the effective volume for a player.
|
|
///
|
|
/// For non-group players: returns player.volumeLevel (unchanged behavior).
|
|
/// For group players: returns cached pending volume if recent, otherwise
|
|
/// computes average from group members.
|
|
///
|
|
/// [player] - The player to get volume for.
|
|
/// [allPlayers] - All available players (needed to look up group members).
|
|
///
|
|
/// Returns volume as int 0-100, or null if no volume can be determined.
|
|
int? getEffectiveVolumeLevel(Player player, List<Player> allPlayers) {
|
|
if (!isGroupPlayer(player)) {
|
|
return player.volumeLevel;
|
|
}
|
|
|
|
// Check for a pending (just-set) volume
|
|
final pending = _pendingVolumes[player.playerId];
|
|
if (pending != null) {
|
|
final timestamp = _pendingTimestamps[player.playerId];
|
|
if (timestamp != null &&
|
|
DateTime.now().difference(timestamp) < _pendingTimeout) {
|
|
// Compute member average to see if server has converged
|
|
final memberAvg = _computeGroupVolume(player, allPlayers);
|
|
if (memberAvg != null && (memberAvg - pending).abs() <= _confirmationTolerance) {
|
|
// Server confirmed — member average matches what we set
|
|
_pendingVolumes.remove(player.playerId);
|
|
_pendingTimestamps.remove(player.playerId);
|
|
_logger.debug(
|
|
'GroupVolume: Convergence confirmed for ${player.name}: '
|
|
'pending=$pending, memberAvg=$memberAvg',
|
|
context: 'GroupVolumeManager');
|
|
return memberAvg;
|
|
}
|
|
// Not yet confirmed — keep showing the pending value
|
|
_logger.debug(
|
|
'GroupVolume: Pending protection active for ${player.name}: '
|
|
'pending=$pending, memberAvg=$memberAvg, '
|
|
'elapsed=${DateTime.now().difference(timestamp).inSeconds}s',
|
|
context: 'GroupVolumeManager');
|
|
return pending;
|
|
}
|
|
// Expired after generous timeout — remove it
|
|
_logger.debug(
|
|
'GroupVolume: Pending expired for ${player.name} after ${_pendingTimeout.inSeconds}s, '
|
|
'falling back to computed average',
|
|
context: 'GroupVolumeManager');
|
|
_pendingVolumes.remove(player.playerId);
|
|
_pendingTimestamps.remove(player.playerId);
|
|
}
|
|
|
|
// Compute average from group members
|
|
return _computeGroupVolume(player, allPlayers);
|
|
}
|
|
|
|
/// Compute the average volume across all group members.
|
|
/// Recursively resolves nested group members (MA sometimes makes the first
|
|
/// member of a group also a group player, which returns null for volumeLevel).
|
|
/// Returns null if no members have a valid volume.
|
|
int? _computeGroupVolume(Player groupPlayer, List<Player> allPlayers,
|
|
{int depth = 0}) {
|
|
// Prevent infinite recursion from circular group references
|
|
if (depth > 3) return null;
|
|
|
|
final memberIds = groupPlayer.groupMembers;
|
|
if (memberIds == null || memberIds.isEmpty) {
|
|
_logger.debug('GroupVolume: No group members for ${groupPlayer.name}',
|
|
context: 'GroupVolumeManager');
|
|
return null;
|
|
}
|
|
|
|
int totalVolume = 0;
|
|
int count = 0;
|
|
|
|
for (final memberId in memberIds) {
|
|
// Skip self-references (groupMembers can include the group's own ID)
|
|
if (memberId == groupPlayer.playerId) continue;
|
|
|
|
final member =
|
|
allPlayers.where((p) => p.playerId == memberId).firstOrNull;
|
|
if (member == null) continue;
|
|
|
|
if (member.volumeLevel != null) {
|
|
totalVolume += member.volumeLevel!;
|
|
count++;
|
|
} else if (isGroupPlayer(member)) {
|
|
// Nested group player — MA returns null volumeLevel for these.
|
|
// Recursively compute from the nested group's own members.
|
|
final subVolume =
|
|
_computeGroupVolume(member, allPlayers, depth: depth + 1);
|
|
if (subVolume != null) {
|
|
totalVolume += subVolume;
|
|
count++;
|
|
_logger.debug(
|
|
'GroupVolume: Resolved nested group ${member.name} → volume $subVolume',
|
|
context: 'GroupVolumeManager');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (count == 0) {
|
|
_logger.debug(
|
|
'GroupVolume: No members with volume for ${groupPlayer.name} '
|
|
'(${memberIds.length} members checked, depth=$depth)',
|
|
context: 'GroupVolumeManager');
|
|
return null;
|
|
}
|
|
|
|
final avg = (totalVolume / count).round();
|
|
_logger.debug(
|
|
'GroupVolume: ${groupPlayer.name} average=$avg from $count members '
|
|
'(total=$totalVolume, depth=$depth)',
|
|
context: 'GroupVolumeManager');
|
|
return avg;
|
|
}
|
|
|
|
/// Record that a volume was just set on a group player.
|
|
/// This prevents the slider from snapping back to 0 before the
|
|
/// MA server processes the volume change on all members.
|
|
void onVolumeSet(String playerId, int volumeLevel) {
|
|
_pendingVolumes[playerId] = volumeLevel;
|
|
_pendingTimestamps[playerId] = DateTime.now();
|
|
}
|
|
|
|
/// Clear all cached state (e.g., on disconnect)
|
|
void clear() {
|
|
_pendingVolumes.clear();
|
|
_pendingTimestamps.clear();
|
|
}
|
|
}
|