fix: show every held volume step in Steam's volume slider (#1704)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-28 00:01:00 -04:00
commit f4656ad83a
4 files changed
+75 -14

No files matched your search

@@ -1,7 +1,9 @@
package com.droiddeck.launcher.runtime
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -34,6 +36,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
private val observer = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) { if (running) publish() }
}
private val volumeSteps = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { if (running) publishVolume() }
}
private val requests = object : FileObserver(directory.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path in REQUESTS) handler.post { if (running) consume() }
@@ -50,13 +55,22 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
}
}
fun publish(force: Boolean = false) {
private fun publishVolume() {
try {
directory.mkdirs()
val level = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
val maximum = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
val muted = audio.isStreamMute(AudioManager.STREAM_MUSIC)
write("volume.state", "$level $maximum ${if (muted) 1 else 0}\n")
} catch (e: Exception) {
Log.w(TAG, "Could not publish Android volume", e)
}
}
fun publish(force: Boolean = false) {
publishVolume()
try {
directory.mkdirs()
val value = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
write("backlight/droiddeck/brightness", "$value\n")
write("backlight/droiddeck/actual_brightness", "$value\n")
@@ -81,6 +95,10 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
permissionNotified = false
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, observer)
requests.startWatching()
context.registerReceiver(volumeSteps, IntentFilter().apply {
addAction(VOLUME_CHANGED_ACTION)
addAction(STREAM_MUTE_CHANGED_ACTION)
}, null, handler)
refresh.run()
}
}
@@ -90,6 +108,7 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
running = false
handler.removeCallbacks(refresh)
requests.stopWatching()
try { context.unregisterReceiver(volumeSteps) } catch (e: IllegalArgumentException) { }
context.contentResolver.unregisterContentObserver(observer)
context.getSystemService(NotificationManager::class.java).cancel(PERMISSION_NOTIFICATION)
}
@@ -170,6 +189,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
companion object {
private const val TAG = "DeviceControls"
// AudioService sends these on every stream step; the Settings observer and 250 ms poll lag a held volume key.
private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"
private const val STREAM_MUTE_CHANGED_ACTION = "android.media.STREAM_MUTE_CHANGED_ACTION"
private const val PERMISSION_CHANNEL = "brightness_permission"
private const val PERMISSION_NOTIFICATION = 0x4442
private val REQUESTS = setOf("brightness.request", "volume.request", "mute.request")
@@ -66,6 +66,15 @@ class DeviceControlsComponentTest {
assertEquals(3, audio.getStreamVolume(AudioManager.STREAM_MUSIC))
}
@Test fun everyVolumeStepIsPublishedWithoutWaitingForThePoll() {
for (level in 2..5) {
audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0)
context.sendBroadcast(android.content.Intent("android.media.VOLUME_CHANGED_ACTION"))
shadowOf(Looper.getMainLooper()).idle()
assertEquals(level.toString(), File(directory, "volume.state").readText().split(" ").first())
}
}
@Test fun deniedBrightnessDoesNotOpenSettingsOrReplayAnOldSliderValueAfterPermission() {
WriteSettingsShadow.writable = false
request("brightness", "0.75")
+33 -11
View File
@@ -3,10 +3,15 @@
#include <pulsecore/core-rtclock.h>
#include <pulsecore/core-subscribe.h>
#define ANDROID_VOLUME_IDLE_MS 100
#define ANDROID_VOLUME_ACTIVE_MS 16
#define ANDROID_VOLUME_ACTIVE_TICKS 40
struct android_volume {
pa_sink *sink;
char *directory;
pa_time_event *timer;
unsigned active_ticks;
};
static bool android_volume_read(const char *directory, unsigned *level, unsigned *maximum, unsigned *muted) {
@@ -57,22 +62,39 @@ static void android_volume_refresh(pa_mainloop_api *api, pa_time_event *event, c
char *processing = pa_sprintf_malloc("%s.processing", request);
char *mute_processing = pa_sprintf_malloc("%s.processing", mute_request);
if (access(request, F_OK) != 0 && access(mute_request, F_OK) != 0 &&
access(processing, F_OK) != 0 && access(mute_processing, F_OK) != 0 &&
android_volume_read(bridge->directory, &level, &maximum, &muted)) {
pa_cvolume volume;
if (bridge->sink->n_volume_steps != maximum + 1) {
bridge->sink->n_volume_steps = maximum + 1;
pa_subscription_post(bridge->sink->core, PA_SUBSCRIPTION_EVENT_SINK | PA_SUBSCRIPTION_EVENT_CHANGE, bridge->sink->index);
access(processing, F_OK) != 0 && access(mute_processing, F_OK) != 0) {
if (android_volume_read(bridge->directory, &level, &maximum, &muted)) {
pa_cvolume volume;
bool changed = false;
if (bridge->sink->n_volume_steps != maximum + 1) {
bridge->sink->n_volume_steps = maximum + 1;
pa_subscription_post(bridge->sink->core, PA_SUBSCRIPTION_EVENT_SINK | PA_SUBSCRIPTION_EVENT_CHANGE, bridge->sink->index);
}
pa_cvolume_set(&volume, bridge->sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum);
if (!pa_cvolume_equal(&volume, &bridge->sink->real_volume)) {
pa_sink_volume_changed(bridge->sink, &volume);
changed = true;
}
if (bridge->sink->muted != !!muted) {
pa_sink_mute_changed(bridge->sink, !!muted);
changed = true;
}
if (changed) bridge->active_ticks = ANDROID_VOLUME_ACTIVE_TICKS;
}
pa_cvolume_set(&volume, bridge->sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum);
if (!pa_cvolume_equal(&volume, &bridge->sink->real_volume)) pa_sink_volume_changed(bridge->sink, &volume);
if (bridge->sink->muted != !!muted) pa_sink_mute_changed(bridge->sink, !!muted);
} else {
bridge->active_ticks = ANDROID_VOLUME_ACTIVE_TICKS;
}
pa_xfree(request);
pa_xfree(mute_request);
pa_xfree(processing);
pa_xfree(mute_processing);
pa_core_rttime_restart(bridge->sink->core, event, pa_rtclock_now() + 100 * PA_USEC_PER_MSEC);
/* A held volume key steps Android every ~50 ms; sampling at 100 ms hands Steam uneven multi-step jumps. */
unsigned delay_ms = ANDROID_VOLUME_IDLE_MS;
if (bridge->active_ticks) {
bridge->active_ticks--;
delay_ms = ANDROID_VOLUME_ACTIVE_MS;
}
pa_core_rttime_restart(bridge->sink->core, event, pa_rtclock_now() + delay_ms * PA_USEC_PER_MSEC);
}
static struct android_volume *android_volume_new(pa_sink *sink, const char *directory) {
@@ -90,7 +112,7 @@ static struct android_volume *android_volume_new(pa_sink *sink, const char *dire
pa_cvolume_reset(&sink->soft_volume, sink->sample_spec.channels);
pa_sink_set_set_volume_callback(sink, android_volume_set);
pa_sink_set_set_mute_callback(sink, android_volume_mute);
bridge->timer = pa_core_rttime_new(sink->core, pa_rtclock_now() + 100 * PA_USEC_PER_MSEC,
bridge->timer = pa_core_rttime_new(sink->core, pa_rtclock_now() + ANDROID_VOLUME_IDLE_MS * PA_USEC_PER_MSEC,
android_volume_refresh, bridge);
return bridge;
}
+10 -2
View File
@@ -44,6 +44,7 @@ typedef struct pa_sink {
struct android_volume;
struct userdata { struct android_volume *android_volume; };
static int restarts, frees;
static uint64_t last_delay;
static char *pa_sprintf_malloc(const char *format, ...) {
va_list ap; va_start(ap,format); char *result; assert(vasprintf(&result,format,ap)>=0); va_end(ap); return result;
}
@@ -57,7 +58,7 @@ static void pa_sink_set_set_volume_callback(pa_sink *sink, void (*callback)(pa_s
static void pa_sink_set_set_mute_callback(pa_sink *sink, void (*callback)(pa_sink*)) { sink->set_mute=callback; }
static void pa_subscription_post(pa_core *core, int event, unsigned index) {}
static uint64_t pa_rtclock_now(void) { return 0; }
static void pa_core_rttime_restart(pa_core *core, pa_time_event *event, uint64_t time) { restarts++; }
static void pa_core_rttime_restart(pa_core *core, pa_time_event *event, uint64_t time) { restarts++; last_delay=time; }
static pa_time_event *pa_core_rttime_new(pa_core *core, uint64_t time,
void (*callback)(pa_mainloop_api*,pa_time_event*,const struct timeval*,void*), void *userdata) {
return calloc(1,sizeof(pa_time_event));
@@ -76,11 +77,18 @@ int main(int argc, char **argv) {
assert(userdata.android_volume && userdata.android_volume->timer && sink.set_volume && sink.set_mute);
state(argv[1],"invalid\n");
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(sink.real_volume.value==PA_VOLUME_NORM && restarts==1);
assert(sink.real_volume.value==PA_VOLUME_NORM && restarts==1 && last_delay==100*PA_USEC_PER_MSEC);
state(argv[1],"3 15 1\n");
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(sink.real_volume.value==3*PA_VOLUME_NORM/15 && sink.muted && sink.n_volume_steps==16);
assert(sink.soft_volume.value==PA_VOLUME_NORM && restarts==2);
assert(last_delay==16*PA_USEC_PER_MSEC);
state(argv[1],"4 15 1\n");
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(sink.real_volume.value==4*PA_VOLUME_NORM/15 && last_delay==16*PA_USEC_PER_MSEC);
for (int i = 0; i < ANDROID_VOLUME_ACTIVE_TICKS + 1; i++)
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(last_delay==100*PA_USEC_PER_MSEC);
sink.real_volume.value=2*PA_VOLUME_NORM/3; sink.set_volume(&sink);
char *request=pa_sprintf_malloc("%s/volume.request",argv[1]);
FILE *f=fopen(request,"r"); unsigned level; assert(f && fscanf(f,"%u",&level)==1 && level==10); fclose(f); free(request);