Closes#64 and #172. Reordering argued on #73 first — #64 is a Phase 4
"independent" item and Phase 3 still has #58 and #51 outstanding; #172's
own Direction names #64 as the one pure-gain vertical win on Now Playing
and #51 is the verification pass that should follow it.
Commits
commit
issue
what
867ced8 feat(player): leave the volume to the system where the system owns it
the frontend half: volume-control asks the player and renders nothing
b43172a docs(player): record who owns the volume, and what it gave back
—
CLAUDE.md and .planning/NOTES.md
The decision that mattered
The issue's first Finding says volume-control "already stands down at
narrow widths". That is true of one of its two copies — the bar's,
by width — and now-playing-view renders one that is visible on a phone deliberately, with a comment saying so. So the real question was what
predicate replaces that comment, and three options were on the issue.
It could not be a width. The backend half of the same issue pins the
level at maximum, and a width rule then leaves an inert slider on the
platform the issue is about: an Android tablet at >=600px gets the
bottom bar and its copy. That is a control that cannot act, which library-status-indicator already settled is worse than none. The same
rule is wrong the other way below 600px, where a narrow desktop window
has no hardware keys to fall back on.
So the frontend asks the player. The predicate is named after the
capability, not the platform — SystemOwnsVolume — which is what
makes it testable: only platformOwnsVolume is behind a build tag, in
two files that declare nothing else, and everything else is decided
against a field a Go test sets either way. That is mediacontrols'
split with androidpayload.go's reasoning. The frontend's absent
branch is exercised in the component tier with a stubbed binding, on an
ordinary Linux runner.
index.css keeps its phone rule for the bar, restated as what a
stylesheet can actually answer: room. Five controls and a slider do not
fit a 360px bar.
Mute is refused too — it is a level of zero by another name, and with no
control rendered it would be the one state on such a platform the user
could not get out of. Persistence round-trips the stored level rather
than recording the synthetic maximum; the alternative, a second query
omitting the column, buys nothing and is a second write path.
SetDuck is untouched, which is the issue's second Finding. It
applies its attenuation by re-applying the user's level through setVolumeLocked, so pinning that level leaves the arithmetic exactly
as it was.
Measured at 424x439 by flipping platformOwnsVolume to true in the !android file and rebuilding — the real binding, the real store, the
real component, everything except the tag itself.
element
before
after
header
48
48
album art
39
68
title / artist / album
63
63
transport
172
143
— seek bar
19
19
— player-controls
116
116
— volume-control
21
0
29px — the 21px control plus the 8px flex gap a hidden box stops
drawing. #172's "~30px of pure gain", confirmed. The art is 74% larger
and no longer the smallest element on the screen.
It is still the second-smallest, and that is #51's question, which is
what #172's Direction says: "decide whether the art gets a floor …
which is #51's question, and this issue is evidence for it rather than a
competing answer." Closing it as that evidence rather than leaving two
issues asking the same thing.
Verification
Run, and what each was for:
make test (3 build configurations) — the four new Go tests, and TestSetDuck still passing beside them.
make ui-test — 976 tests, 89 files, including the two new ones.
make e2e — the whole suite, 220 tests, chromium. Not a formality:
the last three sessions each broke something adjacent, and both
existing volume assertions (bottom-bar.spec.ts:114, phone-shell.spec.ts:238) are what catch an inverted polarity.
make lint (3 configurations), make css-check, make bindings-check, make skill-check, tsc --noEmit in bothfrontend/ and e2e/.
Formalities, but bindings-check is not — this adds a bound method.
Proving the specs bite.volume-ownership.test.ts's two tests were
run against the reverted volume-control.ts and both fail, on the
two different halves they assert:
× renders no control at all, and no empty shadow root to find
expected [ <button …> ] to have a length of +0 but got 1
× leaves the rest of the phone transport alone
a mounted volume-control is not hidden: expected false to be true
The Go tests are all new behaviour and so reproduce trivially; TestAnUnpinnedPlayerStillChangesItsVolume exists because without it
the pinned test passes on a player that refuses everything, which is
what a mis-wired field would produce.
The e2e addition asserts the mechanism this tier can see — that the
control's presence follows the player's answer — and cannot fail on the
old build, since the binding did not exist. It is a guard, not a
reproduction, and the file says so.
What I could not verify at the time. No device was attached when this was written. One was afterwards, and three of the four items below are now closed — see the device-verification comment. What remains is the duck, and for a sharper reason: it is unreachable above API 25 by design, so no modern phone can test it.
Left as written, because the reasoning is what justified shipping without a device:
The build-tagged constant itself is compiled by nothing here. TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps both
files' source instead, which catches deletion and an edited value but
is not a compiler.
Ducking against a real audio-focus change is device-only. TestSystemVolumeStillDucks checks the arithmetic — attenuate,
restore, and a refused SetVolume not disturbing the offset — which
is the property, not the integration.
The one behaviour change a desktop user could see is none: the
binding answers false there and every existing volume spec passes
unchanged.
Screenshots
424x439, before and after, in a comment below.
Closes #64 and #172. Reordering argued on #73 first — #64 is a Phase 4
"independent" item and Phase 3 still has #58 and #51 outstanding; #172's
own Direction names #64 as the one pure-gain vertical win on Now Playing
and #51 is the verification pass that should follow it.
## Commits
| commit | issue | what |
|---|---|---|
| `867ced8` feat(player): leave the volume to the system where the system owns it | #64 | the backend half: level pinned, every route to a level refused, nothing persisted, ducking untouched |
| `2be6fb3` feat(player): draw no volume control where there is no volume | #64, #172 | the frontend half: `volume-control` asks the player and renders nothing |
| `b43172a` docs(player): record who owns the volume, and what it gave back | — | `CLAUDE.md` and `.planning/NOTES.md` |
## The decision that mattered
The issue's first Finding says `volume-control` "already stands down at
narrow widths". That is true of **one** of its two copies — the bar's,
by width — and `now-playing-view` renders one that is visible on a phone
*deliberately*, with a comment saying so. So the real question was what
predicate replaces that comment, and three options were on the issue.
**It could not be a width.** The backend half of the same issue pins the
level at maximum, and a width rule then leaves an inert slider on the
platform the issue is about: an Android **tablet** at >=600px gets the
bottom bar and its copy. That is a control that cannot act, which
`library-status-indicator` already settled is worse than none. The same
rule is wrong the other way below 600px, where a narrow desktop window
has no hardware keys to fall back on.
So the frontend asks the player. **The predicate is named after the
capability, not the platform** — `SystemOwnsVolume` — which is what
makes it testable: only `platformOwnsVolume` is behind a build tag, in
two files that declare nothing else, and everything else is decided
against a field a Go test sets either way. That is `mediacontrols`'
split with `androidpayload.go`'s reasoning. The frontend's *absent*
branch is exercised in the component tier with a stubbed binding, on an
ordinary Linux runner.
`index.css` keeps its phone rule for the bar, restated as what a
stylesheet can actually answer: room. Five controls and a slider do not
fit a 360px bar.
Mute is refused too — it is a level of zero by another name, and with no
control rendered it would be the one state on such a platform the user
could not get out of. Persistence round-trips the stored level rather
than recording the synthetic maximum; the alternative, a second query
omitting the column, buys nothing and is a second write path.
**`SetDuck` is untouched**, which is the issue's second Finding. It
applies its attenuation by re-applying the *user's* level through
`setVolumeLocked`, so pinning that level leaves the arithmetic exactly
as it was.
## #172, re-measured
Measured at 424x439 by flipping `platformOwnsVolume` to `true` in the
`!android` file and rebuilding — the real binding, the real store, the
real component, everything except the tag itself.
| element | before | after |
|---|---|---|
| header | 48 | 48 |
| **album art** | **39** | **68** |
| title / artist / album | 63 | 63 |
| **transport** | **172** | **143** |
| — seek bar | 19 | 19 |
| — player-controls | 116 | 116 |
| — volume-control | 21 | **0** |
**29px** — the 21px control plus the 8px flex gap a hidden box stops
drawing. #172's "~30px of pure gain", confirmed. The art is 74% larger
and no longer the smallest element on the screen.
It is still the second-smallest, and that is **#51's question, which is
what #172's Direction says**: "decide whether the art gets a floor …
which is #51's question, and this issue is evidence for it rather than a
competing answer." Closing it as that evidence rather than leaving two
issues asking the same thing.
## Verification
Run, and what each was for:
- `make test` (3 build configurations) — the four new Go tests, and
`TestSetDuck` still passing beside them.
- `make ui-test` — 976 tests, 89 files, including the two new ones.
- `make e2e` — **the whole suite, 220 tests, chromium**. Not a formality:
the last three sessions each broke something adjacent, and both
existing volume assertions (`bottom-bar.spec.ts:114`,
`phone-shell.spec.ts:238`) are what catch an inverted polarity.
- `make lint` (3 configurations), `make css-check`, `make bindings-check`,
`make skill-check`, `tsc --noEmit` in **both** `frontend/` and `e2e/`.
Formalities, but `bindings-check` is not — this adds a bound method.
**Proving the specs bite.** `volume-ownership.test.ts`'s two tests were
run against the reverted `volume-control.ts` and **both fail**, on the
two different halves they assert:
```
× renders no control at all, and no empty shadow root to find
expected [ <button …> ] to have a length of +0 but got 1
× leaves the rest of the phone transport alone
a mounted volume-control is not hidden: expected false to be true
```
The Go tests are all new behaviour and so reproduce trivially;
`TestAnUnpinnedPlayerStillChangesItsVolume` exists because without it
the pinned test passes on a player that refuses everything, which is
what a mis-wired field would produce.
The e2e addition asserts the *mechanism this tier can see* — that the
control's presence follows the player's answer — and cannot fail on the
old build, since the binding did not exist. It is a guard, not a
reproduction, and the file says so.
**What I could not verify at the time.** No device was attached when this was written. One was afterwards, and three of the four items below are now closed — see the device-verification comment. What remains is the duck, and for a sharper reason: it is unreachable above API 25 by design, so no modern phone can test it.
Left as written, because the reasoning is what justified shipping without a device:
- The build-tagged constant itself is compiled by nothing here.
`TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform` sweeps both
files' source instead, which catches deletion and an edited value but
is not a compiler.
- **Ducking against a real audio-focus change is device-only.**
`TestSystemVolumeStillDucks` checks the arithmetic — attenuate,
restore, and a refused `SetVolume` not disturbing the offset — which
is the property, not the integration.
- The one behaviour change a *desktop* user could see is none: the
binding answers `false` there and every existing volume spec passes
unchanged.
## Screenshots
424x439, before and after, in a comment below.
On Android the hardware keys are the volume control and the framework
mixes our stream against the device level, so a second control inside
the app moves something the user already moved. Where that is true the
player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle
are refused, and nothing persists a level nobody chose: restore
remembers the stored value instead of applying it, and saveState writes
that same value back rather than recording the synthetic maximum.
Mute is in that list because it is a level of zero by another name --
and because with no control rendered it would be the one state on such
a platform the user could not get out of.
The predicate is named after the capability rather than the platform,
because that is what makes it testable. Only platformOwnsVolume is
behind a build tag, in two files that declare nothing else; everything
else is decided against Player.systemVolume, a field a test sets either
way. That is mediacontrols' split, with androidpayload.go's reasoning
for keeping the contract out of a tagged file, and the tagged pair is
covered by a source sweep since no tier here compiles both halves.
SetDuck is deliberately untouched: it applies its attenuation by
re-applying the *user's* level through setVolumeLocked, so pinning that
level to maximum leaves the offset arithmetic exactly as it was. It is
the only thing that may still move the output on such a platform, and
TestSystemVolumeStillDucks is that property rather than a comment.
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not. The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.
It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on. index.css keeps
its phone rule, which is now about room and says so.
Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport. The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].
Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing. The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.
volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer. Both of its tests were confirmed to fail on
the build before this.
Closes#64Closes#172
CLAUDE.md's volume paragraph ended "#64 asks for it to be gone on
Android outright, which is a platform question the frontend cannot
currently ask", which is no longer true -- it can, and the paragraph
now says why the answer is a capability rather than a viewport and what
that costs. The mediacontrols entry gains the corollary: on that
platform "the user's level" is a constant, and the duck is the one
thing that may still move the output.
NOTES.md carries the measurements: the per-element budget at 424x439
before and after, the :host([hidden]) specificity trap, the fact that
the bar's centring survives the control going away, and what no tier
here could check.
Now Playing at 424x439, the reference device's viewport. Both from
the running app, seeded and playing; the "after" is the real rendering
with platformOwnsVolume flipped true and rebuilt, not a simulated
hide.
before
after
The slider and its mute icon are gone from the bottom-left of the
transport, and the art absorbs the 29px — it is flex: 1 1 auto, which
is why the whole gain lands there rather than being shared out.
Worth reading rather than measuring: the art is now large enough to
read as artwork instead of as a stripe, and the block above the
transport has not moved relative to itself. Three bugs in the last three
sessions were found this way and by nothing else, which is why this is
here.
**Now Playing at 424x439**, the reference device's viewport. Both from
the running app, seeded and playing; the "after" is the real rendering
with `platformOwnsVolume` flipped true and rebuilt, not a simulated
hide.
| before | after |
|---|---|
|  |  |
The slider and its mute icon are gone from the bottom-left of the
transport, and the art absorbs the 29px — it is `flex: 1 1 auto`, which
is why the whole gain lands there rather than being shared out.
Worth reading rather than measuring: the art is now large enough to
read as artwork instead of as a stripe, and the block above the
transport has not moved relative to itself. Three bugs in the last three
sessions were found this way and by nothing else, which is why this is
here.
check — every step success, including Commit messages, Lint, Test, Bindings are current and Documented make targets exist.
e2e — success on both engines. E2E — webkit reports completed / success, not skipped: that step carries if: ${{ !cancelled() }}, has silently skipped before, and has been
the only tier to catch a defect in each of the last two sessions, so
it is checked rather than inferred from the run's conclusion. Read
from /api/v1/…/actions/runs/17357/jobs, since gitea_ci job_logs
404s on this build.
The sink plays at real time passed, so the playback specs the
transport work touches were measuring a clock that moves.
**CI green**, run `17357`.
- `check` — every step success, including `Commit messages`, `Lint`,
`Test`, `Bindings are current` and `Documented make targets exist`.
- `e2e` — success on **both** engines. `E2E — webkit` reports
`completed / success`, not `skipped`: that step carries
`if: ${{ !cancelled() }}`, has silently skipped before, and has been
the only tier to catch a defect in each of the last two sessions, so
it is checked rather than inferred from the run's conclusion. Read
from `/api/v1/…/actions/runs/17357/jobs`, since `gitea_ci job_logs`
404s on this build.
- `The sink plays at real time` passed, so the playback specs the
transport work touches were measuring a clock that moves.
A Light Phone III (Android 14, SDK 34, arm64, Chrome 113 at 424x439)
was attached after the PR was opened, so what it listed as
unverifiable was checked rather than left as a caveat.
SystemOwnsVolume answers true on the device -- the build tag, the
constant, the field and the generated binding, end to end, which is the
one thing a source sweep only approximates and which nothing else here
compiles at all. The control is absent in both mount points on the
real engine, and the transport measures 143px, exactly what the
desktop-headless "after" predicted. A stored volume of 37 survives a
session that demonstrably rewrote the row.
The duck is the one that stays open, and now for a stated reason rather
than for want of hardware: the foreground service omits
setWillPauseWhenDucked from Oreo, so the framework attenuates us itself
and never sends AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -- the device logs
`requestAudioFocus() ... flags=0x0` saying so. minSdk is 21, so that
path is live code on Android 5.0 to 7.1 and unreachable above it.
Asking for a modern phone will not test it.
Device verification — three of the four gaps closed
A Light Phone III (TLP301, Android 14 / SDK 34, arm64, WebView
Chrome 113 at 424x439 — the reference device) was attached after this PR
was opened, so what the body lists as unverifiable was re-checked rather
than left standing. f26b44d records it.
Build tag → platformOwnsVolume → Player.systemVolume → the
generated binding, end to end. That is the thing the source sweep only
approximates, and the device is the only tier that compiles the android file at all. (The NDK cross-compile GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/... also
passes, and is worth running first — it catches a type error but not a
wrong constant.)
2. The control is absent in both mount points, on the real engine. .bottom-bar volume-control and now-playing-view's copy are both hidden with an empty shadow root and a 0x0 box. Transport measures 143px on the device — exactly what the desktop-headless "after"
predicted. The art is 75px rather than 68 because this fixture's .names block is one line shorter, not because anything differs.
3. Nothing persists a level nobody chose, and this one needed care:
the default 50 surviving proves nothing, since 50 is what a fresh row
holds anyway. So the stored value was set to 37 in the device's own
database, the app relaunched, and a queue change driven to make the row
be rewritten. Reading it back, the WAL has to be pulled with the
db — the main file alone still showed the old last_track_path and
reads exactly like a write that never happened. With yj.db-wal beside
it: last_track_path is the new track, so saveState ran, and volume is still 37.
4. The duck stays unverified — but now for a stated reason rather
than for want of hardware.WailsForegroundService builds its AudioFocusRequest without setWillPauseWhenDucked from Oreo, so the
framework attenuates the stream itself and never delivers AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK. The device's own log says so:
MediaFocusControl: requestAudioFocus() from uid/pid 10172/28537
AA=USAGE_MEDIA/CONTENT_TYPE_MUSIC ... req=1 flags=0x0
flags=0x0 is the absent AUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS. minSdk is 21, so the Go-side duck is live code rather than dead —
reachable on Android 5.0 to 7.1 and on nothing newer. So #64's "verify
ducking still attenuates and restores" cannot be done on any modern
device, and asking for a better phone will not help; it needs an API
21–25 one. The arithmetic remains covered by TestSystemVolumeStillDucks, including that a refusedSetVolume
does not disturb the offset.
Two things found on the way
Neither is caused by this change.
A fresh install downloads the real catalog, and that is 209px of
the screen while it does.YJ_CORE_INDEX_URL is stubbed in dev-headless.sh and in CI but is real on a device, so the first
measurement taken here was of a screen with job-band on it and the
art at 0px. Worth knowing before someone reports it as #172
getting worse: it is the environment.
#175 — the first-run wizard does not dismiss when a library
appears by a route other than its own, and stays up intercepting
every pointer event. Filed with the reproduction.
The device now has the debug build (app.yellowjacket.dev, a sibling
id — nothing was uninstalled) and a scratch library under /sdcard/Music/yj.
## Device verification — three of the four gaps closed
A **Light Phone III** (TLP301, Android 14 / SDK 34, arm64, WebView
Chrome 113 at 424x439 — the reference device) was attached after this PR
was opened, so what the body lists as unverifiable was re-checked rather
than left standing. `f26b44d` records it.
**1. The whole chain resolves on the device.**
```
__yj.call("player.Player.SystemOwnsVolume", []) -> true
```
Build tag → `platformOwnsVolume` → `Player.systemVolume` → the
generated binding, end to end. That is the thing the source sweep only
approximates, and the device is the only tier that compiles the
`android` file at all. (The NDK cross-compile
`GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...` also
passes, and is worth running first — it catches a type error but not a
wrong constant.)
**2. The control is absent in both mount points, on the real engine.**
`.bottom-bar volume-control` and `now-playing-view`'s copy are both
`hidden` with an empty shadow root and a 0x0 box. Transport measures
**143px** on the device — exactly what the desktop-headless "after"
predicted. The art is 75px rather than 68 because this fixture's
`.names` block is one line shorter, not because anything differs.

**3. Nothing persists a level nobody chose**, and this one needed care:
the default 50 surviving proves nothing, since 50 is what a fresh row
holds anyway. So the stored value was set to **37** in the device's own
database, the app relaunched, and a queue change driven to make the row
be rewritten. Reading it back, **the WAL has to be pulled with the
db** — the main file alone still showed the old `last_track_path` and
reads exactly like a write that never happened. With `yj.db-wal` beside
it: `last_track_path` is the new track, so `saveState` ran, and
`volume` is still `37`.
**4. The duck stays unverified — but now for a stated reason rather
than for want of hardware.** `WailsForegroundService` builds its
`AudioFocusRequest` without `setWillPauseWhenDucked` from Oreo, so the
framework attenuates the stream itself and never delivers
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. The device's own log says so:
```
MediaFocusControl: requestAudioFocus() from uid/pid 10172/28537
AA=USAGE_MEDIA/CONTENT_TYPE_MUSIC ... req=1 flags=0x0
```
`flags=0x0` is the absent `AUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS`.
**`minSdk` is 21**, so the Go-side duck is live code rather than dead —
reachable on Android 5.0 to 7.1 and on nothing newer. So #64's "verify
ducking still attenuates and restores" cannot be done on *any* modern
device, and asking for a better phone will not help; it needs an API
21–25 one. The arithmetic remains covered by
`TestSystemVolumeStillDucks`, including that a *refused* `SetVolume`
does not disturb the offset.
## Two things found on the way
Neither is caused by this change.
- **A fresh install downloads the real catalog, and that is 209px of
the screen while it does.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI but is real on a device, so the first
measurement taken here was of a screen with `job-band` on it and the
art at **0px**. Worth knowing before someone reports it as #172
getting worse: it is the environment.
- **#175** — the first-run wizard does not dismiss when a library
appears by a route other than its own, and stays up intercepting
every pointer event. Filed with the reproduction.
The device now has the debug build (`app.yellowjacket.dev`, a sibling
id — nothing was uninstalled) and a scratch library under
`/sdcard/Music/yj`.
logan
merged commit 2e29e67664 into main2026-08-21 05:50:07 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #64 and #172. Reordering argued on #73 first — #64 is a Phase 4
"independent" item and Phase 3 still has #58 and #51 outstanding; #172's
own Direction names #64 as the one pure-gain vertical win on Now Playing
and #51 is the verification pass that should follow it.
Commits
867ced8feat(player): leave the volume to the system where the system owns it2be6fb3feat(player): draw no volume control where there is no volumevolume-controlasks the player and renders nothingb43172adocs(player): record who owns the volume, and what it gave backCLAUDE.mdand.planning/NOTES.mdThe decision that mattered
The issue's first Finding says
volume-control"already stands down atnarrow widths". That is true of one of its two copies — the bar's,
by width — and
now-playing-viewrenders one that is visible on a phonedeliberately, with a comment saying so. So the real question was what
predicate replaces that comment, and three options were on the issue.
It could not be a width. The backend half of the same issue pins the
level at maximum, and a width rule then leaves an inert slider on the
platform the issue is about: an Android tablet at >=600px gets the
bottom bar and its copy. That is a control that cannot act, which
library-status-indicatoralready settled is worse than none. The samerule is wrong the other way below 600px, where a narrow desktop window
has no hardware keys to fall back on.
So the frontend asks the player. The predicate is named after the
capability, not the platform —
SystemOwnsVolume— which is whatmakes it testable: only
platformOwnsVolumeis behind a build tag, intwo files that declare nothing else, and everything else is decided
against a field a Go test sets either way. That is
mediacontrols'split with
androidpayload.go's reasoning. The frontend's absentbranch is exercised in the component tier with a stubbed binding, on an
ordinary Linux runner.
index.csskeeps its phone rule for the bar, restated as what astylesheet can actually answer: room. Five controls and a slider do not
fit a 360px bar.
Mute is refused too — it is a level of zero by another name, and with no
control rendered it would be the one state on such a platform the user
could not get out of. Persistence round-trips the stored level rather
than recording the synthetic maximum; the alternative, a second query
omitting the column, buys nothing and is a second write path.
SetDuckis untouched, which is the issue's second Finding. Itapplies its attenuation by re-applying the user's level through
setVolumeLocked, so pinning that level leaves the arithmetic exactlyas it was.
#172, re-measured
Measured at 424x439 by flipping
platformOwnsVolumetotruein the!androidfile and rebuilding — the real binding, the real store, thereal component, everything except the tag itself.
29px — the 21px control plus the 8px flex gap a hidden box stops
drawing. #172's "~30px of pure gain", confirmed. The art is 74% larger
and no longer the smallest element on the screen.
It is still the second-smallest, and that is #51's question, which is
what #172's Direction says: "decide whether the art gets a floor …
which is #51's question, and this issue is evidence for it rather than a
competing answer." Closing it as that evidence rather than leaving two
issues asking the same thing.
Verification
Run, and what each was for:
make test(3 build configurations) — the four new Go tests, andTestSetDuckstill passing beside them.make ui-test— 976 tests, 89 files, including the two new ones.make e2e— the whole suite, 220 tests, chromium. Not a formality:the last three sessions each broke something adjacent, and both
existing volume assertions (
bottom-bar.spec.ts:114,phone-shell.spec.ts:238) are what catch an inverted polarity.make lint(3 configurations),make css-check,make bindings-check,make skill-check,tsc --noEmitin bothfrontend/ande2e/.Formalities, but
bindings-checkis not — this adds a bound method.Proving the specs bite.
volume-ownership.test.ts's two tests wererun against the reverted
volume-control.tsand both fail, on thetwo different halves they assert:
The Go tests are all new behaviour and so reproduce trivially;
TestAnUnpinnedPlayerStillChangesItsVolumeexists because without itthe pinned test passes on a player that refuses everything, which is
what a mis-wired field would produce.
The e2e addition asserts the mechanism this tier can see — that the
control's presence follows the player's answer — and cannot fail on the
old build, since the binding did not exist. It is a guard, not a
reproduction, and the file says so.
What I could not verify at the time. No device was attached when this was written. One was afterwards, and three of the four items below are now closed — see the device-verification comment. What remains is the duck, and for a sharper reason: it is unreachable above API 25 by design, so no modern phone can test it.
Left as written, because the reasoning is what justified shipping without a device:
TestPlatformVolumeOwnershipIsDeclaredOncePerPlatformsweeps bothfiles' source instead, which catches deletion and an edited value but
is not a compiler.
TestSystemVolumeStillDuckschecks the arithmetic — attenuate,restore, and a refused
SetVolumenot disturbing the offset — whichis the property, not the integration.
binding answers
falsethere and every existing volume spec passesunchanged.
Screenshots
424x439, before and after, in a comment below.
volume-control asks the player whether there is a volume of ours to control, and renders nothing when there is not. The decision is in the control rather than at either mount point because there are two, and one of them -- the bottom bar's -- lives in index.html, which has no module scope to make it conditional. It could not have been a width, and that is the whole design decision. Every other stand-down rule in this app is keyed on a viewport, because a width is what a browser can answer and what every tier can test. This one is a property of the build: keyed on width, an Android tablet at 600px or more draws the bar's slider over a level the backend has pinned -- a control that cannot act, on exactly the platform the rule exists for, which library-status-indicator settled is worse than none. The same rule is wrong the other way below 600px, where a narrow desktop window has no hardware keys to fall back on. index.css keeps its phone rule, which is now about room and says so. Rendering nothing and hiding the host are both needed and are separate assertions: an empty shadow root is what stops a by-role or positional query finding a button that cannot act, and :host([hidden]) is what stops the element taking a flex item's worth of the transport. The host rule has to be written down, since :host { display: inline-flex } outranks the UA's [hidden]. Measured at 424x439 by flipping the constant and rebuilding: the album art goes 39px to 68px and the transport 172px to 143px -- 29px, being the 21px control plus the 8px gap a hidden box stops drawing. The bar's centring is unaffected, since #23's outer columns are the same min() expression rather than content-sized. volume-ownership.test.ts is the tier that can exercise the Android rendering, on an ordinary Linux runner, because the predicate is a stubbable backend answer. Both of its tests were confirmed to fail on the build before this. Closes #64 Closes #172Now Playing at 424x439, the reference device's viewport. Both from
the running app, seeded and playing; the "after" is the real rendering
with
platformOwnsVolumeflipped true and rebuilt, not a simulatedhide.
The slider and its mute icon are gone from the bottom-left of the
transport, and the art absorbs the 29px — it is
flex: 1 1 auto, whichis why the whole gain lands there rather than being shared out.
Worth reading rather than measuring: the art is now large enough to
read as artwork instead of as a stripe, and the block above the
transport has not moved relative to itself. Three bugs in the last three
sessions were found this way and by nothing else, which is why this is
here.
CI green, run
17357.check— every step success, includingCommit messages,Lint,Test,Bindings are currentandDocumented make targets exist.e2e— success on both engines.E2E — webkitreportscompleted / success, notskipped: that step carriesif: ${{ !cancelled() }}, has silently skipped before, and has beenthe only tier to catch a defect in each of the last two sessions, so
it is checked rather than inferred from the run's conclusion. Read
from
/api/v1/…/actions/runs/17357/jobs, sincegitea_ci job_logs404s on this build.
The sink plays at real timepassed, so the playback specs thetransport work touches were measuring a clock that moves.
Device verification — three of the four gaps closed
A Light Phone III (TLP301, Android 14 / SDK 34, arm64, WebView
Chrome 113 at 424x439 — the reference device) was attached after this PR
was opened, so what the body lists as unverifiable was re-checked rather
than left standing.
f26b44drecords it.1. The whole chain resolves on the device.
Build tag →
platformOwnsVolume→Player.systemVolume→ thegenerated binding, end to end. That is the thing the source sweep only
approximates, and the device is the only tier that compiles the
androidfile at all. (The NDK cross-compileGOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...alsopasses, and is worth running first — it catches a type error but not a
wrong constant.)
2. The control is absent in both mount points, on the real engine.
.bottom-bar volume-controlandnow-playing-view's copy are bothhiddenwith an empty shadow root and a 0x0 box. Transport measures143px on the device — exactly what the desktop-headless "after"
predicted. The art is 75px rather than 68 because this fixture's
.namesblock is one line shorter, not because anything differs.3. Nothing persists a level nobody chose, and this one needed care:
the default 50 surviving proves nothing, since 50 is what a fresh row
holds anyway. So the stored value was set to 37 in the device's own
database, the app relaunched, and a queue change driven to make the row
be rewritten. Reading it back, the WAL has to be pulled with the
db — the main file alone still showed the old
last_track_pathandreads exactly like a write that never happened. With
yj.db-walbesideit:
last_track_pathis the new track, sosaveStateran, andvolumeis still37.4. The duck stays unverified — but now for a stated reason rather
than for want of hardware.
WailsForegroundServicebuilds itsAudioFocusRequestwithoutsetWillPauseWhenDuckedfrom Oreo, so theframework attenuates the stream itself and never delivers
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK. The device's own log says so:flags=0x0is the absentAUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS.minSdkis 21, so the Go-side duck is live code rather than dead —reachable on Android 5.0 to 7.1 and on nothing newer. So #64's "verify
ducking still attenuates and restores" cannot be done on any modern
device, and asking for a better phone will not help; it needs an API
21–25 one. The arithmetic remains covered by
TestSystemVolumeStillDucks, including that a refusedSetVolumedoes not disturb the offset.
Two things found on the way
Neither is caused by this change.
the screen while it does.
YJ_CORE_INDEX_URLis stubbed indev-headless.shand in CI but is real on a device, so the firstmeasurement taken here was of a screen with
job-bandon it and theart at 0px. Worth knowing before someone reports it as #172
getting worse: it is the environment.
appears by a route other than its own, and stays up intercepting
every pointer event. Filed with the reproduction.
The device now has the debug build (
app.yellowjacket.dev, a siblingid — nothing was uninstalled) and a scratch library under
/sdcard/Music/yj.