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.
156 lines
5.6 KiB
Dart
156 lines
5.6 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
import 'debug_logger.dart';
|
|
|
|
/// Service to intercept hardware volume button presses
|
|
/// and route them to the selected Music Assistant player.
|
|
///
|
|
/// For MA players: intercept buttons and send volume commands via API
|
|
/// For builtin player: let native volume work (don't intercept)
|
|
///
|
|
/// Also provides a volume observer for lockscreen volume changes.
|
|
/// When the app is in the background/lockscreen, dispatchKeyEvent doesn't fire,
|
|
/// so the observer watches system STREAM_MUSIC volume changes and sends
|
|
/// absolute volume values to Flutter for routing to the MA player.
|
|
class HardwareVolumeService {
|
|
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
|
|
factory HardwareVolumeService() => _instance;
|
|
HardwareVolumeService._internal();
|
|
|
|
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
|
|
final _logger = DebugLogger();
|
|
|
|
final _volumeUpController = StreamController<void>.broadcast();
|
|
final _volumeDownController = StreamController<void>.broadcast();
|
|
final _absoluteVolumeController = StreamController<int>.broadcast();
|
|
|
|
Stream<void> get onVolumeUp => _volumeUpController.stream;
|
|
Stream<void> get onVolumeDown => _volumeDownController.stream;
|
|
|
|
/// Stream of absolute volume values (0-100) from lockscreen hardware buttons.
|
|
/// Fires when the system STREAM_MUSIC volume changes while the volume
|
|
/// observer is active (remote/group player selected, app in background).
|
|
Stream<int> get onAbsoluteVolumeChange => _absoluteVolumeController.stream;
|
|
|
|
bool _isListening = false;
|
|
bool get isListening => _isListening;
|
|
|
|
bool _isIntercepting = false;
|
|
bool get isIntercepting => _isIntercepting;
|
|
|
|
bool _isObservingVolume = false;
|
|
bool get isObservingVolume => _isObservingVolume;
|
|
|
|
/// Initialize the service and start listening for volume button events
|
|
Future<void> init() async {
|
|
if (_isListening) return;
|
|
|
|
_channel.setMethodCallHandler((call) async {
|
|
switch (call.method) {
|
|
case 'volumeUp':
|
|
_volumeUpController.add(null);
|
|
break;
|
|
case 'volumeDown':
|
|
_volumeDownController.add(null);
|
|
break;
|
|
case 'absoluteVolumeChange':
|
|
// Volume changed from lockscreen/background via ContentObserver
|
|
final volume = call.arguments as int? ?? 0;
|
|
_absoluteVolumeController.add(volume);
|
|
break;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await _channel.invokeMethod('startListening');
|
|
_isListening = true;
|
|
_isIntercepting = true;
|
|
} catch (e) {
|
|
_logger.error('Failed to start volume button listening', context: 'VolumeService', error: e);
|
|
}
|
|
}
|
|
|
|
/// Enable or disable volume button interception.
|
|
/// When disabled, hardware volume buttons control device volume normally.
|
|
/// When enabled, volume button events are sent to Flutter for MA player control.
|
|
Future<void> setIntercepting(bool intercept) async {
|
|
if (!_isListening || _isIntercepting == intercept) return;
|
|
|
|
try {
|
|
if (intercept) {
|
|
await _channel.invokeMethod('startListening');
|
|
_isIntercepting = true;
|
|
} else {
|
|
await _channel.invokeMethod('stopListening');
|
|
_isIntercepting = false;
|
|
}
|
|
} catch (e) {
|
|
_logger.error('Failed to set volume interception', context: 'VolumeService', error: e);
|
|
}
|
|
}
|
|
|
|
/// Start the volume observer for lockscreen/background volume changes.
|
|
/// When active, system STREAM_MUSIC volume changes are detected and sent
|
|
/// to Flutter via [onAbsoluteVolumeChange]. Optionally sets the system
|
|
/// volume to [initialVolume] (0-100) to match the MA player's current volume.
|
|
Future<void> startVolumeObserver({int? initialVolume}) async {
|
|
if (_isObservingVolume) return;
|
|
|
|
try {
|
|
await _channel.invokeMethod('startVolumeObserver', {
|
|
if (initialVolume != null) 'initialVolume': initialVolume,
|
|
});
|
|
_isObservingVolume = true;
|
|
_logger.info('Volume observer started', context: 'VolumeService');
|
|
} catch (e) {
|
|
_logger.error('Failed to start volume observer', context: 'VolumeService', error: e);
|
|
}
|
|
}
|
|
|
|
/// Stop the volume observer.
|
|
Future<void> stopVolumeObserver() async {
|
|
if (!_isObservingVolume) return;
|
|
|
|
try {
|
|
await _channel.invokeMethod('stopVolumeObserver');
|
|
_isObservingVolume = false;
|
|
_logger.info('Volume observer stopped', context: 'VolumeService');
|
|
} catch (e) {
|
|
_logger.error('Failed to stop volume observer', context: 'VolumeService', error: e);
|
|
}
|
|
}
|
|
|
|
/// Sync the system volume to match the MA player volume (0-100).
|
|
/// Used to keep the system volume HUD in sync with the MA player.
|
|
Future<void> syncSystemVolume(int maVolume) async {
|
|
try {
|
|
await _channel.invokeMethod('syncSystemVolume', {'volume': maVolume});
|
|
} catch (e) {
|
|
_logger.error('Failed to sync system volume', context: 'VolumeService', error: e);
|
|
}
|
|
}
|
|
|
|
/// Stop listening for volume button events
|
|
Future<void> dispose() async {
|
|
if (_isObservingVolume) {
|
|
await stopVolumeObserver();
|
|
}
|
|
|
|
if (!_isListening) return;
|
|
|
|
try {
|
|
await _channel.invokeMethod('stopListening');
|
|
_isListening = false;
|
|
_isIntercepting = false;
|
|
_logger.info('Hardware volume button listening stopped', context: 'VolumeService');
|
|
} catch (e) {
|
|
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
|
}
|
|
|
|
// Close StreamControllers to prevent memory leaks
|
|
await _volumeUpController.close();
|
|
await _volumeDownController.close();
|
|
await _absoluteVolumeController.close();
|
|
}
|
|
}
|