Skip to content

LocalPlayer: don't override externally set device volume - #57

Open
sam2kb wants to merge 5 commits into
maniac103:mainfrom
sam2kb:fix/volume-follow-device-changes
Open

sam2kb wants to merge 5 commits into
maniac103:mainfrom
sam2kb:fix/volume-follow-device-changes

Conversation

@sam2kb

@sam2kb sam2kb commented Sep 11, 2026

Copy link
Copy Markdown

Problem

When using the local player with a Bluetooth car head unit, volume set with the car's volume buttons was reset on every new track, back to the value that had last been set from the app/server side.

Root cause

Two separate places re-applied the server's volume:

  1. LMS re-sends its mixer volume (audg) on every stream start, and LocalPlayer applied it again, overwriting the car's change.
  2. Playback state transitions (idle -> buffering -> ready, play/pause) call updatePlayerVolume(), which in Device while playing mode applies the app's volume to the device volume.

A device volume change done by the head unit (AVRCP absolute volume) doesn't come from the app, so it was neither tracked nor adopted - it was simply lost at the next re-application.

Fix

  • LocalPlayer.onDeviceVolumeChanged() now adopts external device volume changes as the new desired volume. Changes caused by the app itself are filtered out via the tracked lastAppliedDeviceVolume; lastSavedDeviceVolume is updated as well, so the "restore volume when playback ends" path (device-while-playing mode) does not undo the change.
  • LocalPlayer.volume setter: a server volume identical to the last announced one is not applied again - that's exactly LMS's per-stream re-send. Tolerance is 1/1000; real LMS steps are >= ~0.009 gain (1%) and the unmute ramp is ~0.02, so no legitimate change is skipped.

Coverage of the volume controls (all paths audited)

Control Code path After the fix
Volume overlay slider / buttons VolumeFragment -> ConnectionHelper.setVolume -> audg unchanged, applied
Volume keys with "use for player volume" MainActivity.onKeyDown -> VolumeFragment.handleKeyDown -> server unchanged, applied
Volume keys routed to the media session MediaService.handleSetDeviceVolume / `handleIncrease DecreaseDeviceVolume` -> server
Player Management volume slider PlayerManagementActivity -> server unchanged, applied
Mute setMuteState -> LMS sends gain 0 / restores on unmute applied (verified: AudioGain(0.0), then ramp back up)
Bluetooth car volume (AVRCP absolute) Android stream volume -> onDeviceVolumeChanged adopted as new volume (Device while playing)
Phone volume keys while app is not the media button session same as above adopted
In-app only mode ExoPlayer internal volume + replay gain untouched (device volume intentionally not controlled)
Use device volume mode server -> device on each value change follows the server, but no longer reset by the per-stream re-send

Known limitation

A car-side change is not reported back to the server (the local player has no Cometd connection to publish a volume request), so the app's volume slider keeps showing the previous server value until volume is changed there; a change made there then takes precedence. Reporting it back would require inverting LMS's volume->gain curve (measured: server 40 -> device 4, 50 -> 5, 80 -> 11); a naive percent round-trip would snap the device volume onto LMS's curve on every car adjustment.

Testing

Debug build on a Samsung device (Android 16), local player, Device while playing mode:

  • external device volume change (simulating the head unit) -> adopted; the next track kept it (before: fell back to the server's value)
  • server-side change (app volume keys) -> applied as before
  • server mute/unmute -> gain 0 / ramp applied
  • In-app only mode unchanged

No unit test added: the logic lives in the media3 player wrapper (device volume callback + playback state machine) and was verified on-device instead.

@sam2kb
sam2kb force-pushed the fix/volume-follow-device-changes branch from e56c496 to cb01719 Compare September 11, 2026 22:57
Alex added 3 commits September 13, 2026 13:42
In DeviceWhilePlaying mode the device volume is driven by the player
volume. The server re-sends its volume on each stream start, and applying
it again overwrote volume changes the user made with a car head unit,
which sets the device volume directly using Bluetooth absolute volume.

Fix this by adopting external device volume changes (that were not caused
by ourselves) as the new desired volume, and by not applying the volume
the server had already announced before.
The server ramps the volume when pausing or resuming playback, and the
device (e.g. a car head unit using Bluetooth absolute volume) also
reports our own volume changes back, rounded to its own volume steps.

The previous code only ignored changes which were exactly the volume we
applied ourselves, so each step of such a ramp was adopted as the
volume the user wanted. As the adopted volume is applied again on
subsequent playback state changes, the volume changed (and drifted) on
every pause/resume cycle.

Ignore changes which arrive shortly after we changed the volume
ourselves instead, which covers both cases.
The server ramps the volume when pausing and resuming playback. Applying
the ramp to the device volume made connected devices (e.g. car head units
following the Bluetooth volume) show every step and pop up their volume
slider, and the saved system volume was restored while playback was only
paused.

- Remember the time of a pause toggle and treat server volume changes
  arriving around it as the pause/resume fade: they are applied as a factor
  on the player volume, which connected devices don't see.
- While paused, only ever apply server volume changes to the player volume.
- Don't write the device volume when the value doesn't change: devices
  connected via Bluetooth still report such a write as a volume change.
- Restore the saved device volume only after playback really stopped (no
  new stream follows within 3s), not on pause or track change.
@sam2kb
sam2kb force-pushed the fix/volume-follow-device-changes branch from cb01719 to be5c786 Compare September 13, 2026 17:42
Alex added 2 commits September 13, 2026 19:01
The server ramps the volume when pausing, resuming or stopping a stream
and re-announces its volume when a new stream starts. Those values are
now recognised as fades for the whole burst (the last step of a ramp can
arrive after the fade window has passed) and are kept out of both the
device volume and lastServerVolume: applying them made the device volume
jump on every track change and pause toggle, and remembering them made
the next announcement of the real server volume overwrite the volume the
user had set.

The check for volume changes we caused ourselves happened after
lastAppliedDeviceVolume had been updated to the reported volume, which
made it always match: externally set volumes were never adopted, so the
device volume was reset to the server volume on every playback state
change. Check before updating the tracking again.

Ramps are now applied relative to the volume the server ramps from (and
to the player volume only), so playback isn't attenuated once the ramp
is over.
The server ramps its volume around pause toggles, track changes and
stream stops, and it announces its volume when a stream is set up. Such
values still reached the device volume in some orderings (e.g. a ramp
that started while playback was still running and only settled after the
pause), which muted or reset the volume the user had set on the device.

- Re-check the fade condition when a pending volume change is applied,
  not only when it arrives.
- Re-adopt the volume the device is set to on pause toggles and around
  stream starts/stops, so playback continues at the volume the user set
  there instead of a fade value the server sent along.
- While playback isn't running, server volume changes count as fades in
  the modes driving the device volume (PlayerOnly mode is unaffected).
@sam2kb

sam2kb commented Sep 14, 2026

Copy link
Copy Markdown
Author

Explainer why so many changes

Behaviour now (Device / DeviceWhilePlaying)

  • device volume is authoritative: external changes (car knob, dash) are adopted immediately
  • LMS volume reaches the device only while actively playing and outside a fade window
  • pause/resume ramps, stream start/stop announcements and the stop-mute go to the player volume only, and are never remembered as the server volume
  • PlayerOnly mode unchanged (server volume still drives the player volume)

Why 5 commits

  • 6d15c13, c48d668, be5c786 — first cut
  • 950377f — two bugs found when integrating: the self-change check compared against a value it had just overwritten (external volume was never adopted, so the device volume was reset to the server volume on every state change); fade values were stored as lastServerVolume, so the next stream-start announcement was no longer deduped and overwrote the user's volume
  • c8fdacc — third bug, found in the live test (pause -> unpause muted the device): a ramp that started while playback was still running and only settled after the pause was still treated as a real change

Protocol reality (why it is easy to get wrong)

  • audg carries no fade flag: a real volume change, a pause ramp and the per-stream re-announcement are the same packet
  • LMS ramps pause/resume in 50 ms steps over 0.3125 s, sends volume(0) on stop/mute, and re-sends its mixer volume at every stream start
  • so this is a heuristic with three guard rails: dedupe against the last real value, write the device only while playing, re-adopt from the device at transitions

Verified on device (SM-F956U1, LMS 9.1.2; adb + LMS CLI)

  • external volume 5 unchanged through: play / pause / unpause / next track / stop
  • real LMS change while playing still applies: 90 -> 13, 45 -> 5
  • :app:testFossDebugUnitTest and assembleFossDebug pass

Limits

  • a server volume change while paused / while a stream is being set up does not move the device volume (the device volume wins on resume)
  • the app still cannot push its volume back to the server (standalone slimproto client, no Cometd)
  • the fade windows are fixed (350 ms settle, 1.5 s pause window); they need retuning if LMS changes its ramp timing

@maniac103

Copy link
Copy Markdown
Owner

the app still cannot push its volume back to the server (standalone slimproto client, no Cometd)

The complication (and I think the proposed change is way too complicated for my taste) comes from this very problem: it's trying to fight the system.
I don't yet understand why the local player needs to handle the volume changes though. I'd expect them to come in as volume change requests via MediaService? If that's not the case, I think I'd prefer the 'workaround' of using either MediaService or ConnectionHelper for sending the volume change to the server over this solution.

@maniac103

Copy link
Copy Markdown
Owner

My idea is something like this:

diff --git a/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlaybackService.kt b/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlaybackService.kt
index b87dbcc..ec6be8c 100644
--- a/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlaybackService.kt
+++ b/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlaybackService.kt
@@ -42,6 +42,8 @@ import androidx.work.NetworkType
 import androidx.work.OneTimeWorkRequestBuilder
 import androidx.work.OutOfQuotaPolicy
 import de.maniac103.squeezeclient.R
+import de.maniac103.squeezeclient.cometd.ConnectionState
+import de.maniac103.squeezeclient.extfuncs.connectionHelper
 import de.maniac103.squeezeclient.extfuncs.getOrCreateNotificationChannel
 import de.maniac103.squeezeclient.extfuncs.localPlayerEnabled
 import de.maniac103.squeezeclient.extfuncs.localPlayerName
@@ -51,6 +53,7 @@ import de.maniac103.squeezeclient.extfuncs.workManager
 import de.maniac103.squeezeclient.service.NotificationIds
 import de.maniac103.squeezeclient.ui.MainActivity
 import de.maniac103.squeezeclient.ui.prefs.SettingsActivity
+import kotlin.math.roundToInt
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.DurationUnit
@@ -64,8 +67,11 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
 import okhttp3.Response
 
 @OptIn(ExperimentalTime::class)
@@ -103,7 +109,8 @@ class LocalPlaybackService :
             onDecoderLoadFinished = { onDecoderLoadFinished() },
             onDecodingFinished = { onDecodingFinished() },
             onHeadersReceived = { resp -> onHeadersReceived(resp) },
-            onMetadataReceived = { title, artworkUri -> onMetadataReceived(title, artworkUri) }
+            onMetadataReceived = { title, artworkUri -> onMetadataReceived(title, artworkUri) },
+            onVolumeChanged = { volume -> onVolumeChanged(volume) }
         )
         startupTimestampNanos = System.nanoTime()
     }
@@ -157,6 +164,16 @@ class LocalPlaybackService :
         }
     }
 
+    private fun onVolumeChanged(volume: Float) = lifecycleScope.launch {
+        connectionHelper.connect()
+        val connectedState = withTimeoutOrNull(2.seconds) {
+            connectionHelper.state.filterIsInstance<ConnectionState.Connected>().first()
+        }
+        if (connectedState != null) {
+            connectionHelper.setVolume(slimproto.playerId, (volume * 100).roundToInt()) // playerId depends on #62
+        }
+    }
+
     private fun onPlaybackReady(buffering: Boolean) = lifecycleScope.launch {
         slimprotoStateFlow.emit(
             SlimprotoState.PlayingOrPaused(player.playingTitle, player.paused)
diff --git a/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlayer.kt b/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlayer.kt
index e4f78cf..f71fdd5 100644
--- a/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlayer.kt
+++ b/app/src/main/java/de/maniac103/squeezeclient/service/localplayer/LocalPlayer.kt
@@ -70,7 +70,8 @@ class LocalPlayer(
     onDecodingFinished: () -> Unit = {},
     onAudioStreamFlushed: () -> Unit = {},
     private val onHeadersReceived: (response: Response) -> Unit = {},
-    private val onMetadataReceived: (title: CharSequence, artworkUri: Uri?) -> Unit = { _, _ -> }
+    private val onMetadataReceived: (title: CharSequence, artworkUri: Uri?) -> Unit = { _, _ -> },
+    private val onVolumeChanged: (volume: Float) -> Unit = {}
 ) : Player.Listener {
     private val prefs = context.prefs
     private val dataSourceFactory: HttpDataSource.Factory
@@ -109,6 +110,7 @@ class LocalPlayer(
     private var playerInternalVolume = 1F
     private var currentReplayGain = 1F
     private var lastSavedDeviceVolume: Int? = null
+    private var lastSetDeviceVolume: Int? = null
 
     @UnstableApi
     private val audioProcessor = LocalPlayerAudioProcessor(
@@ -317,6 +319,16 @@ class LocalPlayer(
         mediaMetadata.title?.let { onMetadataReceived(it, mediaMetadata.artworkUri) }
     }
·
+    override fun onDeviceVolumeChanged(volume: Int, muted: Boolean) {
+        super.onDeviceVolumeChanged(volume, muted)
+        if (lastSetDeviceVolume != null && volume != lastSetDeviceVolume) {
+            val maxVolume = player.deviceInfo.maxVolume.toFloat()
+            if (maxVolume > 0) {
+                onVolumeChanged(volume.toFloat() / maxVolume)
+            }
+        }
+    }
+
     private fun updatePlayerVolume(isSetVolume: Boolean) {
         val volume = lastSetVolume ?: return
         // Playback is considered ongoing while playing or buffering to continue playing
@@ -347,6 +359,7 @@ class LocalPlayer(
             !playbackOngoing && lastSavedDeviceVolume != null -> {
                 player.setDeviceVolume(lastSavedDeviceVolume!!, 0)
                 lastSavedDeviceVolume = null
+                lastSetDeviceVolume = null
             }
         }
     }
@@ -355,6 +368,7 @@ class LocalPlayer(
         val maxVolume = player.deviceInfo.maxVolume.takeIf { it > 0 } ?: return
         val volumeAsInt = (volume * maxVolume).roundToInt()
         player.setDeviceVolume(volumeAsInt, 0)
+        lastSetDeviceVolume = volumeAsInt
     }
 
     @OptIn(UnstableApi::class)

That way the server knows about the volume change and we don't need to fight its logic.

@sam2kb

sam2kb commented Sep 14, 2026

Copy link
Copy Markdown
Author

Mirroring the volume to the server instead of adopting it locally is the better direction — but two things still stand in the way:

1. audg does not carry a percentage
It carries oldGain = volume_map[mixer volume] (a table), and the app maps that gain linearly onto the device volume. So pushing (volume * 100) and getting it back drifts:

device 5/15 (33%)      -> push "mixer volume 33"
LMS volume_map[33] = 25 -> audg gain 25/128 = 0.195
app: 0.195 * 15 = 2.9  -> device 3
next change: 3/15 (20%) -> volume_map[20] = 12 -> 0.094 -> device 1

To push back losslessly the app has to invert the table: device fraction 0.333 is mixer volume 48 (volume_map[48] = 43 -> 0.336), not 33.

2. The fades are still there
LMS ramps pause/resume (50 ms steps over 0.3125 s) and sends volume(0) on stop/mute — all as plain audg. With the device volume mirrored to the server, those values still reach the device and mute/jump it, so a guard that keeps server volume changes away from the device while playback isn't running (and around a pause toggle) is needed either way. It is orthogonal to your change.

On your diff

  • slimproto.playerId is available now that Expose the local player in the media session while it is playing #62 is in ✓.
  • LocalPlaybackService is standalone today (own SlimprotoSocket, no Cometd); the diff makes it depend on a live ConnectionHelper connection to mirror volume changes (offline the change is lost; the server value is re-announced later).
  • lastSetDeviceVolume needs to ignore our own writes and the values that arrive while the fade guard suppresses them, otherwise each mirror triggers the next one.

If you want to go that way, I can rework the branch: push-back with the inverted gain mapping plus a shrunken fade guard. Or take it from here, whichever you prefer.

@maniac103

Copy link
Copy Markdown
Owner

I think working off my diff + the missing fixes is the better idea. Let's see what the outcome of that one looks like.

@maniac103

Copy link
Copy Markdown
Owner

On another thought, I wonder whether setting the volume to fixed 100% for the local player on the server side wouldn't be the better solution for this specific use case. That way the in-app volume control is disabled and it's only the device volume that controls the playback volume (outside of the app's control).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants