Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f26b44db08 | ||
|
|
b43172a60c | ||
|
|
2be6fb3066 | ||
|
|
867ced8c81 | ||
|
|
fd71ef53c5 | ||
|
|
e3b64f9255 | ||
|
|
c7e5a4f086 | ||
|
|
f65822c4b2 | ||
|
|
32d4dc2c82 | ||
|
|
218e4f5e99 | ||
|
|
56a5ff99fe |
@@ -4487,3 +4487,223 @@ is not orphaned" and "a docked column is not in the stack" are both
|
||||
vacuously true of a build that pushes no entry at all. Reverting the
|
||||
source and re-running is what established which were which, and the
|
||||
file says so in its header rather than implying all nine reproduce.
|
||||
|
||||
## The phone's transport, and three things that only a screenshot or a stash could see (measured 2026-08-21)
|
||||
|
||||
#59 and #56 were done as one PR — argued on #73 first — because they are
|
||||
the same row of pixels: one removes controls from the phone's bar and
|
||||
the other enlarges what is left, and both are one property on
|
||||
`player-controls`. Measured at 424x439 before:
|
||||
|
||||
| control | before | after |
|
||||
|---|---|---|
|
||||
| bar: shuffle / prev / play / next / repeat | 33x21 each | prev/next 44, play 56, shuffle+repeat moved |
|
||||
| bar: favourite | **18x14** | 44x44 |
|
||||
| bar: queue button | 33x29 | gone (#59) |
|
||||
| Now Playing: all five | 33x21 each | 44, play 64 |
|
||||
| desktop bar: all five | 33x21 | **33x21** |
|
||||
|
||||
Four things cost a cycle each and are worth keeping.
|
||||
|
||||
**A `<button>` does not inherit its font from its parent.** The UA
|
||||
stylesheet gives it one, so `font-size: inherit` on a button is a
|
||||
*change*, not a no-op: it took every desktop control from 33x21 to
|
||||
36x24 by moving them from 13.3px to the shell's 16px. Nothing failed.
|
||||
The only way it surfaced was measuring the baseline by stashing the file
|
||||
and re-running.
|
||||
|
||||
**And the pixel it was first pinned with was the wrong assertion.** The
|
||||
spec asserted the literal `'33x21'`, measured in Chromium — and WebKit
|
||||
draws the same button **36x24**, so it failed in CI on a build where
|
||||
nothing was wrong. A button's box comes from the UA stylesheet when the
|
||||
author sets nothing, and what each UA sets is its own business. What
|
||||
must not happen is that *we* set something, so that is what it asserts
|
||||
now: `min-width` and `min-height` compute to `0px`, and the font-size
|
||||
still equals that of a bare `<button>` probed in the same page. That
|
||||
form catches the `font-size: inherit` regression in either engine —
|
||||
checked by re-introducing it — and it is the same "assert the
|
||||
mechanism" move `queue-as-a-screen.spec.ts` makes about containment.
|
||||
|
||||
It is also the second time in two sessions that **CI's WebKit was the
|
||||
only tier that could see something**, which is the argument for checking
|
||||
that step ran rather than trusting the run's conclusion.
|
||||
|
||||
**A rule at the bottom of `index.css` still loses to a nested rule
|
||||
above it.** The phone block is last on purpose because a media query
|
||||
adds no specificity — but `#queue-button` is written *nested* inside
|
||||
`.bottom-bar`, so it builds to a descendant selector one class more
|
||||
specific, and a bare `#queue-button { display: none }` in the phone
|
||||
block did nothing at all. Silently: the button simply stayed. Nesting
|
||||
adds specificity the source does not show.
|
||||
|
||||
**Removing a control moved the question of how you reach what is left,
|
||||
and ten specs were quietly asserting the old answer.** Hiding the bar's
|
||||
queue button failed ten tests in four files about the back stack and
|
||||
about layout, every one of which opened the queue by clicking
|
||||
`#queue-button`. `openTheQueue` in `e2e/support/fixtures.ts` is the
|
||||
route *this viewport* offers, and the fix was to stop hard-coding one.
|
||||
|
||||
**And the route it takes did not exist in the state that matters.**
|
||||
`now-playing` renders two branches, and the no-track one had no
|
||||
`.expand` button — so with nothing loaded there was no way to Now
|
||||
Playing, and once the queue button left the bar the queue was
|
||||
unreachable outright. The queue is persisted across restarts, so this
|
||||
is a state the app launches into, not a corner. It first appeared as a
|
||||
*flake* (#168: the long-lived e2e app meant whether a track was loaded
|
||||
depended on which spec ran first), which is worth remembering — a leak
|
||||
made a deterministic bug look like a race.
|
||||
|
||||
## Now Playing does not fit a 439px screen, and #56 makes that visible (measured 2026-08-21)
|
||||
|
||||
Two separate things, and only the first is a defect.
|
||||
|
||||
**The art overflowed its own box and drew over the header and the
|
||||
title.** It is `width: min(100%, 60vh); aspect-ratio: 1`, so its height
|
||||
is derived from its width and bounded by nothing — 60vh bounds the
|
||||
*viewport*, not the room left over, and those differ by all the chrome
|
||||
above and below. `max-height: 100%` is the fix and shipped with #56.
|
||||
Pre-existing: screenshotted on `main`. **Found by reading a screenshot,
|
||||
which is the only tier that can see it** — nothing fails, the shell does
|
||||
not overflow, and every control is still hittable.
|
||||
|
||||
**With that fixed, the art is a 39px sliver**, because the transport is
|
||||
now 172px of a 439px screen. That is a consequence of #56 rather than a
|
||||
fault in it, and it is filed as #172 with the per-element budget. #64
|
||||
(no in-app volume on Android) is ~30px of pure gain there and #51 is the
|
||||
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.
|
||||
|
||||
### The device answered three of the four (measured 2026-08-21, TLP301 / Android 14 / SDK 34 / arm64, Chrome 113 at 424x439)
|
||||
|
||||
A Light Phone III was attached after the PR was opened, so what that PR
|
||||
listed as unverifiable was re-checked rather than left as a caveat.
|
||||
|
||||
**The whole chain resolves on the device.** `__yj.call("player.Player.
|
||||
SystemOwnsVolume", [])` answers `true` — build tag, `platformOwnsVolume`,
|
||||
`Player.systemVolume` and the generated binding, end to end. That is the
|
||||
one thing the source sweep only approximates, and it took a real arm64
|
||||
device because nothing else here compiles the `android` file at all.
|
||||
(`GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...` with
|
||||
the NDK's clang compiles it in ~40 s and is worth running first; it
|
||||
catches a type error but not a wrong constant.)
|
||||
|
||||
**The control is absent in both mount points**, on the real engine:
|
||||
`.bottom-bar volume-control` is `hidden` with an empty shadow root, and
|
||||
so is `now-playing-view`'s. Measured on the device, transport **143px**,
|
||||
which is the figure the desktop-headless "after" predicted exactly. The
|
||||
art is 75px there rather than 68 because the fixture's `.names` block is
|
||||
one line shorter, not because anything differs.
|
||||
|
||||
**Nothing persists a level nobody chose, and this is the measurement
|
||||
that took some care.** The default (50) surviving proves nothing, since
|
||||
50 is also what a fresh row holds. So: force-stop, pull `yj.db`, set
|
||||
`player_state.volume = 37`, push it back through
|
||||
`run-as … dd` (a `cp` from `/sdcard` is refused — the app sandbox
|
||||
cannot read it), relaunch, and drive a queue change to make the row be
|
||||
rewritten. Reading it back **the WAL has to be pulled with it** — the
|
||||
main file still showed the old `last_track_path` and reads as 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**.
|
||||
|
||||
**The duck cannot be verified on this device, and now for a stated
|
||||
reason rather than for want of hardware.** `WailsForegroundService`
|
||||
builds its `AudioFocusRequest` without `setWillPauseWhenDucked` on
|
||||
API >= 26, so the framework attenuates the stream itself and never
|
||||
delivers `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. The device's own log
|
||||
says so: `MediaFocusControl: requestAudioFocus() … AA=USAGE_MEDIA/
|
||||
CONTENT_TYPE_MUSIC … req=1 flags=0x0` — no
|
||||
`AUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS`. **`minSdk` is 21**, so the
|
||||
Go-side duck is not dead code; it is reachable on Android 5.0 to 7.1
|
||||
and on nothing newer. Any future "verify ducking on a device" needs one
|
||||
of those, and asking for a modern phone will not do it.
|
||||
|
||||
Two smaller things from the same session.
|
||||
|
||||
**A fresh install downloads the real catalog, and it 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 was of a screen with `job-band` on it and the art at
|
||||
**0px**. That is not a defect and not #172 — it is the environment.
|
||||
`explore.Service.StopIndexBuild` and a relaunch is the clean state.
|
||||
|
||||
**The first-run wizard does not dismiss when a library appears by a
|
||||
route other than its own** (#175) — it was still up, full-screen and
|
||||
intercepting pointer events, after `AddLibrary` succeeded through the
|
||||
binding, and was gone after a relaunch. Filed.
|
||||
|
||||
@@ -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,9 +1729,113 @@ 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.
|
||||
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 plus, once, a real arm64 device answering `true` — which
|
||||
is the only tier that compiles the `android` file at all.
|
||||
|
||||
**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.
|
||||
|
||||
One thing to know before anyone offers to test it on a phone: **the
|
||||
duck is unreachable above API 25.** `WailsForegroundService` builds its
|
||||
`AudioFocusRequest` without `setWillPauseWhenDucked` from Oreo, so the
|
||||
framework attenuates us itself and never sends
|
||||
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` — a device confirms it by logging
|
||||
`requestAudioFocus() … flags=0x0`. `minSdk` is 21, so this is live code
|
||||
rather than dead, on Android 5.0 to 7.1 and nowhere else.
|
||||
|
||||
**And below 600px that bar carries three controls, not five** (#59).
|
||||
Shuffle, repeat and the queue button leave it; what is left is art,
|
||||
title/artist, favourite, and prev/play/next. `player-controls` is one
|
||||
component in two places and **the context is a property rather than a
|
||||
media query**, which is the exception to the rule two paragraphs down:
|
||||
on a phone the bar wants three controls and `now-playing-view` wants
|
||||
five, larger still, *at the same viewport* — so the host states the
|
||||
context and the viewport states the size band, and neither alone can
|
||||
express it. Sizes come from `--yj-control-*` custom properties set per
|
||||
context; play/pause alone goes above the 44px floor, because a row of
|
||||
identical squares says every action is equally likely and that is not
|
||||
true of play. Measured before #56: every one of them was **33×21px**,
|
||||
and the mini bar's favourite was **18×14**, the smallest control in the
|
||||
app.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**The phone draws three buttons rather than hiding two**, from
|
||||
`matchMedia` — `job-band`'s pattern, and the rule that a decision about
|
||||
whether an element *exists* is not a stylesheet's to make. A
|
||||
`display: none` control is still in the shadow root and still something
|
||||
a positional query finds, so "the phone has three controls" would have
|
||||
been true of the pixels and false of the element.
|
||||
|
||||
**Removing a control is only allowed because it is still reachable.**
|
||||
Plan 018's matrix promises no action is unreachable at any supported
|
||||
size, and all three are on `now-playing-view`, one tap away through the
|
||||
mini player's art. That promise is what `phone-transport.spec.ts`
|
||||
asserts — it walks the route — rather than counting buttons.
|
||||
|
||||
**So the route to Now Playing must not depend on what is playing**, and
|
||||
it did. `now-playing` renders two branches and the no-track one had no
|
||||
`.expand` button on its placeholder, so with nothing loaded there was
|
||||
no way to the full-screen view — which, once the queue button left the
|
||||
bar, made the *queue* unreachable. The queue is persisted across
|
||||
restarts, so "tracks queued, nothing playing" is a state the app
|
||||
launches into.
|
||||
|
||||
**The desktop bar is untouched and a spec says so with a literal.**
|
||||
Both issues are `Platform/Android`. The trap is that a `<button>` does
|
||||
not inherit its font from its parent — the UA stylesheet gives it one —
|
||||
so a generic `font-size: inherit` is not the no-op it reads as: it took
|
||||
every desktop button from 33×21 to 36×24, silently. The sizes are
|
||||
asserted as `'33x21'` rather than as a range, because the regression
|
||||
was three pixels.
|
||||
|
||||
**900 is the worst desktop width, not the 800×600 minimum.** The
|
||||
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
||||
|
||||
@@ -71,6 +71,19 @@ type Player struct {
|
||||
// not something the user chose.
|
||||
duckAmount float64
|
||||
|
||||
// systemVolume is what SystemOwnsVolume answers: the platform's own
|
||||
// control is the only one, so ours neither acts nor persists. It is
|
||||
// a field rather than the build constant read directly so that a
|
||||
// test can exercise both sides on any machine. See systemvolume.go.
|
||||
systemVolume bool
|
||||
|
||||
// storedVolume and storedMuted hold the persisted level as it was
|
||||
// found at restore, for a platform whose volume we do not own: the
|
||||
// maximum we then run at is not a level the user chose, so saveState
|
||||
// writes back what it read rather than overwriting it.
|
||||
storedVolume UserVolume
|
||||
storedMuted bool
|
||||
|
||||
// trackLengthMs holds the authoritative track duration in
|
||||
// milliseconds, sourced from the database (which uses the
|
||||
// custom header parser). The go-mp3 decoder's Len() can be
|
||||
@@ -156,6 +169,8 @@ func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
|
||||
logger: logger,
|
||||
db: db,
|
||||
state: Stopped,
|
||||
systemVolume: platformOwnsVolume,
|
||||
storedVolume: DefaultUserVol,
|
||||
baseStreamer: generators.Silence(-1),
|
||||
format: beep.Format{
|
||||
SampleRate: speakerSampleRate,
|
||||
@@ -875,6 +890,10 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.systemVolume {
|
||||
return
|
||||
}
|
||||
|
||||
p.setVolumeLocked(desiredVolume)
|
||||
p.emitVolumeChanged()
|
||||
p.saveState()
|
||||
@@ -923,6 +942,10 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.systemVolume {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
|
||||
p.emitVolumeChanged()
|
||||
p.saveState()
|
||||
@@ -953,6 +976,14 @@ func (p *Player) MuteToggle() error {
|
||||
return errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
// Mute is a level of zero by another name, so it goes with the rest
|
||||
// of the volume where the system owns it -- and it would be the one
|
||||
// state on such a platform the user could not get out of, since with
|
||||
// no control rendered there is nothing left to un-mute with.
|
||||
if p.systemVolume {
|
||||
return nil
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
p.volume.Silent = !p.volume.Silent
|
||||
speaker.Unlock()
|
||||
@@ -1403,7 +1434,15 @@ func (p *Player) saveState() {
|
||||
volume := int64(DefaultUserVol)
|
||||
muted := false
|
||||
|
||||
if p.volume != nil {
|
||||
switch {
|
||||
case p.systemVolume:
|
||||
// The maximum this platform runs at is not a level anybody
|
||||
// chose, so it is not one to remember. Writing back what
|
||||
// restore found keeps the row a description of the user's
|
||||
// setting without needing a second query that omits the column.
|
||||
volume = int64(p.storedVolume)
|
||||
muted = p.storedMuted
|
||||
case p.volume != nil:
|
||||
volume = int64(p.getUserVolume())
|
||||
muted = p.volume.Silent
|
||||
}
|
||||
@@ -1487,11 +1526,20 @@ func (p *Player) restoreStateLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
vol := clampVolume(UserVolume(state.Volume))
|
||||
p.setVolumeLocked(vol)
|
||||
if p.systemVolume {
|
||||
// Remembered, not applied: the device's keys are the volume
|
||||
// control here, so the player runs wide open and hands the
|
||||
// stored level back untouched at the next save.
|
||||
p.storedVolume = clampVolume(UserVolume(state.Volume))
|
||||
p.storedMuted = state.Muted
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
} else {
|
||||
vol := clampVolume(UserVolume(state.Volume))
|
||||
p.setVolumeLocked(vol)
|
||||
|
||||
if state.Muted {
|
||||
p.volume.Silent = true
|
||||
if state.Muted {
|
||||
p.volume.Silent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Restore last track if the file still exists.
|
||||
@@ -1531,8 +1579,9 @@ func (p *Player) restoreStateLocked() {
|
||||
}
|
||||
|
||||
p.logger.Info("Player state restored",
|
||||
"volume", vol,
|
||||
"muted", state.Muted,
|
||||
"volume", p.getUserVolume(),
|
||||
"muted", p.volume.Silent,
|
||||
"systemVolume", p.systemVolume,
|
||||
"trackPath", state.LastTrackPath,
|
||||
"positionSeconds", state.LastPositionSeconds,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package player
|
||||
|
||||
// Who owns the volume, and what follows when it is not us.
|
||||
//
|
||||
// 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 is a slider that moves something the user
|
||||
// already moved (#64). Where that is true the player's own level sits
|
||||
// at maximum, nothing changes it, and nothing persists it.
|
||||
//
|
||||
// **The predicate is named after the capability, not the platform.**
|
||||
// The frontend asks "is there a volume for me to control", which is a
|
||||
// question about this build; asking "is this a phone" instead would
|
||||
// key the answer to a viewport, and an Android tablet at 600px or more
|
||||
// would then draw the bottom bar's slider over a level pinned at
|
||||
// maximum -- a control that cannot act, which is the thing
|
||||
// `library-status-indicator` already settled is worse than none.
|
||||
//
|
||||
// **Only `platformOwnsVolume` is behind a build tag**, in two files
|
||||
// that declare nothing else. A tagged file is compiled by nothing
|
||||
// `make lint` or `make test` runs and is untestable off a phone, which
|
||||
// is the reasoning `mediacontrols/androidpayload.go` states for
|
||||
// keeping its contract out of one -- so everything decidable here is
|
||||
// decided against `Player.systemVolume`, a field a test sets either
|
||||
// way, and the tag decides only what that field starts as.
|
||||
//
|
||||
// The one thing this must not disturb is ducking. `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: an OS asking us to get out of
|
||||
// the way of a navigation prompt is not the user setting a volume, and
|
||||
// it is the only thing that may move the output on such a platform.
|
||||
|
||||
// SystemOwnsVolume reports whether the platform's own control is the
|
||||
// only volume control there is, so this app neither offers one nor
|
||||
// remembers a level.
|
||||
//
|
||||
// It is bound: the frontend renders no `<volume-control>` when it is
|
||||
// true, at any width.
|
||||
func (p *Player) SystemOwnsVolume() bool {
|
||||
return p.systemVolume
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build android
|
||||
|
||||
package player
|
||||
|
||||
// platformOwnsVolume is true on Android: volume is the device's, set
|
||||
// with the hardware keys, and `mediacontrols`' Android handler
|
||||
// implements no volume callback for the same reason.
|
||||
//
|
||||
// See systemvolume.go for why this constant is the whole of what a
|
||||
// build tag decides here.
|
||||
const platformOwnsVolume = true
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !android
|
||||
|
||||
package player
|
||||
|
||||
// platformOwnsVolume is false everywhere but Android: a desktop mixer
|
||||
// is per-application, so our level is the one the user reaches for.
|
||||
//
|
||||
// See systemvolume.go for why this constant is the whole of what a
|
||||
// build tag decides here.
|
||||
const platformOwnsVolume = false
|
||||
@@ -0,0 +1,215 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gopxl/beep/v2/effects"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// pinnedPlayer is a player on a platform whose volume belongs to the
|
||||
// device. The field is set rather than the build constant read,
|
||||
// because the constant is true on exactly one platform and no tier
|
||||
// here runs on it -- see systemvolume.go.
|
||||
func pinnedPlayer(t *testing.T, db *database.DB) *Player {
|
||||
t.Helper()
|
||||
|
||||
p := NewPlayer(slog.Default(), db)
|
||||
p.systemVolume = true
|
||||
p.volume = &effects.Volume{Base: 2}
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// TestSystemVolumeRefusesEveryWayToChangeTheLevel is the first half of
|
||||
// #64: where the device owns the volume, ours sits at maximum and none
|
||||
// of the three routes to a level moves it. Mute is in that list
|
||||
// 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 there would
|
||||
// be nothing to get out of.
|
||||
func TestSystemVolumeRefusesEveryWayToChangeTheLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := pinnedPlayer(t, nil)
|
||||
|
||||
if !p.SystemOwnsVolume() {
|
||||
t.Fatal("SystemOwnsVolume() = false on a pinned player")
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("starting volume = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
p.SetVolume(20)
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("volume after SetVolume(20) = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if err := p.ChangeVolume(-30); err != nil {
|
||||
t.Fatalf("ChangeVolume: %v", err)
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("volume after ChangeVolume(-30) = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if err := p.MuteToggle(); err != nil {
|
||||
t.Fatalf("MuteToggle: %v", err)
|
||||
}
|
||||
|
||||
if p.volume.Silent {
|
||||
t.Error("MuteToggle silenced a player whose volume the system owns")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnUnpinnedPlayerStillChangesItsVolume is the other side of the
|
||||
// same switch. Without it the test above passes on a player that
|
||||
// refuses everything, which is what a mis-wired field would produce.
|
||||
func TestAnUnpinnedPlayerStillChangesItsVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
p.volume = &effects.Volume{Base: 2}
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
|
||||
if p.SystemOwnsVolume() {
|
||||
t.Fatal("SystemOwnsVolume() = true off Android")
|
||||
}
|
||||
|
||||
p.SetVolume(20)
|
||||
|
||||
if got := p.getUserVolume(); got != 20 {
|
||||
t.Errorf("volume after SetVolume(20) = %d, want 20", got)
|
||||
}
|
||||
|
||||
if err := p.MuteToggle(); err != nil {
|
||||
t.Fatalf("MuteToggle: %v", err)
|
||||
}
|
||||
|
||||
if !p.volume.Silent {
|
||||
t.Error("MuteToggle did not silence an ordinary player")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemVolumeStillDucks is the issue's second Finding, made a
|
||||
// test: pinning the user's level must leave the OS's attenuation
|
||||
// working, because a duck is not a volume the user chose and is the
|
||||
// only thing that may move the output on such a platform.
|
||||
func TestSystemVolumeStillDucks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := pinnedPlayer(t, nil)
|
||||
open := p.volume.Volume
|
||||
|
||||
p.SetDuck(true)
|
||||
|
||||
if p.volume.Volume >= open {
|
||||
t.Errorf(
|
||||
"ducked output = %v, want less than %v", p.volume.Volume, open,
|
||||
)
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("user volume while ducked = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
// A refused SetVolume must not disturb the offset either: it
|
||||
// returns before setVolumeLocked, which is what re-applies it.
|
||||
ducked := p.volume.Volume
|
||||
|
||||
p.SetVolume(10)
|
||||
|
||||
if p.volume.Volume != ducked {
|
||||
t.Errorf(
|
||||
"output after a refused SetVolume = %v, want %v",
|
||||
p.volume.Volume, ducked,
|
||||
)
|
||||
}
|
||||
|
||||
p.SetDuck(false)
|
||||
|
||||
if p.volume.Volume != open {
|
||||
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, open)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemVolumeWritesBackTheLevelItFound is the rest of the
|
||||
// Direction: "make sure nothing writes a persisted volume from that
|
||||
// platform". The maximum the player runs at is synthetic, so saving
|
||||
// must not record it over whatever the row already said.
|
||||
func TestSystemVolumeWritesBackTheLevelItFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// A level set by some earlier, unpinned session.
|
||||
writer := NewPlayer(slog.Default(), db)
|
||||
writer.volume = &effects.Volume{Base: 2}
|
||||
writer.setVolumeLocked(30)
|
||||
writer.SaveState()
|
||||
|
||||
p := pinnedPlayer(t, db)
|
||||
p.RestoreState()
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("restored volume = %d, want %d (the level is pinned)", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if p.volume.Silent {
|
||||
t.Error("restore muted a player whose volume the system owns")
|
||||
}
|
||||
|
||||
p.SaveState()
|
||||
|
||||
state, err := db.Queries.GetPlayerState(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlayerState: %v", err)
|
||||
}
|
||||
|
||||
if state.Volume != 30 {
|
||||
t.Errorf("persisted volume = %d, want 30 (untouched)", state.Volume)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps the
|
||||
// source, because the pair of tagged files is the one thing here no
|
||||
// tier compiles both halves of: `make lint` and `make test` build the
|
||||
// `!android` side only, so a deleted or edited android file fails
|
||||
// nothing until somebody has a phone in their hand.
|
||||
func TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[string]string{
|
||||
"systemvolume_other.go": "const platformOwnsVolume = false",
|
||||
"systemvolume_android.go": "const platformOwnsVolume = true",
|
||||
}
|
||||
|
||||
tags := map[string]string{
|
||||
"systemvolume_other.go": "//go:build !android",
|
||||
"systemvolume_android.go": "//go:build android",
|
||||
}
|
||||
|
||||
for name, decl := range want {
|
||||
src, err := os.ReadFile(filepath.Join(".", name))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(src), decl) {
|
||||
t.Errorf("%s does not declare %q", name, decl)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(src), tags[name]) {
|
||||
t.Errorf("%s does not carry %q", name, tags[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,25 @@ test.describe('the shell on a phone', () => {
|
||||
// are here, and they are the *same* components -- this view
|
||||
// composes the transport rather than reimplementing it.
|
||||
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
|
||||
|
||||
// Volume is here **because the player says there is one** (#64),
|
||||
// not because this is a phone. This tier is the platform that owns
|
||||
// its own volume, so what it can assert is that the control's
|
||||
// presence follows that answer -- an inverted polarity in
|
||||
// `volume-style-store` fails here and in `bottom-bar.spec.ts`, and
|
||||
// the *absent* branch is checked in the component tier, where the
|
||||
// binding can be stubbed. Nothing here can reach the Android side.
|
||||
const systemOwns = await app.evaluate(
|
||||
async () =>
|
||||
(await window.__yjEvents.call(
|
||||
'player.Player.SystemOwnsVolume',
|
||||
[],
|
||||
5_000,
|
||||
)) as boolean,
|
||||
);
|
||||
|
||||
expect(systemOwns, 'this platform should own its own volume').toBe(false);
|
||||
|
||||
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
|
||||
|
||||
// Back goes where the user came from, through the nav stack.
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The phone's transport (#59, #56).
|
||||
*
|
||||
* #56 reports that "the playback controls are the most important thing
|
||||
* in the mobile app and they are tiny". Measured at the reference
|
||||
* device's 424x439 before this, every one of them was **33x21px**, and
|
||||
* the favourite beside them — which #59 keeps on the bar — was
|
||||
* **18x14px**, the smallest control in the app.
|
||||
*
|
||||
* #59 is what makes the sizes affordable: five controls plus a queue
|
||||
* button at 44px does not fit 424 CSS px, so the bar carries three and
|
||||
* the rest are on the full-screen view.
|
||||
*
|
||||
* **The assertion that matters is not the pixel count.** Plan 018's
|
||||
* matrix promises that *no action is ever unreachable at any supported
|
||||
* size*, and #59 removes three controls from the phone's bar — so the
|
||||
* first thing this file checks is that all three are still reachable,
|
||||
* by walking the route a user would. A spec that only measured the
|
||||
* survivors would be green on a build that had made shuffle
|
||||
* unreachable, which is the failure mode this pair of issues is one
|
||||
* mistake away from.
|
||||
*/
|
||||
type Page = import('@playwright/test').Page;
|
||||
|
||||
/** The reference device's real viewport. */
|
||||
const DEVICE = { width: 424, height: 439 };
|
||||
const PHONE = { width: 390, height: 780 };
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
|
||||
/**
|
||||
* The touch-target floor. 44px is what #56's Findings name and what
|
||||
* #55's queue header was sized to, so the app has one number.
|
||||
*/
|
||||
const TARGET = 44;
|
||||
|
||||
/** The play button is named for its action, not its identity. */
|
||||
const PLAY_PAUSE = /^(Play|Pause)$/;
|
||||
|
||||
const barControls = (page: Page) =>
|
||||
page.locator('audio-player player-controls');
|
||||
|
||||
/**
|
||||
* `name` may be a regex, and for play/pause it must be: that button is
|
||||
* named for the *action*, so it is "Pause" while a track runs and
|
||||
* "Play" when it stops. An exact 'Play' made these tests wait out a
|
||||
* fixture track (11.1s each, passing by luck) and would have failed
|
||||
* outright against `LONG_TRACK`. A test about a control's size does not
|
||||
* care what the transport is doing.
|
||||
*/
|
||||
async function sizeOf(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
): Promise<[number, number]> {
|
||||
const box = await page
|
||||
.getByRole('button', { name, exact: typeof name === 'string' })
|
||||
.boundingBox();
|
||||
|
||||
expect(box, `no button named ${name}`).not.toBeNull();
|
||||
|
||||
return [box!.width, box!.height];
|
||||
}
|
||||
|
||||
/** Put something in the queue, so the transport has a track to act on. */
|
||||
async function stageATrack(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const tracks = (await window.__yjEvents.call(
|
||||
'library.Library.GetTracks',
|
||||
[0],
|
||||
10_000,
|
||||
)) as { FilePath: string }[];
|
||||
|
||||
await window.__yjEvents.call(
|
||||
'queue.Queue.SetQueue',
|
||||
[tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('the phone bar carries three controls', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(DEVICE);
|
||||
await stageATrack(app);
|
||||
});
|
||||
|
||||
test('drops shuffle, repeat and the queue from the bar', async ({ app }) => {
|
||||
const bar = barControls(app);
|
||||
|
||||
await expect(bar.getByRole('button', { name: 'Previous track' })).toBeVisible();
|
||||
await expect(bar.getByRole('button', { name: 'Next track' })).toBeVisible();
|
||||
|
||||
// Not in the bar's own subtree. Asserted against the bar rather
|
||||
// than the page, because the whole point is that they moved rather
|
||||
// than went away -- a page-wide `not.toBeVisible()` would fail the
|
||||
// moment Now Playing is open and would be asserting the wrong
|
||||
// thing besides.
|
||||
await expect(bar.getByRole('button', { name: 'Shuffle' })).toHaveCount(0);
|
||||
await expect(bar.getByRole('button', { name: /^Repeat/ })).toHaveCount(0);
|
||||
await expect(app.locator('#queue-button')).toBeHidden();
|
||||
});
|
||||
|
||||
/**
|
||||
* The promise, walked. Every control #59 takes off the bar is
|
||||
* reachable from the mini player's art in one tap.
|
||||
*/
|
||||
test('leaves every removed control reachable from Now Playing', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('open-now-playing').click();
|
||||
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'now-playing',
|
||||
);
|
||||
|
||||
await expect(app.getByRole('button', { name: 'Shuffle' })).toBeVisible();
|
||||
await expect(app.getByRole('button', { name: /^Repeat/ })).toBeVisible();
|
||||
await expect(app.getByRole('button', { name: 'Show the queue' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('sizes what is left for a thumb', async ({ app }) => {
|
||||
for (const name of ['Previous track', 'Next track']) {
|
||||
const [w, h] = await sizeOf(app, name);
|
||||
|
||||
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
|
||||
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
|
||||
}
|
||||
|
||||
// Play is deliberately bigger than its neighbours: a row of
|
||||
// identical squares says every action is equally likely, which is
|
||||
// not true of play.
|
||||
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
|
||||
const [nw] = await sizeOf(app, 'Next track');
|
||||
|
||||
expect(ph).toBeGreaterThanOrEqual(TARGET);
|
||||
expect(pw).toBeGreaterThan(nw);
|
||||
});
|
||||
|
||||
/**
|
||||
* The favourite was 18x14 and is one of the three controls #59
|
||||
* keeps, so it is part of this issue rather than a nicety.
|
||||
*/
|
||||
test('sizes the favourite, which was the smallest control in the app', async ({
|
||||
app,
|
||||
}) => {
|
||||
const fav = app
|
||||
.locator('now-playing')
|
||||
.getByRole('button', { name: /Favorites$/ });
|
||||
|
||||
const box = await fav.boundingBox();
|
||||
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThanOrEqual(TARGET);
|
||||
expect(box!.height).toBeGreaterThanOrEqual(TARGET);
|
||||
});
|
||||
|
||||
/**
|
||||
* **The route to the queue must not depend on what is playing.**
|
||||
*
|
||||
* `now-playing` renders two branches, and the no-track one had no
|
||||
* `.expand` button on its placeholder — so with nothing loaded there
|
||||
* was no way to Now Playing, and once #59 takes the queue button off
|
||||
* the bar that makes the *queue* unreachable. The queue is persisted
|
||||
* across restarts, so "tracks queued, nothing playing" is a state the
|
||||
* app launches into.
|
||||
*
|
||||
* This is asserted with the queue explicitly emptied rather than by
|
||||
* relying on the app not having played anything: `make e2e` runs one
|
||||
* long-lived app across every spec file (#168), so "no track loaded"
|
||||
* is otherwise whatever the file before this one left behind — which
|
||||
* is how the underlying fault first showed up as a flake in a spec
|
||||
* about something else.
|
||||
*/
|
||||
test('reaches the queue with nothing playing', async ({ app }) => {
|
||||
await app.evaluate(async () => {
|
||||
await window.__yjEvents.call('queue.Queue.Clear', [], 10_000);
|
||||
});
|
||||
|
||||
await expect(app.getByTestId('open-now-playing')).toBeVisible();
|
||||
|
||||
await app.getByTestId('open-now-playing').click();
|
||||
await app.getByTestId('npv-queue').click();
|
||||
|
||||
await expect(app.locator('#queue-panel')).toHaveAttribute('open', '');
|
||||
});
|
||||
|
||||
test('still fits, with nothing to scroll sideways to', async ({ app }) => {
|
||||
const fit = await app.evaluate(() => ({
|
||||
scroll: document.body.scrollWidth,
|
||||
client: document.body.clientWidth,
|
||||
}));
|
||||
|
||||
expect(fit.scroll).toBe(fit.client);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the full-screen transport is the page', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(DEVICE);
|
||||
await stageATrack(app);
|
||||
await app.getByTestId('open-now-playing').click();
|
||||
});
|
||||
|
||||
test('draws all five, larger than the bar draws any', async ({ app }) => {
|
||||
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
|
||||
|
||||
expect(pw).toBeGreaterThanOrEqual(56);
|
||||
expect(ph).toBeGreaterThanOrEqual(56);
|
||||
|
||||
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
|
||||
const [w, h] = await sizeOf(app, name);
|
||||
|
||||
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
|
||||
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
|
||||
}
|
||||
});
|
||||
|
||||
test('fits at both phone widths', async ({ app }) => {
|
||||
for (const size of [DEVICE, PHONE]) {
|
||||
await app.setViewportSize(size);
|
||||
|
||||
const fit = await app.evaluate(() => ({
|
||||
scroll: document.body.scrollWidth,
|
||||
client: document.body.clientWidth,
|
||||
}));
|
||||
|
||||
expect(fit.scroll, `${size.width}px`).toBe(fit.client);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **The desktop bar is not what either issue is about, and must not
|
||||
* move.** Both are `Platform/Android`; this is the guard that says so
|
||||
* in a way a build can check.
|
||||
*
|
||||
* It caught a real regression while it was being written: a generic
|
||||
* `font-size` on the buttons took them from the UA stylesheet's 13.3px
|
||||
* to the shell's 16px and grew every one from 33x21 to 36x24 — a
|
||||
* change nobody asked for, invisible to every other assertion here.
|
||||
*/
|
||||
test.describe('the desktop bar is untouched', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(DESKTOP);
|
||||
await stageATrack(app);
|
||||
});
|
||||
|
||||
test('keeps all five controls and the queue button', async ({ app }) => {
|
||||
const bar = barControls(app);
|
||||
|
||||
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
|
||||
await expect(bar.getByRole('button', { name })).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(bar.getByRole('button', { name: /^Repeat/ })).toBeVisible();
|
||||
await expect(app.locator('#queue-button')).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* **The mechanism, because the pixels are the engine's.**
|
||||
*
|
||||
* The first version of this asserted the literal `'33x21'`, measured
|
||||
* on `main` in Chromium — and WebKit draws the same button **36x24**,
|
||||
* so it failed in CI on a build where nothing was wrong. A button's
|
||||
* box comes from the UA stylesheet when the author sets nothing, and
|
||||
* what each UA sets is its own business.
|
||||
*
|
||||
* What this PR must not do is *set* anything here, so that is what is
|
||||
* asserted: our two box properties are unset, and the font is still
|
||||
* the UA's rather than the shell's. That is precisely the regression
|
||||
* this caught the first time — a generic `font-size: inherit` took
|
||||
* these from the UA's default to 16px — and it catches it in either
|
||||
* engine.
|
||||
*/
|
||||
test('sets no size of its own on the desktop bar', async ({ app }) => {
|
||||
const measured = await barControls(app).evaluate((el) => {
|
||||
// A bare button with no author styles: whatever this engine
|
||||
// gives one is what the bar's buttons must still be.
|
||||
const probe = document.createElement('button');
|
||||
|
||||
document.body.appendChild(probe);
|
||||
|
||||
const uaFontSize = getComputedStyle(probe).fontSize;
|
||||
|
||||
probe.remove();
|
||||
|
||||
return [...el.shadowRoot!.querySelectorAll('button')].map((b) => {
|
||||
const cs = getComputedStyle(b);
|
||||
const r = b.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
minWidth: cs.minWidth,
|
||||
minHeight: cs.minHeight,
|
||||
usesUaFont: cs.fontSize === uaFontSize,
|
||||
size: `${Math.round(r.width)}x${Math.round(r.height)}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
expect(measured).toHaveLength(5);
|
||||
|
||||
for (const m of measured) {
|
||||
expect(m.minWidth, 'min-width').toBe('0px');
|
||||
expect(m.minHeight, 'min-height').toBe('0px');
|
||||
expect(m.usesUaFont, 'font-size is still the UA default').toBe(true);
|
||||
}
|
||||
|
||||
// And all five are the same box: `.play` takes a larger size in
|
||||
// both sized contexts, so this is what says the desktop is neither
|
||||
// of them.
|
||||
expect(new Set(measured.map((m) => m.size)).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
import { test, expect, openTheQueue } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* #55 — the queue is a *place* while it covers the content, and a
|
||||
@@ -37,15 +37,37 @@ const DEVICE = { width: 424, height: 439 };
|
||||
/** Wide enough that the queue is a column: 1280 − 200 − 320 ≥ 480. */
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
|
||||
/**
|
||||
* The Compact band, where the queue is a *screen* (644 − 320 < 480) and
|
||||
* the bottom bar still carries its button.
|
||||
*
|
||||
* Two of these tests need both facts at once and only this band has
|
||||
* them: below 600px #59 takes the button off the bar, so there is no
|
||||
* toggle to re-press and the queue is opened from Now Playing — which
|
||||
* is itself a detail view, so "the destination stays lit" is vacuously
|
||||
* true there rather than tested.
|
||||
*/
|
||||
const COMPACT = { width: 700, height: 600 };
|
||||
|
||||
const activeView = (page: Page) => page.getByTestId('main-content');
|
||||
const queue = (page: Page) => page.locator('#queue-panel');
|
||||
const toggle = (page: Page) => page.locator('#queue-button');
|
||||
|
||||
/**
|
||||
* Whether the queue is up.
|
||||
*
|
||||
* The panel's own attribute rather than the toggle's `aria-expanded`,
|
||||
* because below 600px there is no toggle to ask (#59) — and the panel
|
||||
* is the one fact both of them reflect anyway.
|
||||
*/
|
||||
async function expectQueue(page: Page, open: boolean): Promise<void> {
|
||||
await expect(toggle(page)).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
String(open),
|
||||
);
|
||||
const panel = queue(page);
|
||||
|
||||
if (open) {
|
||||
await expect(panel).toHaveAttribute('open', '');
|
||||
} else {
|
||||
await expect(panel).not.toHaveAttribute('open', '');
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('the queue is a screen where it covers the content', () => {
|
||||
@@ -55,12 +77,17 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
});
|
||||
|
||||
// On a phone the queue is opened from Now Playing (#59), so the page
|
||||
// *underneath* it is `now-playing` and the journey is two entries
|
||||
// deep: albums -> now-playing -> queue. That is the real route a user
|
||||
// takes, which is why these do not reach for the shortcut.
|
||||
|
||||
test('back closes the queue and leaves the page where it was', async ({
|
||||
app,
|
||||
}) => {
|
||||
await expect(queue(app)).toHaveAttribute('overlay', '');
|
||||
|
||||
await toggle(app).click();
|
||||
await openTheQueue(app);
|
||||
await expectQueue(app, true);
|
||||
|
||||
await app.goBack();
|
||||
@@ -69,27 +96,31 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
// The page underneath is untouched. Before #55 this was the
|
||||
// *previous* view, because the queue was not in the stack at all
|
||||
// and back spent an entry navigating something nobody could see.
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
await expect(activeView(app)).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'now-playing',
|
||||
);
|
||||
});
|
||||
|
||||
test('costs exactly one entry, so the next press navigates', async ({
|
||||
app,
|
||||
}) => {
|
||||
await toggle(app).click();
|
||||
await openTheQueue(app);
|
||||
await expectQueue(app, true);
|
||||
|
||||
await app.goBack();
|
||||
await expectQueue(app, false);
|
||||
await expect(activeView(app)).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'now-playing',
|
||||
);
|
||||
|
||||
await app.goBack();
|
||||
|
||||
// Whatever the launch page is, it is not Albums — the point is that
|
||||
// this press moved the app rather than being swallowed by a queue
|
||||
// that had already closed.
|
||||
await expect(activeView(app)).not.toHaveAttribute(
|
||||
'data-active-view',
|
||||
'albums',
|
||||
);
|
||||
// Exactly one entry each: the second press leaves Now Playing for
|
||||
// the page it was opened from, rather than being swallowed by a
|
||||
// queue that had already closed.
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -116,15 +147,9 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
await app.keyboard.press('Escape');
|
||||
},
|
||||
],
|
||||
[
|
||||
'the toggle it was opened from',
|
||||
async (app: Page) => {
|
||||
await toggle(app).click();
|
||||
},
|
||||
],
|
||||
] as Array<[string, (app: Page) => Promise<void>]>) {
|
||||
test(`${name} leaves no entry behind`, async ({ app }) => {
|
||||
await toggle(app).click();
|
||||
await openTheQueue(app);
|
||||
await expectQueue(app, true);
|
||||
|
||||
await dismiss(app);
|
||||
@@ -132,7 +157,10 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
|
||||
await app.goBack();
|
||||
|
||||
await expect(activeView(app)).not.toHaveAttribute(
|
||||
// One press, one screen: Now Playing is what the queue was opened
|
||||
// from, so leaving it lands on Albums. An orphaned entry would
|
||||
// have spent this press on nothing and left it here.
|
||||
await expect(activeView(app)).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'albums',
|
||||
);
|
||||
@@ -149,18 +177,6 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
* `back-navigation.spec.ts` gives: the class was right throughout the
|
||||
* bug that rule exists for.
|
||||
*/
|
||||
test('leaves the tab it was opened from highlighted', async ({ app }) => {
|
||||
await expect(
|
||||
app.getByRole('button', { name: 'Albums', exact: true }),
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await toggle(app).click();
|
||||
await expectQueue(app, true);
|
||||
|
||||
await expect(
|
||||
app.getByRole('button', { name: 'Albums', exact: true }),
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
/**
|
||||
* With the panel spanning the whole width the scrim has no uncovered
|
||||
@@ -168,7 +184,7 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
|
||||
*/
|
||||
test('offers a way out a thumb can hit', async ({ app }) => {
|
||||
await toggle(app).click();
|
||||
await openTheQueue(app);
|
||||
|
||||
const box = await app
|
||||
.getByRole('button', { name: 'Close queue' })
|
||||
@@ -204,7 +220,7 @@ test('the panel stays out of the paint-contained region', async ({ app }) => {
|
||||
// from it — and because the host drops `paint` from its own
|
||||
// containment deliberately in overlay mode, so a closed panel answers
|
||||
// a different question.
|
||||
await toggle(app).click();
|
||||
await openTheQueue(app);
|
||||
await expectQueue(app, true);
|
||||
|
||||
const ancestry = await app.evaluate(() => {
|
||||
@@ -235,6 +251,66 @@ test('the panel stays out of the paint-contained region', async ({ app }) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Two properties need the queue to be a *screen* and the bar to still
|
||||
* have its button, and only the Compact band has both — below 600px #59
|
||||
* takes the button off the bar.
|
||||
*/
|
||||
test.describe('a screen opened from the bar', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(COMPACT);
|
||||
await app.getByTestId('nav-albums').click();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
await expect(queue(app)).toHaveAttribute('overlay', '');
|
||||
});
|
||||
|
||||
/**
|
||||
* A detail view leaves the destination it was opened from lit
|
||||
* (`active-view-store`, #72), and the queue inherits that — it is
|
||||
* published with `isPrimary: false`, so `isActive('albums')` is still
|
||||
* true underneath it.
|
||||
*
|
||||
* `aria-current` rather than a class, for the reason
|
||||
* `back-navigation.spec.ts` gives: the class was right throughout the
|
||||
* bug that rule exists for.
|
||||
*/
|
||||
test('leaves the destination it was opened from highlighted', async ({
|
||||
app,
|
||||
}) => {
|
||||
// By testid, not by role: at 700px the sidebar is in icon mode, so
|
||||
// what the item is *named* is a different question from which item
|
||||
// it is. The assertion is still `aria-current`, which is the
|
||||
// accessible fact.
|
||||
const albums = app.getByTestId('nav-albums');
|
||||
|
||||
await expect(albums).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await toggle(app).click();
|
||||
await expectQueue(app, true);
|
||||
|
||||
await expect(albums).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
/** The toggle is a fourth way out, and it unwinds the entry like the
|
||||
* other three — through the panel's attribute, not its own handler. */
|
||||
test('closes from the same toggle, leaving no entry behind', async ({
|
||||
app,
|
||||
}) => {
|
||||
await toggle(app).click();
|
||||
await expectQueue(app, true);
|
||||
|
||||
await toggle(app).click();
|
||||
await expectQueue(app, false);
|
||||
|
||||
await app.goBack();
|
||||
|
||||
await expect(activeView(app)).not.toHaveAttribute(
|
||||
'data-active-view',
|
||||
'albums',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The column is not a place. Somebody docked it; back must not undock
|
||||
* it, and navigating to another view must not take it away.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
import { test, expect, openTheQueue } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* #24 — the queue panel does not take the page's width away from it.
|
||||
@@ -54,15 +54,15 @@ const shellGeometry = (page: import('@playwright/test').Page) =>
|
||||
};
|
||||
});
|
||||
|
||||
async function openQueue(page: import('@playwright/test').Page) {
|
||||
const toggle = page.locator('#queue-button');
|
||||
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
}
|
||||
/**
|
||||
* Opening the queue is `openTheQueue`, which takes the route this
|
||||
* viewport offers. It used to be a local helper that clicked
|
||||
* `#queue-button` unconditionally, and #59 hid that button below
|
||||
* 600px -- so the two phone bands here failed on a build where the
|
||||
* queue was working perfectly, having been asserting *how* it opens as
|
||||
* much as what it does.
|
||||
*/
|
||||
const openQueue = openTheQueue;
|
||||
|
||||
test.describe('an open queue leaves the content its width', () => {
|
||||
for (const band of BANDS) {
|
||||
|
||||
@@ -140,6 +140,50 @@ export async function navigateTo(page: Page, view: string): Promise<void> {
|
||||
.waitFor({ state: 'attached' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the queue the way a user at this viewport would.
|
||||
*
|
||||
* **The route differs by width and that is the feature, not an
|
||||
* inconvenience.** Above 600px the bottom bar carries a queue button.
|
||||
* Below it that button is gone (#59) and the queue is reached from the
|
||||
* full-screen Now Playing view, which the mini player's art opens —
|
||||
* "reachable only from Now Playing", which is what the issue asks for.
|
||||
*
|
||||
* It is here rather than in one spec because four files need it, and
|
||||
* because a spec that hard-codes `#queue-button` is quietly asserting
|
||||
* *which* route exists as well as what the queue does. Four of them
|
||||
* were, which is how hiding one button failed ten tests about
|
||||
* something else.
|
||||
*
|
||||
* The width is read from the page rather than passed, so a caller that
|
||||
* resizes and then opens does not have to say so twice.
|
||||
*/
|
||||
export async function openTheQueue(page: Page): Promise<void> {
|
||||
const toggle = page.locator('#queue-button');
|
||||
|
||||
if (await toggle.isVisible()) {
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The phone: through Now Playing. `open-now-playing` is the mini
|
||||
// player's art, which is a button only below 600px.
|
||||
if (
|
||||
(await page.getByTestId('main-content').getAttribute('data-active-view')) !==
|
||||
'now-playing'
|
||||
) {
|
||||
await page.getByTestId('open-now-playing').click();
|
||||
}
|
||||
|
||||
await page.getByTestId('npv-queue').click();
|
||||
await expect(page.locator('#queue-panel')).toHaveAttribute('open', '');
|
||||
}
|
||||
|
||||
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
|
||||
export class TestCtl {
|
||||
constructor(private readonly baseURL: string) {}
|
||||
|
||||
@@ -142,6 +142,18 @@ export function SetVolume(desiredVolume: $models.UserVolume): $CancellablePromis
|
||||
return $Call.ByID(1375836663, desiredVolume);
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemOwnsVolume reports whether the platform's own control is the
|
||||
* only volume control there is, so this app neither offers one nor
|
||||
* remembers a level.
|
||||
*
|
||||
* It is bound: the frontend renders no `<volume-control>` when it is
|
||||
* true, at any width.
|
||||
*/
|
||||
export function SystemOwnsVolume(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(1027623185);
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackLengthInSeconds returns the duration of the current track.
|
||||
*/
|
||||
|
||||
+37
-9
@@ -522,19 +522,47 @@ body div.sidebar {
|
||||
}
|
||||
|
||||
/* Volume stands down here whatever the setting says, because this
|
||||
is about room and about the platform rather than about
|
||||
preference: the hardware keys own volume on a phone, which is
|
||||
also why mediacontrols' Android handler implements no volume
|
||||
callback. It moved from `audio-player`'s own media query when
|
||||
#42 moved the control into the bar — same rule, and now stated
|
||||
where the element actually is.
|
||||
is about room: five controls and a slider do not fit a 360px
|
||||
bar, and the full-screen now-playing view is where seeking and
|
||||
volume go on a phone. It moved from `audio-player`'s own media
|
||||
query when #42 moved the control into the bar — same rule, and
|
||||
now stated where the element actually is.
|
||||
|
||||
`.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. */
|
||||
**This rule used to carry the platform argument too, and no
|
||||
longer does** (#64). "The hardware keys own the volume" is not a
|
||||
width: it is false of a narrow desktop window and true of an
|
||||
Android tablet, which this selector gets backwards both ways.
|
||||
The player answers it now — `SystemOwnsVolume` — and
|
||||
`volume-control` renders nothing when it is true, at every
|
||||
width and in both of its mount points. What is left here is the
|
||||
question a stylesheet can actually answer. */
|
||||
.bottom-bar volume-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The queue leaves the phone's bar (#59), because #55 made it a
|
||||
screen with an entry in the back stack and Now Playing already
|
||||
carries its own button for it. The route is the mini player's
|
||||
art -> Now Playing -> the queue, which is the "reachable only
|
||||
from Now Playing" this issue asks for.
|
||||
|
||||
This is allowed to remove a control only because the control is
|
||||
still reachable: plan 018's matrix promises that no action is
|
||||
ever unreachable at any supported size, and that promise is what
|
||||
`phone-transport.spec.ts` asserts rather than the button count.
|
||||
|
||||
**`.bottom-bar #queue-button`, not `#queue-button`**, and that is
|
||||
not decoration. The rule this overrides is written *nested*
|
||||
inside `.bottom-bar`, so it builds to a descendant selector one
|
||||
class more specific than it looks in the source -- and a bare
|
||||
`#queue-button` here loses to it, media query or not. Being last
|
||||
in the file is not enough when the thing above is more specific,
|
||||
which is the same lesson as this section's own header one level
|
||||
down: nesting adds specificity the source does not show, and the
|
||||
failure is silent (the button simply stayed). */
|
||||
.bottom-bar #queue-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
||||
|
||||
@@ -1,22 +1,77 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import type { RepeatMode } from '@store/queue-store';
|
||||
import { designTokens } from '../../../styles/tokens.css';
|
||||
import { PHONE_QUERY } from '../../../utils/breakpoints';
|
||||
|
||||
/**
|
||||
* The transport, in the two places it appears.
|
||||
*
|
||||
* **The context is a property and cannot be a media query**, which is
|
||||
* the whole reason this exists (#56). Everywhere else in this app a
|
||||
* component states what it drops at phone width itself, because a media
|
||||
* query inside a shadow root is answered by the viewport and that is
|
||||
* the honest signal. Here the two hosts want *different* answers at the
|
||||
* *same* viewport: on a phone the bottom bar wants three controls sized
|
||||
* for a thumb, and `now-playing-view` wants five, larger still. So the
|
||||
* host says which context and the viewport says which size band, and
|
||||
* neither one alone can express it.
|
||||
*
|
||||
* Measured at the reference device's 424x439 before this: every button
|
||||
* here was **33x21px**, in both places, which is what #56 reports as
|
||||
* "the most important thing in the mobile app and they are tiny".
|
||||
*/
|
||||
export type ControlsContext = 'bar' | 'full';
|
||||
|
||||
@customElement('player-controls')
|
||||
export class PlayerControls extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
private unsubscribeQueue?: () => void;
|
||||
|
||||
/**
|
||||
* Where these controls are drawn. `bar` is the bottom bar in both
|
||||
* bands; `full` is the full-screen transport.
|
||||
*
|
||||
* Reflected so a spec can read it and so the stylesheet keys off one
|
||||
* fact rather than a class the host has to remember to set.
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
context: ControlsContext = 'bar';
|
||||
|
||||
@state() private shuffleMode = false;
|
||||
@state() private repeatMode: RepeatMode = 'off';
|
||||
|
||||
/**
|
||||
* Phone width, from `matchMedia` rather than from a media query,
|
||||
* because what it decides is whether shuffle and repeat *exist* here
|
||||
* — and a stylesheet can only decide whether they are painted.
|
||||
* `job-band` and `search-trigger` are the same pattern for the same
|
||||
* reason.
|
||||
*/
|
||||
@state() private phone = false;
|
||||
|
||||
private media?: MediaQueryList;
|
||||
|
||||
private onMedia = (e: MediaQueryListEvent) => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
|
||||
/** Whether this is the phone's bottom bar, which carries three
|
||||
* controls rather than five. */
|
||||
private get slim(): boolean {
|
||||
return this.context === 'bar' && this.phone;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.media = window.matchMedia(PHONE_QUERY);
|
||||
this.phone = this.media.matches;
|
||||
this.media.addEventListener('change', this.onMedia);
|
||||
|
||||
const s = queueStore.getState();
|
||||
this.shuffleMode = s.shuffleMode;
|
||||
this.repeatMode = s.repeatMode;
|
||||
@@ -37,6 +92,7 @@ export class PlayerControls extends LitElement {
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribeQueue?.();
|
||||
this.media?.removeEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
static override styles = [designTokens, css`
|
||||
@@ -58,6 +114,99 @@ export class PlayerControls extends LitElement {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
Sizes (#56).
|
||||
|
||||
44px is the floor everything here is sized to, and play/pause
|
||||
alone goes above it -- "large play/pause, adequate prev/next" is
|
||||
the Direction, and it is the one control the report calls "front
|
||||
and centre".
|
||||
|
||||
They are stated as custom properties rather than on each button
|
||||
so a context sets two numbers instead of five rules, and so the
|
||||
icon scales with its target: a 44px box around a 16px glyph is a
|
||||
big hit area that still looks tiny, which is half of what the
|
||||
report is about.
|
||||
|
||||
**The desktop bar sets none of them and must not change at all.**
|
||||
#56 is an Android issue; the desktop's buttons are 33x21 before
|
||||
this and are 33x21 after it.
|
||||
|
||||
That is why the box rules take a zero fallback and the *font-size*
|
||||
rules are scoped to the two contexts instead of sharing them. A
|
||||
button does not inherit its font from its parent -- the UA
|
||||
stylesheet gives it one -- so a generic font-size: inherit is
|
||||
not the no-op it reads as: it moved the desktop's buttons from
|
||||
33x21 to 36x24, silently, by taking them from the UA's 13.3px to
|
||||
the shell's 16px. Measured before and after by stashing this
|
||||
file, which is the only way that particular 3px shows up.
|
||||
--------------------------------------------------------------- */
|
||||
button {
|
||||
min-width: var(--yj-control-target, 0);
|
||||
min-height: var(--yj-control-target, 0);
|
||||
}
|
||||
|
||||
button.play {
|
||||
min-width: var(--yj-control-play-target, 0);
|
||||
min-height: var(--yj-control-play-target, 0);
|
||||
}
|
||||
|
||||
/* The phone's bottom bar: three controls, sized for a thumb.
|
||||
Shuffle and repeat are not here -- see the render method, which
|
||||
does not draw them rather than hiding them, because a control
|
||||
that is display:none is still a thing the component claims to
|
||||
have. They are on the full-screen view, which is one tap away
|
||||
through the mini player's art (#59). */
|
||||
@media (max-width: 599px) {
|
||||
:host([context='bar']) {
|
||||
--yj-control-target: 44px;
|
||||
--yj-control-icon: 18px;
|
||||
--yj-control-play-target: 56px;
|
||||
--yj-control-play-icon: 24px;
|
||||
}
|
||||
|
||||
:host([context='bar']) button {
|
||||
font-size: var(--yj-control-icon);
|
||||
}
|
||||
|
||||
:host([context='bar']) button.play {
|
||||
font-size: var(--yj-control-play-icon);
|
||||
}
|
||||
}
|
||||
|
||||
/* The full-screen transport, at every width: this view *is* the
|
||||
player, so the controls are the page rather than a strip of it. */
|
||||
:host([context='full']) {
|
||||
--yj-control-target: 44px;
|
||||
--yj-control-icon: 20px;
|
||||
--yj-control-play-target: 64px;
|
||||
--yj-control-play-icon: 28px;
|
||||
}
|
||||
|
||||
:host([context='full']) button {
|
||||
font-size: var(--yj-control-icon);
|
||||
}
|
||||
|
||||
:host([context='full']) button.play {
|
||||
font-size: var(--yj-control-play-icon);
|
||||
}
|
||||
|
||||
:host([context='full']) #player-control-buttons {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Secondary controls sit below the primary row rather than beside
|
||||
it, which is the Direction's shape and is why this is a second
|
||||
group in the DOM instead of a CSS order property: visual order
|
||||
and focus order have to agree. */
|
||||
.secondary {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: var(--yj-accent-text, #ffd43b);
|
||||
}
|
||||
@@ -104,46 +253,107 @@ export class PlayerControls extends LitElement {
|
||||
queueStore.cycleRepeat();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
|
||||
const playOrPauseHandler = this.player.isPlaying
|
||||
? this.handlePauseClick
|
||||
: this.handlePlayClick;
|
||||
/** Shuffle. Secondary: it changes how the queue behaves rather than
|
||||
* what is playing now. */
|
||||
private renderShuffle() {
|
||||
return html`
|
||||
<button
|
||||
class=${this.shuffleMode ? 'active' : ''}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed=${this.shuffleMode}
|
||||
@click=${this.handleShuffleClick}
|
||||
>
|
||||
<wa-icon name="shuffle"></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const shuffleClass = this.shuffleMode ? 'active' : '';
|
||||
/** Repeat, whose label spells the mode out because one icon covers
|
||||
* three states. */
|
||||
private renderRepeat() {
|
||||
const repeatMode = this.repeatMode;
|
||||
const repeatClasses = [
|
||||
repeatMode !== 'off' ? 'active' : '',
|
||||
repeatMode === 'one' ? 'repeat-one' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return html`
|
||||
<button
|
||||
class=${repeatClasses}
|
||||
aria-label=${`Repeat: ${repeatMode}`}
|
||||
aria-pressed=${repeatMode !== 'off'}
|
||||
@click=${this.handleRepeatClick}
|
||||
>
|
||||
<wa-icon name="repeat"></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next — the three that are always drawn, in
|
||||
* every context and at every width. Only play/pause takes the large
|
||||
* size: the Direction asks for "large play/pause, adequate
|
||||
* prev/next", and a row of identical squares says every action here
|
||||
* is equally likely, which is not true of play. */
|
||||
private renderPrimary() {
|
||||
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
|
||||
const playOrPauseHandler = this.player.isPlaying
|
||||
? this.handlePauseClick
|
||||
: this.handlePlayClick;
|
||||
|
||||
return html`
|
||||
<button
|
||||
aria-label="Previous track"
|
||||
@click=${this.handlePreviousClick}
|
||||
>
|
||||
<wa-icon name="backward-step"></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="play"
|
||||
aria-label=${this.player.isPlaying ? 'Pause' : 'Play'}
|
||||
@click="${playOrPauseHandler}"
|
||||
>
|
||||
<wa-icon name=${playOrPauseIcon}></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next track"
|
||||
@click=${this.handleNextClick}
|
||||
>
|
||||
<wa-icon name="forward-step"></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two arrangements, not two components.
|
||||
*
|
||||
* `bar` keeps the order it has always had — shuffle, prev, play,
|
||||
* next, repeat, one row — so nothing about the desktop bar moves.
|
||||
* `full` puts the primary three on their own row with the secondary
|
||||
* pair beneath, which the Direction asks for.
|
||||
*
|
||||
* **The phone's bar draws three buttons rather than hiding two.** A
|
||||
* `display: none` control is still in the component's shadow root,
|
||||
* still in the accessibility tree's markup, and still something a
|
||||
* `shadowAll('button')[4]` finds — so "the phone has three controls"
|
||||
* would be true of the pixels and false of the element. They are
|
||||
* reachable on the full-screen view, which the mini player's art
|
||||
* opens, and through the global shortcuts.
|
||||
*/
|
||||
override render() {
|
||||
if (this.context === 'full') {
|
||||
return html`
|
||||
<div id="player-control-buttons">${this.renderPrimary()}</div>
|
||||
<div class="secondary">
|
||||
${this.renderShuffle()}${this.renderRepeat()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div id="player-control-buttons">
|
||||
<button
|
||||
class=${shuffleClass}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed=${this.shuffleMode}
|
||||
@click=${this.handleShuffleClick}
|
||||
>
|
||||
<wa-icon name="shuffle"></wa-icon>
|
||||
</button>
|
||||
<button aria-label="Previous track" @click=${this.handlePreviousClick}>
|
||||
<wa-icon name="backward-step"></wa-icon>
|
||||
</button>
|
||||
<button aria-label=${this.player.isPlaying ? 'Pause' : 'Play'} @click="${playOrPauseHandler}">
|
||||
<wa-icon name=${playOrPauseIcon}></wa-icon>
|
||||
</button>
|
||||
<button aria-label="Next track" @click=${this.handleNextClick}>
|
||||
<wa-icon name="forward-step"></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class=${repeatClasses}
|
||||
aria-label=${`Repeat: ${repeatMode}`}
|
||||
aria-pressed=${repeatMode !== 'off'}
|
||||
@click=${this.handleRepeatClick}
|
||||
>
|
||||
<wa-icon name="repeat"></wa-icon>
|
||||
</button>
|
||||
${this.slim ? nothing : this.renderShuffle()}
|
||||
${this.renderPrimary()}
|
||||
${this.slim ? nothing : this.renderRepeat()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||
@@ -27,6 +27,26 @@ export class VolumeControl extends LitElement {
|
||||
@state()
|
||||
private popup = volumeStyleStore.popup;
|
||||
|
||||
/**
|
||||
* Whether there is a volume of ours to control at all (#64).
|
||||
*
|
||||
* The decision is made here rather than at either mount point,
|
||||
* because there are two -- the bottom bar's copy lives in
|
||||
* `index.html`, which has no module scope to make it conditional --
|
||||
* and one of them is a control the shell cannot un-render. So the
|
||||
* control answers for itself, and the bar and the phone's
|
||||
* full-screen transport get the same answer without either knowing
|
||||
* the question exists.
|
||||
*
|
||||
* It renders `nothing` *and* hides the host: an empty shadow root is
|
||||
* what stops a positional or role query finding a button that cannot
|
||||
* act, and `:host([hidden])` is what stops the element occupying a
|
||||
* flex item's worth of the transport -- the `:host` display above
|
||||
* outranks the UA's `[hidden]` rule, so it has to be said.
|
||||
*/
|
||||
@state()
|
||||
private available = volumeStyleStore.available;
|
||||
|
||||
private unsubscribeStyle?: () => void;
|
||||
|
||||
// Locally-tracked volume while the user is actively dragging or scrolling.
|
||||
@@ -43,6 +63,13 @@ export class VolumeControl extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* See the available field. A gap is only drawn between boxes,
|
||||
so a hidden host costs its parent nothing -- which is where the
|
||||
29px this gives back to Now Playing comes from (#172). */
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -154,12 +181,15 @@ export class VolumeControl extends LitElement {
|
||||
|
||||
this.unsubscribeStyle = volumeStyleStore.subscribe(() => {
|
||||
this.popup = volumeStyleStore.popup;
|
||||
this.setAvailable(volumeStyleStore.available);
|
||||
|
||||
// Switching to the slider while the popup is open would leave the
|
||||
// document listener installed for a popup that no longer renders.
|
||||
if (!this.popup) this.closeSlider();
|
||||
});
|
||||
|
||||
this.setAvailable(volumeStyleStore.available);
|
||||
|
||||
void volumeStyleStore.init();
|
||||
}
|
||||
|
||||
@@ -233,7 +263,25 @@ export class VolumeControl extends LitElement {
|
||||
// RENDER
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* `hidden` is set imperatively rather than reflected from the state,
|
||||
* because it has to be on the *host* and a `@state` does not reflect.
|
||||
* It is the right attribute besides: it takes the element out of the
|
||||
* accessibility tree as well as out of the layout.
|
||||
*/
|
||||
private setAvailable(available: boolean) {
|
||||
this.available = available;
|
||||
this.hidden = !available;
|
||||
|
||||
// A popup left open when the control goes away would keep its
|
||||
// document click listener installed for markup that no longer
|
||||
// renders.
|
||||
if (!available) this.closeSlider();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.available) return nothing;
|
||||
|
||||
const muted = this.player.muted;
|
||||
|
||||
// Inline, the icon is the mute toggle rather than a disclosure:
|
||||
|
||||
@@ -117,8 +117,27 @@ export class NowPlayingView extends LitElement {
|
||||
.art .placeholder {
|
||||
/* Square, and never taller than the room left over: the
|
||||
art is the one thing here that would happily push the
|
||||
transport off the bottom of a short phone. */
|
||||
transport off the bottom of a short phone.
|
||||
|
||||
**max-height is what actually keeps that promise**, and
|
||||
it was missing. With a definite width and
|
||||
a 1:1 aspect-ratio the height is *derived from the width*
|
||||
and is bounded by nothing: at the reference device's
|
||||
424x439 that is a 263px square (60vh) in a box with far
|
||||
less than 263px left, so the art overflowed its own
|
||||
centred flex item and drew over the header above and the
|
||||
title below it. The comment claimed this was handled;
|
||||
60vh is a bound on the *viewport*, not on the room left
|
||||
over, and those differ by however much chrome is above
|
||||
and below.
|
||||
|
||||
Pre-existing -- screenshotted on main -- and made acute
|
||||
by #56, which gives the transport 95px more than it had.
|
||||
Found by reading a screenshot, which is the only tier
|
||||
that can see it: nothing fails, nothing overflows the
|
||||
*shell*, and every control is still hittable. */
|
||||
width: min(100%, 60vh);
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
@@ -317,7 +336,19 @@ export class NowPlayingView extends LitElement {
|
||||
|
||||
<div class="transport">
|
||||
<seek-bar></seek-bar>
|
||||
<player-controls></player-controls>
|
||||
<!-- context="full": this view *is* the player, so the
|
||||
transport is the page rather than a strip of it --
|
||||
primary controls large, shuffle and repeat beneath
|
||||
at normal size (#56). It is a property rather than
|
||||
a media query because the bottom bar wants a
|
||||
different answer at this same viewport. -->
|
||||
<player-controls context="full"></player-controls>
|
||||
<!-- Rendered unconditionally and absent on its own
|
||||
terms where the device owns the volume (#64): the
|
||||
control asks the player, not this view and not the
|
||||
viewport. A hidden host draws no gap, so that is
|
||||
29px of a 439px screen back to the album art
|
||||
(#172). -->
|
||||
<volume-control></volume-control>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -215,6 +215,17 @@ export class NowPlaying extends LitElement {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The favourite is one of the three controls #59 keeps on the
|
||||
phone's bar, and it was the **smallest control in the app**:
|
||||
measured at 424x439, 18x14px, against the 48x48 art beside it.
|
||||
Zero padding around an icon-sized glyph is a reasonable mouse
|
||||
target and is not a thumb target at all. */
|
||||
.fav-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
font-size: var(--yj-icon-md);
|
||||
}
|
||||
}
|
||||
|
||||
.cover-preview-panel {
|
||||
@@ -446,8 +457,30 @@ export class NowPlaying extends LitElement {
|
||||
return html`
|
||||
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
|
||||
<div class="now-playing">
|
||||
<div class="cover-art">
|
||||
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
|
||||
<!-- **The way to Now Playing does not depend on what is
|
||||
playing.** This branch used to render the placeholder
|
||||
with no button on it, so on a phone there was no route to
|
||||
the full-screen view while nothing was loaded -- and once
|
||||
#59 took the queue button off the bar, that made the
|
||||
queue itself unreachable, because Now Playing is where it
|
||||
is reached from. The queue is persisted across restarts,
|
||||
so "a queue with tracks in it and nothing playing" is an
|
||||
ordinary state to launch into, not a corner.
|
||||
|
||||
Plan 018's matrix promises no action is unreachable at
|
||||
any supported size, and the promise is what makes #59
|
||||
allowed to remove a control at all. -->
|
||||
<div class="cover-art-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="expand"
|
||||
data-testid="open-now-playing"
|
||||
aria-label="Open now playing"
|
||||
@click=${this.openNowPlaying}
|
||||
></button>
|
||||
<div class="cover-art">
|
||||
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { GetPopupVolume } from '@go/config/config.js';
|
||||
import { SystemOwnsVolume } from '@go/player/player.js';
|
||||
import { Events } from '../events';
|
||||
|
||||
type Subscriber = () => void;
|
||||
@@ -28,10 +29,37 @@ type Subscriber = () => void;
|
||||
* becomes one. An install that has chosen the popup sees it swap once
|
||||
* on load, which is the cheaper of the two wrong first frames: the
|
||||
* inline slider occupies the space the popup's button would have.
|
||||
*
|
||||
* **`available` is the question one step earlier — whether there is a
|
||||
* volume of ours to draw at all (#64).** On Android the hardware keys
|
||||
* are the volume control and the backend pins its own level at
|
||||
* maximum, so a slider here would move nothing.
|
||||
*
|
||||
* It is asked of the *player* rather than of the viewport, and that is
|
||||
* the whole design decision. Every other stand-down rule in this app
|
||||
* is a width, because a width is what a browser can answer and what
|
||||
* every tier can test — but this one is a property of the build. Keyed
|
||||
* on width instead, an Android tablet at 600px or more would draw the
|
||||
* bottom bar's slider over a pinned level: a control that cannot act,
|
||||
* which `library-status-indicator` settled is worse than none.
|
||||
*
|
||||
* It lives beside `popup` because both answer "what presentation does
|
||||
* the volume control get", both readers are the same two components,
|
||||
* and "none" is a presentation. A second store would be a second
|
||||
* subscription in the same `connectedCallback` saying the same thing.
|
||||
*
|
||||
* The initial value is `true` on the same first-frame rule: there is a
|
||||
* volume on every platform but one, and the platform that pins it sees
|
||||
* the control once at boot and never again in the session — the answer
|
||||
* cannot change while the app runs, so by the time the lazily-mounted
|
||||
* now-playing view exists it has long been settled by the bar's own
|
||||
* copy.
|
||||
*/
|
||||
class VolumeStyleStore {
|
||||
private value = false;
|
||||
|
||||
private hasVolume = true;
|
||||
|
||||
private loaded = false;
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
@@ -47,13 +75,21 @@ class VolumeStyleStore {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this app has a volume of its own to control. False where
|
||||
* the device owns it; see the class comment.
|
||||
*/
|
||||
get available(): boolean {
|
||||
return this.hasVolume;
|
||||
}
|
||||
|
||||
/** Reads the setting once. Safe to call from every mount. */
|
||||
async init(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
|
||||
this.loaded = true;
|
||||
|
||||
await this.refresh();
|
||||
await Promise.all([this.refreshAvailability(), this.refresh()]);
|
||||
}
|
||||
|
||||
subscribe(fn: Subscriber): () => void {
|
||||
@@ -77,6 +113,28 @@ class VolumeStyleStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked once, not on `GeneralConfigChanged`: this is a property of
|
||||
* the platform the binary was built for and cannot change while
|
||||
* the app is running.
|
||||
*/
|
||||
private async refreshAvailability(): Promise<void> {
|
||||
try {
|
||||
const owned = await SystemOwnsVolume();
|
||||
|
||||
if (owned === !this.hasVolume) return;
|
||||
|
||||
this.hasVolume = !owned;
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
// The control renders, which is the answer on every
|
||||
// platform but one and is the recoverable way to be wrong:
|
||||
// a working control nobody needs, rather than a missing one
|
||||
// somebody does.
|
||||
console.error('failed to ask who owns the volume', err);
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* The transport in its two contexts (#59, #56).
|
||||
*
|
||||
* `player-controls` is one component in two places, and what each place
|
||||
* wants differs *at the same viewport*: on a phone the bottom bar wants
|
||||
* three controls sized for a thumb, and `now-playing-view` wants five,
|
||||
* larger still. So the host states the context and the viewport states
|
||||
* the size band, and this file pins the half a media query cannot
|
||||
* express.
|
||||
*
|
||||
* **What this tier can and cannot see.** It can see which buttons
|
||||
* exist, because that is `matchMedia` and a render — and existence is
|
||||
* the whole of #59. It cannot see the *sizes*: those come from the
|
||||
* context's custom properties, and a component-tier render has no shell
|
||||
* around it, so the measurements live in `e2e/specs/phone-transport.spec.ts`
|
||||
* where there is a real bar in a real viewport. Asserting a pixel here
|
||||
* would be asserting the fallbacks, which is `ui-visual`'s documented
|
||||
* blind spot one tier over.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import '@components/audio-player/controls/player-controls';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, flush, calls } from '@test/support/harness';
|
||||
import { fixture, shadowAll, click } from '@test/support/render';
|
||||
|
||||
/** Reset the backend-owned state the component reads from. */
|
||||
function idle(): void {
|
||||
emit(Events.TrackChanged, null);
|
||||
emit(Events.PlaybackStateChanged, { state: 'stopped' });
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'off' });
|
||||
}
|
||||
|
||||
const names = (el: Element): Array<string | null> =>
|
||||
shadowAll(el, 'button').map((b) => b.getAttribute('aria-label'));
|
||||
|
||||
/**
|
||||
* Answer `matchMedia` for the phone query, since the test runner's own
|
||||
* window is whatever size the browser provider gives it.
|
||||
*
|
||||
* It is stubbed rather than resized because what is under test is the
|
||||
* component's *reaction* to the answer, and a resize would additionally
|
||||
* be asserting that this runner's viewport can get below 600px.
|
||||
*/
|
||||
const realMatchMedia = window.matchMedia;
|
||||
|
||||
function pretendPhone(phone: boolean): void {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: phone && query.includes('599'),
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = realMatchMedia;
|
||||
});
|
||||
|
||||
describe('<player-controls> in the bar', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
it('keeps all five on a desktop, in the order it always had', async () => {
|
||||
pretendPhone(false);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
// Unchanged from before #59, deliberately: this is the desktop bar
|
||||
// and nothing about it was reported.
|
||||
expect(names(el)).toEqual([
|
||||
'Shuffle',
|
||||
'Previous track',
|
||||
'Play',
|
||||
'Next track',
|
||||
'Repeat: off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws three on a phone, and does not merely hide the other two', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
expect(names(el)).toEqual(['Previous track', 'Play', 'Next track']);
|
||||
|
||||
// The distinction this asserts is the point. A `display: none`
|
||||
// control is still in the shadow root, still something a positional
|
||||
// query finds, and still a thing the component claims to have --
|
||||
// so "the phone has three controls" would have been true of the
|
||||
// pixels and false of the element.
|
||||
expect(shadowAll(el, 'button')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('follows the viewport when it changes, not just at construction', async () => {
|
||||
pretendPhone(false);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
expect(names(el)).toHaveLength(5);
|
||||
|
||||
// A desktop window dragged narrow is the phone layout, per plan
|
||||
// 018's decision 4 -- so this is a real transition and not a
|
||||
// hypothetical.
|
||||
(el as unknown as { phone: boolean }).phone = true;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(names(el)).toEqual(['Previous track', 'Play', 'Next track']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<player-controls> full-screen', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
it('keeps all five on a phone, where the bar keeps three', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
el.setAttribute('context', 'full');
|
||||
await el.updateComplete;
|
||||
|
||||
// The same viewport, the other answer: this is why the context is a
|
||||
// property and cannot be a media query.
|
||||
expect(names(el)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('puts the secondary pair after the primary three, in the DOM', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
el.setAttribute('context', 'full');
|
||||
await el.updateComplete;
|
||||
|
||||
// Order, not just membership: the secondary controls are drawn on a
|
||||
// second row, and this is asserted in the DOM because visual order
|
||||
// and focus order have to agree. A CSS `order` property would move
|
||||
// them on screen and leave Tab walking the old sequence.
|
||||
expect(names(el)).toEqual([
|
||||
'Previous track',
|
||||
'Play',
|
||||
'Next track',
|
||||
'Shuffle',
|
||||
'Repeat: off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still routes every button to the backend', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
el.setAttribute('context', 'full');
|
||||
await el.updateComplete;
|
||||
|
||||
// Two arrangements, one set of handlers. The regression this
|
||||
// guards is the reason a second *component* was refused: a second
|
||||
// template renders buttons wired to nothing, which looks perfect
|
||||
// in a screenshot and does nothing at all.
|
||||
for (const name of [
|
||||
'Previous track',
|
||||
'Next track',
|
||||
'Shuffle',
|
||||
'Repeat: off',
|
||||
]) {
|
||||
await click(el, `button[aria-label="${name}"]`);
|
||||
}
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'queue.Queue.Previous',
|
||||
'queue.Queue.Next',
|
||||
'queue.Queue.ToggleShuffle',
|
||||
'queue.Queue.CycleRepeat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Who owns the volume, and what the control does when it is not us
|
||||
* (#64).
|
||||
*
|
||||
* **This is the tier that can exercise the Android branch**, and it is
|
||||
* the reason the predicate is a backend answer rather than a build tag
|
||||
* the frontend cannot see: `SystemOwnsVolume` is a stub here, so the
|
||||
* "no volume" rendering is checked on an ordinary Linux CI runner with
|
||||
* no device anywhere. What no tier here can check is the *constant*
|
||||
* behind it, which `TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`
|
||||
* sweeps the Go source for instead.
|
||||
*
|
||||
* It is a file of its own because `volumeStyleStore` asks once and
|
||||
* latches — the answer is a property of the binary and cannot change
|
||||
* while the app runs, so there is deliberately no event that refreshes
|
||||
* it. Vitest gives each file its own module registry, which is what
|
||||
* lets the stub be in place before the singleton is first touched.
|
||||
* The *available* case is the rest of `transport.test.ts`, which mounts
|
||||
* the same element under the default stub.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/audio-player/volume-control/volume-control';
|
||||
import '@components/now-playing-view/now-playing-view';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, resetHarness, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'tideline.mp3',
|
||||
filePath: '/music/tideline.mp3',
|
||||
trackLength: 245,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Tideline',
|
||||
artist: 'Sea Change',
|
||||
album: 'Ebb',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
describe('a platform whose volume we do not own', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
stub('player.Player.SystemOwnsVolume', true);
|
||||
stub('config.Config.GetPopupVolume', false);
|
||||
|
||||
// The store latches on the first mount; do it here so every test
|
||||
// below sees a settled answer rather than the first frame.
|
||||
const warm = await fixture('volume-control');
|
||||
|
||||
await warm.updateComplete;
|
||||
});
|
||||
|
||||
it('renders no control at all, and no empty shadow root to find', async () => {
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
// Both halves matter. An empty shadow root is what stops a
|
||||
// positional or by-role query finding a button that cannot act;
|
||||
// `hidden` is what stops the host taking a flex item's worth of
|
||||
// space in the transport it sits in.
|
||||
expect(shadowAll(el, 'button')).toHaveLength(0);
|
||||
expect(shadowAll(el, 'wa-slider')).toHaveLength(0);
|
||||
expect(el.hidden, 'the host is not hidden').toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the rest of the phone transport alone', async () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const view = await fixture('now-playing-view');
|
||||
|
||||
await view.updateComplete;
|
||||
|
||||
// Seeking and the transport buttons are not volume, and #64 is
|
||||
// allowed to remove one control, not to thin the screen out.
|
||||
expect(shadow(view, 'seek-bar')).not.toBeNull();
|
||||
expect(shadow(view, 'player-controls')).not.toBeNull();
|
||||
|
||||
const volume = shadow(view, 'volume-control') as HTMLElement | null;
|
||||
|
||||
expect(volume, 'the element is still mounted').not.toBeNull();
|
||||
expect(volume!.hidden, 'a mounted volume-control is not hidden').toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user