diff --git a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt index beceaba..4d21723 100644 --- a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt @@ -19,6 +19,9 @@ class MainActivity: AudioServiceActivity() { private val CHANNEL = "com.collotsspot.ensemble/volume_buttons" private var methodChannel: MethodChannel? = null private var isListening = false + // Set to true when MA is actively playing (sent from Flutter). + // Used by the volume observer to suppress volume mirroring when MA is not streaming. + private var isMAPlaying = false // Volume observer for lockscreen volume changes // Watches system STREAM_MUSIC volume and mirrors changes to the MA player @@ -29,6 +32,10 @@ class MainActivity: AudioServiceActivity() { private var lastKnownVolume: Int = -1 // Guard flag to ignore volume changes triggered by our own setStreamVolume calls private var ignoringVolumeChange = false + // Tracks the estimated MA volume locally so the system slider can shadow + // the MA position without a Flutter round-trip. Updated in lockstep with + // every observer event and reset by syncSystemVolume / startVolumeObserver. + private var estimatedMAVolume = 50 override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -68,6 +75,14 @@ class MainActivity: AudioServiceActivity() { syncSystemVolume(volume) result.success(null) } + "setMAPlayingState" -> { + // Flutter notifies us whether MA is actively playing. + // The volume observer uses this to suppress mirroring when + // MA is paused, idle, or stopped. + isMAPlaying = call.argument("isPlaying") ?: false + Log.d(TAG, "MA playing state updated: isMAPlaying=$isMAPlaying") + result.success(null) + } else -> { Log.d(TAG, "Unknown method: ${call.method}") result.notImplemented() @@ -86,6 +101,7 @@ class MainActivity: AudioServiceActivity() { // Optionally set system volume to match the MA player's current volume if (initialVolume != null) { + estimatedMAVolume = initialVolume syncSystemVolume(initialVolume) } @@ -120,34 +136,71 @@ class MainActivity: AudioServiceActivity() { val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume) + estimatedMAVolume = maVolume + ignoringVolumeChange = true am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0) lastKnownVolume = systemVolume - ignoringVolumeChange = false + // Clear the guard after a short delay so the ContentObserver's echo + // (dispatched asynchronously via Handler) is still suppressed. + // 100ms is plenty — the echo arrives within one main-thread frame (~16ms). + Handler(Looper.getMainLooper()).postDelayed({ ignoringVolumeChange = false }, 100) Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume") } /// ContentObserver that watches for system volume changes. /// When volume changes (e.g., from lockscreen hardware buttons), - /// maps the change to a 0-100 value and sends to Flutter. + /// maps the change to a 0-100 value and sends to Flutter along with + /// the button direction (+1 up, -1 down). inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { super.onChange(selfChange) - if (ignoringVolumeChange) return - val am = audioManager ?: return val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC) - if (currentVolume != lastKnownVolume) { + // Always track the real system volume BEFORE checking guards. + // This ensures direction detection is accurate even after periods + // where events were suppressed (e.g., MA was paused, or during + // the ignoringVolumeChange window after syncSystemVolume). + val previousVolume = lastKnownVolume + lastKnownVolume = currentVolume + + // Guards: suppress the event but volume tracking above stays accurate. + if (ignoringVolumeChange) return + if (!isMAPlaying) return + if (am.isMusicActive) return + if (am.mode != AudioManager.MODE_NORMAL) return + + if (currentVolume != previousVolume) { val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0 + val prevMaVolume = if (maxVolume > 0) (previousVolume * 100 / maxVolume) else 0 + val direction = if (currentVolume > previousVolume) 1 else -1 + // Per-step delta mapped to 0-100 scale so Flutter can match + // the system volume rate of change exactly. + val delta = Math.abs(maVolume - prevMaVolume) + + Log.d(TAG, "Volume observer: system $previousVolume -> $currentVolume (MA: $maVolume%, delta: $delta, dir: $direction)") + + // Update local MA estimate so the system slider can shadow it. + estimatedMAVolume = (estimatedMAVolume + direction * delta).coerceIn(0, 100) - Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)") - lastKnownVolume = currentVolume + // Send volume + direction + delta to Flutter + methodChannel?.invokeMethod("absoluteVolumeChange", mapOf("volume" to maVolume, "direction" to direction, "delta" to delta)) - // Send the absolute volume (0-100) to Flutter - methodChannel?.invokeMethod("absoluteVolumeChange", maVolume) + // Reset system volume to the position matching our estimated MA + // volume, clamped to [1, max-1] so the ContentObserver always + // has room to detect the next press in either direction. + // This makes the system slider visually shadow the MA volume + // instead of snapping to midpoint. + val targetSystemVol = (estimatedMAVolume * maxVolume / 100).coerceIn(1, maxVolume - 1) + if (currentVolume != targetSystemVol) { + ignoringVolumeChange = true + am.setStreamVolume(AudioManager.STREAM_MUSIC, targetSystemVol, 0) + lastKnownVolume = targetSystemVol + Handler(Looper.getMainLooper()).postDelayed({ ignoringVolumeChange = false }, 100) + } } } } @@ -197,17 +250,17 @@ class MainActivity: AudioServiceActivity() { } } - // Pause volume interception when app goes to background so hardware buttons - // only control the system volume (e.g., YouTube, phone ringer) as expected. + // Pause foreground key interception (dispatchKeyEvent) when the app goes to background + // so hardware volume buttons don't intercept YouTube, ringer, etc. + // The volume observer is intentionally kept alive across pause/resume — it is the + // mechanism that routes lockscreen hardware-button presses to the MA player, and + // it already guards against unwanted mirroring via isMAPlaying / isMusicActive / mode. private var wasListeningBeforePause = false - private var wasObservingBeforePause = false override fun onPause() { wasListeningBeforePause = isListening - wasObservingBeforePause = isObservingVolume isListening = false - stopVolumeObserver() - Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)") + Log.d(TAG, "onPause: suspended key interception (wasListening=$wasListeningBeforePause), volume observer remains active") super.onPause() } @@ -216,10 +269,7 @@ class MainActivity: AudioServiceActivity() { if (wasListeningBeforePause) { isListening = true } - if (wasObservingBeforePause) { - startVolumeObserver(null) - } - Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)") + Log.d(TAG, "onResume: restored key interception (listening=$isListening)") } override fun onDestroy() { diff --git a/docs/PR_VOLUME_MIRRORING_FIX.md b/docs/PR_VOLUME_MIRRORING_FIX.md new file mode 100644 index 0000000..bbb5714 --- /dev/null +++ b/docs/PR_VOLUME_MIRRORING_FIX.md @@ -0,0 +1,48 @@ +# PR: Fix lockscreen volume mirroring for remote/group players + +## Summary + +Fixes the volume jump bug in lockscreen hardware volume control when using remote/group players. The system volume slider now shadows the MA player volume without dangerous jumps or desync. + +**References:** +- Original feature: `ea7144d` (Sync group volume management, hardware volume buttons) +- Original feature introduced unintended behaviour and was pathed: `688b635` (Fix hardware volume in background) + +## Problem + +When the app was backgrounded and the system volume changed (e.g., for YouTube), returning to use hardware buttons for MA volume control caused potentially very large volume jumps — the system volume (e.g., 100%) was mirrored directly to MA, potentially blowing out speakers and killing the cat. The patch in `688b635` stopped the volume observer on pause, but this disabled lockscreen volume control entirely. + +## Approach + +Instead of mirroring system volume values to MA, hardware buttons are now treated as **directional step inputs**. The system volume slider becomes a "button press detector" that silently resets after each event, while MA volume changes independently in safe increments. + +## Changes + +### `MainActivity.kt` (Kotlin — native layer) +- **`isMAPlaying` guard**: Observer suppresses mirroring when MA is not actively streaming (`isMusicActive`, `MODE_NORMAL` checks), replacing the stop/start of `688b635` +- **Observer stays alive across pause/resume**: `onPause` only disables `dispatchKeyEvent` (foreground key interception); the observer remains active for lockscreen button detection +- **Direction + delta signal**: Each observer event sends `direction` (+1/-1) and `delta` (per-step size mapped to 0-100) to Flutter +- **Center-reset with MA shadow**: After each event, system volume resets to the position matching `estimatedMAVolume`, clamped to `[1, max-1]` so buttons always have room in both directions. The slider visually tracks MA instead of snapping to midpoint +- **Reduced ignore window**: `ignoringVolumeChange` guard reduced from 1000ms to 100ms, preventing button presses from being swallowed + +### `lib/main.dart` (Flutter — app layer) +- **Always-step mode**: `_setAbsoluteVolume` never mirrors the system value directly — steps MA by the native `delta` in the button `direction`, making volume jumps structurally impossible +- **`_lastSteppedVolume` tracking**: Accumulates volume changes synchronously so rapid presses (hold button) use the correct base instead of stale `player.volume` +- **Play-resume re-sync**: When `isPlaying` transitions true, syncs system volume to MA, closing any drift from the suppression window +- **`setMAPlayingState`**: Sends playback state to native layer so the observer can self-guard + +### `lib/services/hardware_volume_service.dart` +- Updated `onAbsoluteVolumeChange` stream type to include `direction` and `delta` fields +- Added `setMAPlayingState()` method to notify native layer of playback state + +## How it works + +1. User presses hardware volume button on lockscreen +2. System volume changes by 1 step → ContentObserver fires +3. Kotlin detects direction and delta, sends to Flutter, resets system to MA-equivalent position (clamped [1, max-1]) +4. Flutter steps MA by delta in that direction, tracks accumulated value +5. System slider visually shadows MA position; buttons always work in both directions + +**Desync scenario** (MA=20%, system was changed to 100% while paused): +- Play resumes → system syncs back to 20% +- If sync didn't happen: button press steps MA by ~7%, never jumps to 100% diff --git a/lib/main.dart b/lib/main.dart index 6137b14..8c7ee31 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -134,6 +134,16 @@ class _MusicAssistantAppState extends State with WidgetsBindi StreamSubscription? _absoluteVolumeSub; String? _lastSelectedPlayerId; String? _builtinPlayerId; + // Track last known playing state to avoid redundant setMAPlayingState calls + bool _lastKnownPlayingState = false; + + // Tracks accumulated MA volume during rapid lockscreen presses. + // player.volume lags behind async setVolume calls, so without this + // rapid presses all step from the same stale base and most are no-ops. + int? _lastSteppedVolume; + // Clears _lastSteppedVolume after inactivity so it falls back to the + // real player.volume (which may have changed via in-app slider or web UI). + Timer? _steppedVolumeExpiry; // Volume step size (percentage points per button press) static const int _volumeStep = 5; @@ -166,21 +176,49 @@ class _MusicAssistantAppState extends State with WidgetsBindi // Listen for absolute volume changes from lockscreen/background // (ContentObserver on system STREAM_MUSIC volume) - _absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) { - _setAbsoluteVolume(volume); + _absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((event) { + _setAbsoluteVolume(event.volume, event.direction, event.delta); }); } catch (e, stack) { _logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack); } } - /// Called when provider state changes - check if selected player changed + /// Called when provider state changes - check if selected player changed or playback state changed void _onProviderChanged() { final currentPlayerId = _musicProvider.selectedPlayer?.playerId; if (currentPlayerId != _lastSelectedPlayerId) { _lastSelectedPlayerId = currentPlayerId; _updateVolumeInterception(); } + + // Keep the native volume observer in sync with the current playback state. + // This allows the observer to suppress mirroring when MA is not streaming. + final isPlaying = _musicProvider.selectedPlayer?.isPlaying ?? false; + if (isPlaying != _lastKnownPlayingState) { + _lastKnownPlayingState = isPlaying; + _hardwareVolumeService.setMAPlayingState(isPlaying); + + // Re-sync system volume when playback starts or resumes. + // While MA was paused the observer suppressed events, so the system + // volume may have drifted (e.g. user adjusted it for YouTube). + // Snapping the system volume back to MA here means the next hardware + // button press starts from a known-good sync point. + if (isPlaying) { + _lastSteppedVolume = null; + _steppedVolumeExpiry?.cancel(); + final player = _musicProvider.selectedPlayer; + if (player != null && + !(_builtinPlayerId != null && player.playerId == _builtinPlayerId)) { + final vol = _musicProvider.groupVolumeManager.isGroupPlayer(player) + ? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel( + player, _musicProvider.availablePlayersUnfiltered) ?? + player.volume) + : player.volume; + _hardwareVolumeService.syncSystemVolume(vol); + } + } + } } /// Enable/disable volume button interception based on selected player. @@ -228,17 +266,49 @@ class _MusicAssistantAppState extends State with WidgetsBindi } /// Handle absolute volume change from lockscreen/background. - /// Called when the ContentObserver detects a system volume change - /// while a remote/group player is active. - Future _setAbsoluteVolume(int volume) async { + /// Always steps MA by the per-step delta in the button direction — never + /// mirrors the system volume value directly, so volume jumps are impossible. + /// The delta matches the system volume step size (mapped to 0-100) so MA + /// changes at the same rate as the system HUD when the button is held. + /// The native layer resets system volume to midpoint after each event so + /// the ContentObserver always has room in both directions. + Future _setAbsoluteVolume(int systemVolume, int direction, int delta) async { final player = _musicProvider.selectedPlayer; if (player == null) return; - // Don't route to builtin player - that's handled by the system directly + // Don't route to builtin player — handled by the system directly. if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return; + if (direction == 0) return; // No actual change + + // Use the native per-step delta so MA tracks the system rate of change. + // Fall back to _volumeStep if delta is missing or zero. + final step = delta > 0 ? delta : _volumeStep; + + // Use tracked value for rapid presses (player.volume lags behind async + // setVolume calls — without this, repeated presses step from the same + // stale base and most are silently dropped). + final currentMA = _lastSteppedVolume ?? + (_musicProvider.groupVolumeManager.isGroupPlayer(player) + ? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel( + player, _musicProvider.availablePlayersUnfiltered) ?? + player.volume) + : player.volume); + + final newMA = (currentMA + direction * step).clamp(0, 100); + if (newMA == currentMA) return; // At boundary + + // Track synchronously so the next rapid press uses this as its base. + _lastSteppedVolume = newMA; + + // Clear tracked value after 1.5s of inactivity so we fall back to the + // real player.volume (which may have been changed via slider or web UI). + _steppedVolumeExpiry?.cancel(); + _steppedVolumeExpiry = Timer(const Duration(milliseconds: 1500), () { + _lastSteppedVolume = null; + }); try { - await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100)); + await _musicProvider.setVolume(player.playerId, newMA); } catch (e) { _logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e); } @@ -250,6 +320,7 @@ class _MusicAssistantAppState extends State with WidgetsBindi _volumeUpSub?.cancel(); _volumeDownSub?.cancel(); _absoluteVolumeSub?.cancel(); + _steppedVolumeExpiry?.cancel(); _hardwareVolumeService.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/services/hardware_volume_service.dart b/lib/services/hardware_volume_service.dart index ae486e1..15818f5 100644 --- a/lib/services/hardware_volume_service.dart +++ b/lib/services/hardware_volume_service.dart @@ -22,15 +22,16 @@ class HardwareVolumeService { final _volumeUpController = StreamController.broadcast(); final _volumeDownController = StreamController.broadcast(); - final _absoluteVolumeController = StreamController.broadcast(); + final _absoluteVolumeController = StreamController<({int volume, int direction, int delta})>.broadcast(); Stream get onVolumeUp => _volumeUpController.stream; Stream 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 get onAbsoluteVolumeChange => _absoluteVolumeController.stream; + /// Stream of volume changes from lockscreen hardware buttons. + /// Each event contains `volume` (0-100 mapped system volume), + /// `direction` (+1 for vol-up, -1 for vol-down), and `delta` (per-step + /// size mapped to 0-100 scale) from the native layer. + Stream<({int volume, int direction, int delta})> get onAbsoluteVolumeChange => _absoluteVolumeController.stream; bool _isListening = false; bool get isListening => _isListening; @@ -55,8 +56,13 @@ class HardwareVolumeService { break; case 'absoluteVolumeChange': // Volume changed from lockscreen/background via ContentObserver - final volume = call.arguments as int? ?? 0; - _absoluteVolumeController.add(volume); + // Native sends a map with 'volume' (0-100), 'direction' (+1/-1), + // and 'delta' (per-step size mapped to 0-100 scale). + final args = call.arguments as Map; + final volume = (args['volume'] as int?) ?? 0; + final direction = (args['direction'] as int?) ?? 0; + final delta = (args['delta'] as int?) ?? 0; + _absoluteVolumeController.add((volume: volume, direction: direction, delta: delta)); break; } }); @@ -120,6 +126,18 @@ class HardwareVolumeService { } } + /// Notify the native layer whether MA is actively playing. + /// The volume observer uses this flag to suppress mirroring when MA is + /// paused, idle, disconnected, or not the active audio source. + Future setMAPlayingState(bool isPlaying) async { + try { + await _channel.invokeMethod('setMAPlayingState', {'isPlaying': isPlaying}); + _logger.info('MA playing state set to $isPlaying', context: 'VolumeService'); + } catch (e) { + _logger.error('Failed to set MA playing state', 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 syncSystemVolume(int maVolume) async { diff --git a/logs2.txt b/logs2.txt new file mode 100644 index 0000000..2b03ab6 --- /dev/null +++ b/logs2.txt @@ -0,0 +1,6 @@ +No supported devices found with name or id matching 'Medium_Phone_API_36.1'. + +The following devices were found: +Windows (desktop) ΓÇó windows ΓÇó windows-x64 ΓÇó Microsoft Windows [Version 10.0.26200.7840] +Chrome (web) ΓÇó chrome ΓÇó web-javascript ΓÇó Google Chrome 145.0.7632.77 +Edge (web) ΓÇó edge ΓÇó web-javascript ΓÇó Microsoft Edge 145.0.3800.70 diff --git a/pubspec.lock b/pubspec.lock index 1027847..330b269 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -625,18 +625,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_design_icons_flutter: dependency: "direct main" description: @@ -1086,10 +1086,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timing: dependency: transitive description: