From 55c9786fa8d51147cc1921c58b2f81e9c398d19e Mon Sep 17 00:00:00 2001 From: pcsokonay Date: Thu, 26 Feb 2026 21:51:31 +1100 Subject: [PATCH 1/2] lockscreen volume control fixed and no longer changes ma volume unless ensemble is actively streaming. --- .../com/collotsspot/ensemble/MainActivity.kt | 77 ++++++++++++---- lib/main.dart | 87 +++++++++++++++++-- lib/services/hardware_volume_service.dart | 32 +++++-- logs2.txt | 6 ++ pubspec.lock | 16 ++-- 5 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 logs2.txt 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..28b9c0f 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 @@ -68,6 +71,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() @@ -123,31 +134,62 @@ class MainActivity: AudioServiceActivity() { 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)") - 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 midpoint of its range so the + // ContentObserver always has room to detect the next press + // in either direction. Without this, holding a button drives + // the system to 0 or max and further presses are invisible. + val midpoint = maxVolume / 2 + if (currentVolume != midpoint) { + ignoringVolumeChange = true + am.setStreamVolume(AudioManager.STREAM_MUSIC, midpoint, 0) + lastKnownVolume = midpoint + Handler(Looper.getMainLooper()).postDelayed({ ignoringVolumeChange = false }, 100) + } } } } @@ -197,17 +239,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 +258,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/lib/main.dart b/lib/main.dart index 44898fc..9ae9073 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; @@ -165,21 +175,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. @@ -227,17 +265,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); } @@ -249,6 +319,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 8bb4ad8..39d8a2c 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: From ef01a1f6371ca764de2834a79cbdce19f62c647a Mon Sep 17 00:00:00 2001 From: pcsokonay Date: Fri, 27 Feb 2026 10:47:44 +1100 Subject: [PATCH 2/2] Ready# PR: Fix lockscreen volume mirroring for remote/group players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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. 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. - **`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 - **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 - Updated `onAbsoluteVolumeChange` stream type to include `direction` and `delta` fields - Added `setMAPlayingState()` method to notify native layer of playback state 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% --- .../com/collotsspot/ensemble/MainActivity.kt | 27 +++++++---- docs/PR_VOLUME_MIRRORING_FIX.md | 48 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 docs/PR_VOLUME_MIRRORING_FIX.md 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 28b9c0f..4d21723 100644 --- a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt @@ -32,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) @@ -97,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) } @@ -131,6 +136,8 @@ 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 @@ -176,18 +183,22 @@ class MainActivity: AudioServiceActivity() { 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) + // Send volume + direction + delta to Flutter methodChannel?.invokeMethod("absoluteVolumeChange", mapOf("volume" to maVolume, "direction" to direction, "delta" to delta)) - // Reset system volume to the midpoint of its range so the - // ContentObserver always has room to detect the next press - // in either direction. Without this, holding a button drives - // the system to 0 or max and further presses are invisible. - val midpoint = maxVolume / 2 - if (currentVolume != midpoint) { + // 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, midpoint, 0) - lastKnownVolume = midpoint + am.setStreamVolume(AudioManager.STREAM_MUSIC, targetSystemVol, 0) + lastKnownVolume = targetSystemVol Handler(Looper.getMainLooper()).postDelayed({ ignoringVolumeChange = false }, 100) } } 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%