Android: no in-app volume control — leave volume to the system #64

Closed
opened 2026-08-18 06:00:07 +00:00 by logan · 3 comments
Collaborator

Report

On Android, volume is the device's job. We do not need our own volume control; assume the user handles it with the hardware keys and keep our internal level at 100%.

Findings

  • volume-control is part of the shared bottom bar and already stands down at narrow widths; this asks for it to be absent on Android entirely.
  • Do not confuse this with ducking. OnDuck is Android-specific: the framework asks for attenuation when something short needs the output, and Player.SetDuck keeps that as an offset on top of the user's level rather than writing through to the volume — it must not persist or emit a level the user did not choose, and it only fires below API 26. Pinning "our level" at 100% must leave that offset mechanism intact.
  • The Up/Down global keyboard shortcuts are volume bindings; irrelevant on a phone but they share the code path.

Direction

Hide the control on Android, keep the internal level at 1.0, and make sure nothing writes a persisted volume from that platform. Verify ducking still attenuates and restores.

**Report** On Android, volume is the device's job. We do not need our own volume control; assume the user handles it with the hardware keys and keep our internal level at 100%. **Findings** - `volume-control` is part of the shared bottom bar and already stands down at narrow widths; this asks for it to be absent on Android entirely. - **Do not confuse this with ducking.** `OnDuck` is Android-specific: the framework asks for attenuation when something short needs the output, and `Player.SetDuck` keeps that as an offset on top of the user's level rather than writing through to the volume — it must not persist or emit a level the user did not choose, and it only fires below API 26. Pinning "our level" at 100% must leave that offset mechanism intact. - The Up/Down global keyboard shortcuts are volume bindings; irrelevant on a phone but they share the code path. **Direction** Hide the control on Android, keep the internal level at 1.0, and make sure nothing writes a persisted volume from that platform. Verify ducking still attenuates and restores.
logan added the Area/PlayerKind/EnhancementPlatform/Android
Priority
Medium
3
labels 2026-08-18 14:36:15 +00:00
Author
Collaborator

Checked while planning #59/#56, because this issue is in the same
pixels. It is not done, and its first Finding is now misleading —
recording that rather than acting on it.

"volume-control … already stands down at narrow widths" is true of
one of the two copies. Measured at 424x439:

  • The bottom bar's copy is gone, and by width rather than by
    platform: index.css's phone block carries
    .bottom-bar volume-control { display: none }, added when #42 moved
    the control into the bar.
  • now-playing-view renders one and it is visible on a phone. That
    is deliberate today — the comment beside the rule says so in as many
    words: ".bottom-bar volume-control, not the one in
    now-playing-view: that view is the phone's transport and is where a
    slider does belong."

So what this issue asks for is exactly the part that has not happened,
and it directly contradicts a decision that was made on purpose and
written down. That is worth settling before anyone writes code.

The crux is platform versus width, and the frontend cannot currently
tell.
Every stand-down rule in this app is keyed on a viewport width,
because that is a question a browser can answer and every tier here can
test. "Absent on Android" is a different predicate, and there is no
isAndroid signal in the frontend at all. Three options, none free:

  1. Keep it width-keyed and extend the existing rule to
    now-playing-view. Cheapest, testable in every tier — but it also
    takes the slider away from a narrow desktop window, which nobody
    asked for and which has no hardware keys guaranteed.
  2. Add a platform signal from the backend. Honest, and reusable — #54
    and #63 will want one — but it is a new cross-cutting fact and
    nothing can test the Android branch except a device.
  3. Decide the slider is wrong on any small screen and say so, which is
    option 1 with the reasoning made explicit rather than incidental.

The other two halves of the Direction are untouched and are backend
work: pinning the internal level at 1.0 on that platform, and making
sure nothing persists a volume from it — without disturbing
Player.SetDuck, which the second Finding is right to flag.

Not claiming this. #59/#56 deliberately do not touch the volume control,
so nothing here is blocked by them.

**Checked while planning #59/#56, because this issue is in the same pixels. It is not done, and its first Finding is now misleading — recording that rather than acting on it.** "`volume-control` … already stands down at narrow widths" is true of *one* of the two copies. Measured at 424x439: - **The bottom bar's copy is gone**, and by width rather than by platform: `index.css`'s phone block carries `.bottom-bar volume-control { display: none }`, added when #42 moved the control into the bar. - **`now-playing-view` renders one and it is visible on a phone.** That is deliberate today — the comment beside the rule says so in as many words: "`.bottom-bar volume-control`, not the one in `now-playing-view`: that view is the phone's transport and is where a slider does belong." So what this issue asks for is exactly the part that has *not* happened, and it directly contradicts a decision that was made on purpose and written down. That is worth settling before anyone writes code. **The crux is platform versus width, and the frontend cannot currently tell.** Every stand-down rule in this app is keyed on a viewport width, because that is a question a browser can answer and every tier here can test. "Absent on Android" is a different predicate, and there is no `isAndroid` signal in the frontend at all. Three options, none free: 1. Keep it width-keyed and extend the existing rule to `now-playing-view`. Cheapest, testable in every tier — but it also takes the slider away from a *narrow desktop window*, which nobody asked for and which has no hardware keys guaranteed. 2. Add a platform signal from the backend. Honest, and reusable — #54 and #63 will want one — but it is a new cross-cutting fact and nothing can test the Android branch except a device. 3. Decide the slider is wrong on any small screen and say so, which is option 1 with the reasoning made explicit rather than incidental. The other two halves of the Direction are untouched and are backend work: pinning the internal level at 1.0 on that platform, and making sure nothing persists a volume from it — **without** disturbing `Player.SetDuck`, which the second Finding is right to flag. Not claiming this. #59/#56 deliberately do not touch the volume control, so nothing here is blocked by them.
logan self-assigned this 2026-08-21 04:40:21 +00:00
logan added the
Status
In Progress
label 2026-08-21 04:40:22 +00:00
Author
Collaborator

Taking this, branch 64-android-system-volume. Reordering argued on
#73 first (it is a Phase 4 "independent" item and Phase 3 has #58 and
#51 outstanding): #172's Direction names this as the one pure-gain
vertical win on Now Playing, and #51 is the verification pass that
should run after it.

The approach, and the crux is that this cannot be a viewport
question.

The first Finding is misleading and my comment above says why: the bar's
copy stands down by width, and now-playing-view's copy is visible on
a phone deliberately. So the choice is what predicate replaces it.

I am not extending the width rule to now-playing-view, because the
backend half of this issue makes that wrong on the platform it is about.
Pinning the internal level at 1.0 on Android means a slider that moves
and changes nothing — and an Android tablet at >=600px would get the
bottom bar's copy, i.e. an inert control on exactly the platform this
issue exists for. library-status-indicator already settled that
shape: a control that cannot act is worse than none.

So the predicate is a backend fact, and it is named after the capability
rather than the platform: "does the system own the volume". One
build-tagged constant (android), everything else untagged and
therefore testable on every platform — the split mediacontrols
already uses, with androidpayload.go's reasoning for keeping the
contract out of a tagged file. The frontend asks the player, not the
viewport, so the answer is right at every width.

Three parts:

  1. backend/playerSetVolume/ChangeVolume/mute are no-ops where
    the system owns the volume, the level sits at max, and the stored
    volume round-trips untouched rather than being overwritten with a
    level the user did not choose. SetDuck is untouched: it re-applies
    the user's level through setVolumeLocked, so pinning that level to
    max leaves the offset working exactly as it did.
  2. One binding, read once at boot, folded into volume-style-store
    which already answers "what presentation does the volume control
    get" for both mount points, and "none" is a presentation.
  3. volume-control renders nothing and takes no space, so both the bar
    and Now Playing get it from one place.

Then re-measure #172 at 424x439 and put the before/after on it.

What I will not be able to verify: no device is attached, so the
Android branch of the tagged constant is unexercised — the policy
either side of it is tested in Go, and the frontend's "no volume" branch
is testable in the component tier through the Wails fake. Ducking on a
real audio focus change is device-only and I will say so rather than
imply otherwise.

Taking this, branch `64-android-system-volume`. Reordering argued on #73 first (it is a Phase 4 "independent" item and Phase 3 has #58 and #51 outstanding): #172's Direction names this as the one pure-gain vertical win on Now Playing, and #51 is the verification pass that should run *after* it. **The approach, and the crux is that this cannot be a viewport question.** The first Finding is misleading and my comment above says why: the bar's copy stands down by *width*, and `now-playing-view`'s copy is visible on a phone deliberately. So the choice is what predicate replaces it. I am **not** extending the width rule to `now-playing-view`, because the backend half of this issue makes that wrong on the platform it is about. Pinning the internal level at 1.0 on Android means a slider that moves and changes nothing — and an Android *tablet* at >=600px would get the bottom bar's copy, i.e. an inert control on exactly the platform this issue exists for. `library-status-indicator` already settled that shape: **a control that cannot act is worse than none.** So the predicate is a backend fact, and it is named after the capability rather than the platform: **"does the system own the volume"**. One build-tagged constant (`android`), everything else untagged and therefore testable on every platform — the split `mediacontrols` already uses, with `androidpayload.go`'s reasoning for keeping the contract out of a tagged file. The frontend asks the player, not the viewport, so the answer is right at every width. Three parts: 1. `backend/player` — `SetVolume`/`ChangeVolume`/mute are no-ops where the system owns the volume, the level sits at max, and the *stored* volume round-trips untouched rather than being overwritten with a level the user did not choose. `SetDuck` is untouched: it re-applies the user's level through `setVolumeLocked`, so pinning that level to max leaves the offset working exactly as it did. 2. One binding, read once at boot, folded into `volume-style-store` — which already answers "what presentation does the volume control get" for both mount points, and "none" is a presentation. 3. `volume-control` renders nothing and takes no space, so both the bar and Now Playing get it from one place. Then re-measure #172 at 424x439 and put the before/after on it. **What I will not be able to verify:** no device is attached, so the Android branch of the tagged constant is unexercised — the *policy* either side of it is tested in Go, and the frontend's "no volume" branch is testable in the component tier through the Wails fake. Ducking on a real audio focus change is device-only and I will say so rather than imply otherwise.
Author
Collaborator

PR #174, CI green on both engines (WebKit step confirmed run, not
skipped).

The design landed as claimed: the predicate is a backend answer named
after the capability (SystemOwnsVolume), only platformOwnsVolume is
build-tagged, and volume-control renders nothing in both mount points
at every width. The bar's phone rule in index.css stays, restated as
what a stylesheet can answer — room — rather than carrying the platform
argument it could not express.

On the two Findings. The first is corrected in the code rather than
just in prose: what "already stands down at narrow widths" described is
now explicitly the room rule, and the platform rule is a separate
mechanism beside it. The second is honoured — SetDuck is untouched,
and TestSystemVolumeStillDucks pins the property (attenuate, restore,
and a refused SetVolume not disturbing the offset, which is the new
way that could have broken). The third — the Up/Down bindings sharing
the code path — is why ChangeVolume is refused rather than only
SetVolume, and mute is refused with them, since with no control
rendered it is the one state on such a platform there would be nothing
to get out of.

Not verified, and no device was attached: the tagged constant is
compiled by nothing here (a source sweep covers deletion and an edited
value, which is not the same as a compiler), and ducking against a real
audio-focus change is device-only.

PR #174, CI green on both engines (WebKit step confirmed run, not skipped). The design landed as claimed: the predicate is a backend answer named after the capability (`SystemOwnsVolume`), only `platformOwnsVolume` is build-tagged, and `volume-control` renders nothing in both mount points at every width. The bar's phone rule in `index.css` stays, restated as what a stylesheet can answer — room — rather than carrying the platform argument it could not express. **On the two Findings.** The first is corrected in the code rather than just in prose: what "already stands down at narrow widths" described is now explicitly the *room* rule, and the platform rule is a separate mechanism beside it. The second is honoured — `SetDuck` is untouched, and `TestSystemVolumeStillDucks` pins the property (attenuate, restore, and a *refused* `SetVolume` not disturbing the offset, which is the new way that could have broken). The third — the Up/Down bindings sharing the code path — is why `ChangeVolume` is refused rather than only `SetVolume`, and mute is refused with them, since with no control rendered it is the one state on such a platform there would be nothing to get out of. **Not verified, and no device was attached:** the tagged constant is compiled by nothing here (a source sweep covers deletion and an edited value, which is not the same as a compiler), and ducking against a real audio-focus change is device-only.
logan closed this issue 2026-08-21 05:50:07 +00:00
gitea-actions bot removed the
Status
In Progress
label 2026-08-21 05:50:18 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#64