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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Boolean>("isPlaying") ?: false
Log.d(TAG, "MA playing state updated: isMAPlaying=$isMAPlaying")
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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()
}

Expand All @@ -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() {
Expand Down
48 changes: 48 additions & 0 deletions docs/PR_VOLUME_MIRRORING_FIX.md
Original file line number Diff line number Diff line change
@@ -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%
87 changes: 79 additions & 8 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> 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;
Expand Down Expand Up @@ -166,21 +176,49 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> 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.
Expand Down Expand Up @@ -228,17 +266,49 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> 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<void> _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<void> _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);
}
Expand All @@ -250,6 +320,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_volumeUpSub?.cancel();
_volumeDownSub?.cancel();
_absoluteVolumeSub?.cancel();
_steppedVolumeExpiry?.cancel();
_hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
Expand Down
Loading
Loading