From b43172a60c8c0b9177c83d2980562076a564800c Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 21 Aug 2026 00:53:44 -0400 Subject: [PATCH] docs(player): record who owns the volume, and what it gave back 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. --- .planning/NOTES.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 64 +++++++++++++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/.planning/NOTES.md b/.planning/NOTES.md index dd1a3b0..6b2c8cd 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -4573,3 +4573,79 @@ fault in it, and it is filed as #172 with the per-element budget. #64 umbrella; folding shuffle and repeat back onto the primary row was considered and rejected — it buys 52px, leaves the art at 91px, and costs a third arrangement of the same five buttons. + +## The volume is not ours on Android, and the predicate could not be a width (measured 2026-08-21) + +#64 asked for the in-app volume control to be absent on Android. Its +first Finding said `volume-control` "already stands down at narrow +widths", which was true of one of its two copies and is why the issue +had been read as nearly done. The bar's copy goes by width; the +full-screen view's copy was deliberately kept, with a comment saying a +slider does belong there. + +**The crux was platform versus width, and three options were on the +issue.** What settled it is that the *backend* half of the same issue — +pin the level at 1.0 — makes a width rule wrong on the platform the +issue is about: an Android tablet at >=600px gets the bottom bar, and +the bar's slider would then move a level that is pinned. 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. + +So the frontend asks the player — `SystemOwnsVolume` — and the answer +is right at every width in both mount points. **The predicate is named +after the capability rather than the platform**, 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. `frontend/test/components/ +volume-ownership.test.ts` stubs the binding and so exercises the +*Android* rendering on an ordinary Linux runner; both of its tests were +confirmed to fail on the build before the change. + +**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: + +| element | before | after | +|---|---|---| +| header | 48 | 48 | +| **album art** | **39** | **68** | +| title / artist / album | 63 | 63 | +| transport (seek + controls + volume) | **172** | **143** | +| — seek bar | 19 | 19 | +| — player-controls | 116 | 116 | +| — volume-control | 21 | **0** | + +29px, which is the 21px control plus the 8px flex gap it stops drawing: +a gap is only painted between boxes, so `:host([hidden])` costs the +transport nothing rather than leaving a hole. That is #172's "~30px of +pure gain" confirmed, and the art is 74% larger. It is still the +second-smallest thing on the screen, which is #51's evidence. + +Three smaller things worth keeping. + +**`:host([hidden])` has to be written down.** The UA's `[hidden]` +rule is `display: none`, but `volume-control`'s own `:host` sets +`display: inline-flex` and outranks it — so setting `hidden` alone +hides nothing. Same family as the nested-`#queue-button` specificity +trap from the session before. + +**Rendering `nothing` and hiding the host are two different +assertions**, and the component test makes both: an empty shadow root +is what stops a by-role or positional query finding a button that +cannot act, and `hidden` is what stops the host occupying space. Either +alone passes on a build that gets the other wrong. + +**The bar's centring survives the control going away.** #23's outer +columns are the same `min()` expression rather than content-sized, so +at 900px with the volume gone the bar's centre, `audio-player`'s centre +and `player-controls`' centre are all 450 — checked, because "the +transport is centred with a slider bolted to one side" is the fault +that rule exists for and removing the slider is the obvious way to +re-break it. + +**What no tier here can check**: the constant itself, and ducking +against a real audio-focus change. The first is a source sweep +(`TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`), the second is +`TestSystemVolumeStillDucks` against the arithmetic. Neither is a +device, and no device was attached. diff --git a/CLAUDE.md b/CLAUDE.md index af2da7f..f54c6a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -646,7 +646,11 @@ rather than renaming them. level rather than writing through to the volume, so it cannot accumulate and nothing persists or emits a level the user did not choose — and it only ever fires below API 26, where the framework - does not already duck the app itself. + does not already duck the app itself. On that platform "the user's + level" is a constant, since #64 pins it at maximum and refuses every + way to move it; the duck is the one thing that still may, and it + works unchanged because it was always an offset applied *to* that + level rather than a write of it. - `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows). - `explore` — Catalog search and browse over `explore_index`. See below. Its **shelves** (`shelves.go`) are the page Explore shows before @@ -1725,12 +1729,58 @@ where the zero value has to be the intended answer, so an existing `config.toml` with no key gets the new default without a migration. Inline, the icon becomes the mute toggle and is named after that action rather than after the state, because with the slider beside it there is -nothing left to disclose. It stands down below 600px whatever the -setting says — that is about the platform rather than preference, and -is why `mediacontrols`' Android handler implements no volume callback. -(Only the *bar's* copy: `now-playing-view` renders one and it is -visible on a phone. #64 asks for it to be gone on Android outright, -which is a platform question the frontend cannot currently ask.) +nothing left to disclose. The bar's copy stands down below 600px, which +is about *room*: five controls and a slider do not fit a 360px bar, and +`now-playing-view` is where seeking and volume go on a phone. + +**Whether there is a volume to control at all is a different question, +and it is asked of the player** (#64). On Android the hardware keys are +the volume control and the framework mixes our stream against the +device level, so `player`'s own level is pinned at maximum, `SetVolume` +/ `ChangeVolume` / `MuteToggle` are refused, and `volume-control` +renders `nothing` — in both of its mount points, at every width. +`mediacontrols`' Android handler implementing no volume callback is the +same fact one layer down. + +Five things about it are load-bearing. + +**It could not be a width, and that is not a preference.** 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 bottom bar's slider over a pinned level — a control that +cannot act, on exactly the platform the rule exists for, which +`library-status-indicator` already settled is worse than none. The +same rule is wrong in the other direction below 600px, where a narrow +desktop window has no hardware keys to fall back on. + +**The predicate is named after the capability, not the platform.** +`SystemOwnsVolume` is what the frontend asks; `platformOwnsVolume` is +the one build-tagged constant behind it, in two files that declare +nothing else. That is `mediacontrols`' split with +`androidpayload.go`'s reasoning: a tagged file is compiled by nothing +`make lint` or `make test` runs, so everything decidable off a phone is +decided against `Player.systemVolume`, a field a test sets either way. +The frontend's absent branch is therefore testable in the component +tier with a stubbed binding, and the constant itself is covered by a +source sweep rather than by a device. + +**Mute goes with it, because it is a level of zero by another name** — +and because with no control rendered it is the one state on such a +platform the user could not get out of. + +**Nothing persists a level nobody chose.** The maximum the player runs +at is synthetic, so `restoreStateLocked` *remembers* the stored volume +instead of applying it and `saveState` writes that same value back. +The alternative — a second query that omits the column — buys nothing +and is a second write path to keep in step. + +**And ducking is untouched, which is what makes the pin safe.** +`SetDuck` 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 move +the output on such a platform, and it is the one volume-shaped path +that is not refused. **And below 600px that bar carries three controls, not five** (#59). Shuffle, repeat and the queue button leave it; what is left is art,