Compare commits

..
Author SHA1 Message Date
yonlu feab0c18ec docs: a CI-only change is ci:, not fix(ci):
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 6m16s
The commit-analyzer reads the type and ignores the scope, so `fix` is a
patch whatever sits in the brackets. Two commits touching nothing but
.gitea/workflows/unclaim.yml were written `fix(ci):` and cut v0.2.1 and
v0.2.2 -- real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change.

CLAUDE.md already warned that a mistyped feat ships a minor version.
That was not enough, because this was not a mistyped type: `fix` was
chosen deliberately, in the belief that the (ci) scope qualified it.

The version bump is the small half, which is why this gets a paragraph
rather than a clause. A merge to main starts two workflows; if
release.yml then pushes a tag, that tag push starts four more --
arch-package, homebrew-formula, android-apk and desktop-assets -- on a
runner with capacity 1, where the APK build alone is tens of minutes
and publishes a signed artifact to a public registry. So a mistyped
type is six workflow runs, not an odd-looking changelog.

`make release-dry` answers this before the merge instead of after, and
is cheaper than any one of those runs.

The two releases are staying: they are already published, and a version
that vanishes is worse for whoever pulled it than one that turns out to
be empty.

Closes #111
2026-08-19 14:54:18 +00:00
188 changed files with 2289 additions and 25673 deletions
-5
View File
@@ -88,8 +88,3 @@ build/android/overlay.json
# Written by @semantic-release/changelog purely to carry the release notes
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
# and .planning/). Written by the scheduled backlog runs.
.pi/journal.md
.pi/schedule-prompts.json
-142
View File
@@ -1,142 +0,0 @@
---
description: Take on the next actionable backlog issue end to end, and stop
---
Take on exactly one issue from the YellowJacket backlog, end to end, and stop.
Repo: yonlu/yellowjacket at https://git.ljones.me — API base
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket, auth with
`-H "Authorization: token $GITEA_TOKEN"`. Default branch is `main`.
## 1. Orient before you pick
Read, in this order: `CLAUDE.md` (the architecture and the reasons behind
it), `.pi/journal.md` (what happened last), `.planning/NOTES.md` (what was
already considered and rejected), and `.planning/plans/active/`. Do not skip
this because the issue looks small — most of this codebase's traps are
written down in exactly one of those four places, and the ones that bite are
the ones you didn't read.
## 2. Pick the issue
List open issues. Choose the single highest-value one that is *actionable
right now*:
- Order by `Priority/Critical``High``Medium``Low`. Within a tier,
prefer `Reviewed/Confirmed`, then `Kind/Bug` over `Kind/Enhancement` over
`Kind/Feature`.
- Consult issue #73 (the roadmap) — if it sequences the candidates, that
ordering wins over the label ordering.
- **Skip** anything labelled `Status/Blocked`, `Status/In Progress`,
`Status/Abandoned`, `Reviewed/Won't Fix`, `Reviewed/Duplicate`,
`Reviewed/Invalid`, or already carrying an open PR.
- **Skip anything someone else is already on.** The label is not the only
claim, because a concurrent session may not have applied it — several pi
sessions run against this repo from separate worktrees under
`~/.paseo/worktrees/`. Run `git ls-remote --heads origin` and skip any
issue whose number or slug matches an existing branch (`60-…`,
`fix/<slug>`). A duplicated fix costs more than a skipped issue.
- **Skip** anything that cannot be verified without hardware you do not
have: physical-device Android behaviour (audio output, on-device file
writes, real gesture input). A browser at 424px is not a phone — see the
Chrome 113 section of `CLAUDE.md`.
- **Skip** intermittent-failure issues unless you can reproduce the failure
on demand within a few minutes. Chasing a 1-in-3 flake is an unbounded
task and does not belong in a scheduled run.
- If nothing qualifies, say so, do nothing, and stop. An empty run is a
correct outcome.
## 3. Claim it
Add `Status/In Progress` to the issue and comment that you are picking it
up. Then branch:
```
git fetch origin && git checkout -b <type>/<short-slug> origin/main
```
`<type>` matches the issue's `Kind` (`fix/`, `feat/`, `refactor/`, `test/`,
`docs/`, `ci/`). Branch from `origin/main`, never by checking out `main`
itself — this repo is worked from several git worktrees at once and `main`
is checked out in one of them, so `git checkout main` fails outright.
## 4. Do the work
Fix the issue that was reported and nothing else. Match the surrounding
code's style. Follow the constraints in `CLAUDE.md` rather than reasoning
from first principles — where it explains why something is shaped the way it
is, that shape is load-bearing and there is usually a test pinning it.
**Anything else you discover becomes a new issue, not a bigger diff.** File
it with the right `Area/`, `Kind/`, `Priority/` labels, describe the
symptom before the theory, and link it from your PR. Scope creep is the
failure mode this instruction exists to prevent.
If the work turns out to be materially larger than the issue implied, stop:
comment on the issue with what you found and what it would actually take,
remove `Status/In Progress`, push nothing, and end the run.
## 5. Verify — the right tier, not the cheapest one
Run `make generate` if you touched `.sql` or `.templ`, and `make bindings`
if you changed a bound Go signature. Then run what the change actually
demands:
- Go change → `make lint` and `make test` (both cover all three build
configurations).
- Frontend component or store → `make ui-test`.
- User-visible flow → `make e2e` against `make dev-headless`. **Check the
port first**: `ss -ltn | grep 34115`. If it is occupied, another worktree
is already running the app — do not start a second one and do not run
`make e2e`. Attaching to someone else's build produces a green result
about code that is not yours, which is worse than no result. Either
choose an issue that does not need this tier, or stop and say why.
- Anything cosmetic or layout-related → look at a screenshot. Several bugs
in this repo's history were invisible to every assertion and obvious in an
image.
A tier you skipped is a claim you did not check. If a tier fails for reasons
unrelated to your change, say so explicitly rather than quietly moving on.
## 6. Keep the documentation true
If you changed structure, behaviour, or a constraint, update `CLAUDE.md` in
the same commit. That file is this project's memory; a change that leaves it
describing the old shape is worse than no change. Append a short entry to
`.pi/journal.md` covering what you did, what you verified, and what you left
open.
## 7. Commit and open the PR
Conventional Commits, imperative subject, ≤72 chars, scope optional. The
body explains *why*. Push the branch — never push to `main`, never
force-push.
Open the PR:
```
curl -sS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls \
-d '{"head":"<branch>","base":"main","title":"<subject>","body":"<body>"}'
```
The body states: what the issue was, what you changed and why, **which
verification tiers you ran and their results**, anything you deliberately
did not do, and `Closes #<n>`.
Then wait for CI (`ci.yml`, jobs `check` and `e2e`) and report the result on
the PR. If it fails, read the log — `gitea_ci`'s `job_logs` 404s on this
Gitea build, so use
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for per-step
status and `GET /api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs` for
the log — and fix it. Two consecutive failed CI runs on the same cause: stop,
comment what you know on the PR, and leave it for a human.
**Do not merge.** Comment on the issue linking the PR, leave
`Status/In Progress` on, and end the run.
## Finally
Report in three lines: which issue you took, what state it is in
(PR open / CI green / stopped and why), and any issues you filed.
+1 -13
View File
@@ -130,13 +130,7 @@ reference, because you need them *before* the failure, not after.
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice. **The same command carries
a second CSS check**: a nested rule whose selector starts with an
element name (`audio-player { … }` rather than `& audio-player { … }`)
is silently dropped by the device's Chrome 113 and by nothing else, so
every tier you can run renders it correctly. Run it after touching
`index.css` or any `css` literal; a rule directly inside a top-level
`@media` is not nested and is not flagged.
which its own warning had been read twice.
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment:
@@ -201,12 +195,6 @@ Two rules about climbing:
- **Do not write an e2e spec first.** Drive the flow by hand, then
promote it with `/e2e`. Specs written blind assert on selectors that
do not exist.
- **Not every view has a nav item.** Since #25 the destinations are
configurable, Autotag is hidden by default and Downloads is absent
until a download client exists — so `getByTestId('nav-<view>')` waits
30 s for a locator that will never resolve. `navigateTo(page, view)`
(`e2e/support/fixtures.ts`) dispatches the app's own `navigate` event.
Click the nav item when the *nav* is what the spec is about.
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
`make bindings-check`, `make css-check` and — from `frontend/`
@@ -8,29 +8,13 @@ This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Two facts that make failure invisible
## Three facts that make failure invisible
There were three. The first was that **Go's stdout does not reach
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
`slog` line the app wrote was discarded, including the one naming the
error it was about to exit on. That is fixed (#160):
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
selected in `main()` by build tag, and the app's whole diagnostic
stream now arrives under the `yellowjacket` tag, which `make
android-logs` filters for.
What remains true about it is the part that misleads: **`setprop
log.redirect-stdio true` still does not help**, because it redirects
the *Java* runtime's `System.out` and the Go code is a c-shared native
library. Nothing that reaches logcat here does so through stdout, so
anything printed with `fmt.Println` is still lost. Log with `slog`.
The tag is a fixed string rather than the application id, and that is
load-bearing rather than tidy: the debug build carries
`applicationIdSuffix ".dev"` so it can be installed beside the release
app, and it is the only build whose WebView can be inspected — so a tag
derived from the id would be filtered out on the one build anybody
debugging this app is running.
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
@@ -50,10 +34,7 @@ the wrong question. `make android-smoke` asks the right one — is it the
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths — and
since #160, **read the `E/yellowjacket` line above it first**, because
every one of those paths logs the error before it exits. That line is
what #52 spent months without.
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
@@ -213,13 +194,9 @@ like the app's fault and none is:
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | **runs** (2026-08-20) | — |
| arm64, real device | — | unverified, still |
**A physical arm64 device remains the only verification path**, and it
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
launches and stays up; `make android-smoke SECONDS=60` passes on it.
What that run *found* is the lifecycle fault below.
**A physical arm64 device remains the only verification path.**
### What was fixed to get here
@@ -233,11 +210,6 @@ no-op. `backend/system` gained no import of the Wails application
package, which matters for the same reason `backend/events` is split by
the `indexbuild` tag.
**And `main()` is now latched to one run per process** (#52). That is
the second `os.Exit(1)` in this file's history and it had the same
signature as the first, which is the argument for #160: both were named
exactly by an `slog` line that went to `/dev/null`.
### What is still not done
The shell is still a desktop shell, and the x86_64 half of the APK is
@@ -277,25 +249,10 @@ one.
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
they are the right thing to reach for when you want something one-off:
> **These four were unsafe until #159 and are now the way in.** All of
> them began with `adb uninstall {{.APP_ID}}`, where `APP_ID` defaulted
> to `app.yellowjacket` — the **release** id — while `run` and
> `run:device` build the **debug** variant, whose id is
> `app.yellowjacket.dev`. So they uninstalled the user's app, taking
> the library with it, installed a different package, and then failed
> to launch the one they had removed.
>
> They share `scripts/android-deploy.sh` now, which **never**
> uninstalls (`install -r`, and a changed signing certificate is
> reported with the command rather than acted on), reads the package id
> back out of the built APK, and refuses a target that is not the kind
> the task names. There is nothing left to avoid; the manual sequence
> below is kept because it is still the smallest thing that works.
```
wails3 task android:run # debug build + emulator install + launch
wails3 task android:run:device # debug build + install + launch on a phone
wails3 task android:deploy-device # release build, same
wails3 task android:run:device # same, first connected physical device
wails3 task android:deploy-device # production APK to a device
wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list
@@ -303,16 +260,6 @@ wails3 task android:logs:all
wails3 task android:clean
```
**`run` and `deploy-emulator` mean the emulator, and now say so to
adb.** They used a bare `adb install`, which with exactly one device
attached picks that device whatever it is — so with a phone plugged in
and no emulator running, the task whose summary reads "in the Android
Emulator" installed on the phone. They pass `--target emulator` and
refuse with `make android-emulator` as the remedy.
**`DEVICE_ID=<serial>` still names a device, and several attached
devices is now an error rather than a silent pick of the first.**
Two are deliberately **not** wrapped. `android:logs` greps logcat for
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
@@ -322,51 +269,16 @@ instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced.
## The identity is read back from the APK
## The identity is declared twice
It used to be **declared twice**, and that is what #159 was.
`applicationId` in `build/android/app/build.gradle` is what Gradle
installs; `APP_ID` in `build/android/Taskfile.yml` was what every
adb-driven task uninstalled, launched and filtered, and nothing
enforced that they agree. They did not: the debug buildType carries
`applicationIdSuffix ".dev"`, so every task that assembles a debug APK
addressed the release id. This file flagged the hazard for five phases
and it cashed out twice — once as a wrong `am start`, once as an
uninstall of the user's library.
**`scripts/android-pkgid.sh` is the one answer now.** It prints the
package id an APK declares (`aapt2 dump packagename`, falling back to
`aapt dump badging`), and the deploy path installs and launches *that*.
The APK is the authority because the task that installs it has just
built it: whatever Gradle resolved the applicationId to, suffixes and
flavours included, is in the file, and no default can disagree with it.
An APK it cannot read is a hard failure, never a fallback to a written
down default — guessing is the bug.
**`APP_ID` survives as an assertion, not a setting**, and has no
default. `wails3 task android:run APP_ID=app.yellowjacket` says "this
build had better declare that id" and is refused, naming both, *before*
anything is installed or a device is even chosen. It could never have
been a setting: `ANDROID.md`'s advice to put it in `build/config.yml`
does not work in beta.8 — `wails3 task` never reads that file (verified
with `--dry`) — and even when set it fed only the adb commands, never
Gradle.
`scripts/android-emulator.sh` derives `PKG` the same way, from
`bin/yellowjacket.apk` when one is built, so `make android-install`,
`android-launch`, `android-logs` and `android-smoke` follow whichever
variant is actually in `bin/`. `YJ_ANDROID_PKG` still overrides, and
the old literal survives only for a tree with no APK built yet.
**The uninstall is gone and is not coming back.** It existed to make
the bare `install` on the next line work at all — without `-r` Android
refuses an install over an existing package — so `install -r` removes
the *reason* for it rather than merely removing it. What is left is the
one case an uninstall really is the remedy, a changed signing
certificate, and that is exactly the case where performing it silently
costs the user their library. So it is named and not done, which is the
answer `scripts/android-emulator.sh` had already reached for
`make android-install`.
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
adb-driven task uninstalls, launches and filters. **Nothing enforces
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
that file (verified with `--dry`), and even when set it feeds only the
adb commands, never Gradle. Change both or the official `run`/`deploy`
tasks address a package that is not installed.
Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is
@@ -375,27 +287,6 @@ resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the
fully-qualified form.
**`wails3 task android:run:device` is the way to put a debug build on a
real device**, since #159. What #52 used, before it was safe, was the
longer form, and it is still the smallest thing that works if you want
no script between you and adb:
```bash
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
adb install -r bin/yellowjacket.apk # -r, never uninstall
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
```
The id in that last line is the one thing to keep an eye on by hand —
`./scripts/android-pkgid.sh bin/yellowjacket.apk` is what the tasks ask,
and it is a good habit before any `am start` written out in full.
`YJ_ANDROID_PKG=app.yellowjacket.dev` still overrides what
`scripts/android-emulator.sh` — and therefore `make android-smoke`,
`android-logs`, `android-launch` — addresses, but it is rarely needed
now: that default is read from `bin/yellowjacket.apk`, so it already
follows whichever variant was built last.
## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them
@@ -420,91 +311,6 @@ system bars, the back gesture, focus and audio interruptions,
permission dialogs, the keyboard — not about what the app draws. The
drawing is what the other five tiers already cover.
**The third such fault was the activity lifecycle** (#52), and it is
the one to re-check after touching `main()`, `WailsBridge` or
`MainActivity`. Android destroys and recreates an activity **without
restarting the process**, and Wails' `nativeInit` — which
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
is still true behind Android's `select{}`), and the `os.Exit(1)` under
it took the healthy first app down with it.
### The lifecycle check, and how to trigger it on demand
This is the regression guard for #52 on this tier, because no other
tier runs `main()` on Android at all. The Go-side guard
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
latch; only the device catches the latch not working.
**Trigger a relaunch with a configuration change the manifest does not
declare.** `AndroidManifest.xml` lists
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
in-place and are *not* triggers. `fontScale` is not listed, and it is a
one-liner:
```bash
adb shell settings put system font_scale 1.15 # restore the old value after
```
That is the same in-process destroy/recreate that "Don't keep
activities", a locale change and a memory trim produce, but on demand.
**"Don't keep activities" is the report's own lever and did not work on
this device**: `settings put global always_finish_activities 1` reads
back as `1`, `am set-always-finish-activities` does not exist on this
build, and the activity was never finished on backgrounding. Do not
spend an afternoon on it; use the config change.
**The assertion is the pid, and the tell is two bridge inits in one.**
```bash
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
```
Healthy is one pid appearing twice — the process surviving the
recreation:
```
I/WailsBridge(28420): Wails bridge initialized
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
```
Broken is that pair followed within a second by:
```
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
```
Two things about reading that. **`has died: fg TOP` is not a memory
kill** — the system does not reclaim the foreground process, so this is
the app leaving of its own accord. And there is **no crash record
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
and it is why "the system killed it" is the wrong first hypothesis.
**Surviving is only half of it — check the recreated WebView is still
wired to the running app.** A plausible-looking fix (making
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
keeps the process alive and silently breaks this, because `nativeInit`
is also what re-points the JNI reference at the new bridge. Go would go
on executing JavaScript against the destroyed activity's WebView: the
app opens, renders, and never receives another backend event.
Ask the page, after a relaunch and a resume:
```bash
make android-inspect
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
# background and foreground the app, then:
make android-eval EXPR='JSON.stringify(window.__probe)'
```
A healthy build answers with events from the live services —
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
`[]` means the bridge reference is stale.
## Asking the device, not just looking at it
A real phone can be inspected, and that turns this tier from "reported
@@ -534,130 +340,6 @@ Four things about it, each of which costs an hour if met cold:
script. Plug in over USB for anything longer than a couple of probes.
- **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered.
- **A reinstall resets the runtime permissions**, and the grant dialog
is a separate activity that takes focus — so the app is up, `am start`
reports "delivered to currently running top-most instance", and
`pidof` is empty because it never got to the foreground.
`dumpsys window | grep mCurrentFocus` naming
`GrantPermissionsActivity` is the tell. `adb shell pm grant
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it.
### Getting the app into a state worth measuring
A fresh install is **not** a neutral starting point, and three things
about it will each cost you a measurement.
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI and is *real* here, so the app spends its
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
screen** while it does. Every vertical number taken in that state is
wrong -- one #51 measurement had the album art at 0px and it was
entirely this.
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
cleanly. **It then starts again within seconds.** So stop it
*immediately before* the measurement rather than once at the beginning,
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
that otherwise keep the band open.
**A library added over the bridge does not dismiss the first-run
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
checks for an existing library once, on mount, and its "Get Started"
button gates on a directory chosen *in the wizard* -- so it stays up
with a correctly disabled button over everything you are trying to
measure. Nothing is broken; reload the page and it is gone. This reads
exactly like a tap being swallowed, which is the expensive part.
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
`AddLibrary` takes the plain path; a push into
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
there. Some builds additionally want
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
the app opens the *system* "All files access" screen on launch -- so
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
that, not a crash.
### A note on quoting `make android-eval`
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
snippet containing a string literal -- breaks in a way that reads as a
JavaScript error. Put the expression in a file and pass it positionally:
```bash
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
```
That is the same script `make android-eval` wraps, so nothing is lost.
Two things worth knowing about it: it does **not** await a promise, so
an async call has to park its result (`window.__r = ...`) and be read
back in a second eval; and the shim from the section below is lost on
every reload and every app restart, along with the devtools socket,
whose name carries the pid.
### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth
knowing before an hour is spent on it. The WebView cannot deliver a
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
calls through the `addJavascriptInterface` bridge instead: the
@wailsio/runtime installs a `customTransport` that calls
`window.wails.invokeAsync(id, payload)` and receives the answer on
`window._wailsAndroidCallback`. Two consequences:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`, which sees nothing here, and its
`call()` posts to `/wails/runtime`, which answers
`Invalid runtime call: missing object value` — the interceptor got the
URL with no body. Its *inbound* half is still right, because
`dispatchWailsEvent` is the entry point in every mode.
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
the bundle captured its reference at module scope, so a wrapper
installed afterwards records nothing. That is why the harness is an
`initScript` and not a step in a spec.
What works is to borrow the bridge, chaining the runtime's own callback
so its pending calls still resolve:
```js
const pending = new Map();
const prev = window._wailsAndroidCallback;
window._wailsAndroidCallback = (id, response, error) => {
if (!pending.has(id)) return prev && prev(id, response, error);
const p = pending.get(id); pending.delete(id);
const env = JSON.parse(response || "{}");
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
};
window.__yj = { call(name, args) {
return new Promise((resolve, reject) => {
const id = "yj" + Math.random().toString(36).slice(2);
pending.set(id, { resolve, reject });
window.wails.invokeAsync(id, JSON.stringify({
object: 0, method: 0, windowName: "",
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
clientId: window._wails.clientId,
}));
});
} };
```
That turns the device into a tier that can be *driven* rather than only
looked at — `__yj.call("player.Player.LoadFile", [path])` and
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
#53 was measured. Names are the Go ones (`GetTracks`, not
`GetAllTracks`); an unknown one comes back as a plain
`unknown bound method name`, so a wrong guess is loud.
**Getting audio onto the phone**: `adb push` into
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
files are not there — scoped storage. `/sdcard/Music/...` plus
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
plain path. The generated fixtures are **~2 seconds** each, which is
fine for a scan and useless for watching a seek bar, so synthesise a
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
**And the reason to bother: the phone is an engine, not a screen.** The
first device here renders in **Chrome 113** at 424x439 CSS px. Every
-1332
View File
File diff suppressed because it is too large Load Diff
@@ -1,327 +0,0 @@
# 018 — Supported sizes, and what the queue panel is
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
**Status:** complete — #24 shipped as PR #132, and the matrix's last
unkept promise closed with #69.
#73 puts this first in Phase 2 and hangs the rest of the phase off it,
so the decision has to be written down and arguable before any CSS
moves. This document is the decision. Everything below the matrix is
either a measurement or an argument for one of the four choices #24
asks for.
---
## What is actually wrong, measured
Against the running app (`make dev-headless SEED=default`, Chromium),
Playlists, sweeping the viewport with the queue open and closed. The
number that matters is how much of the page header survives.
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|---|---|---|---|---|---|
| 1280×800 | 200 | open 321 | 759 | 759 | — |
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
| **900×600** | 200 | open 321 | **379** | 747 | **all three** |
| 800×600 | 56 | open 321 | 423 | 747 | all three |
| 700×600 | 56 | open 321 | 323 | 747 | all three |
| 390×780 | — | open 321 | **69** | 747 | all three |
| 320×600 | — | open 321 | **0** | 747 | all three |
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
| **800×600** | 56 | closed | 744 | 747 | **New Smart Playlist (158/162px)** |
| 320×600 | — | closed | 320 | 747 | all three |
Five things in that table are not in the issue.
**The header clips at the supported minimum with the queue closed.**
At 800×600 — the size `backend/config/window.go` enforces and the only
size this app *promises* — "New Smart Playlist" loses 4px of its 162.
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
header overflows on its own at the minimum window.
**900×600 is worse than 800×600, because the sidebar expands at 900.**
`AUTO_COLLAPSE_VIEWPORT` collapses the sidebar to icons *below* 900, so
at 899px the main panel is 843px and at 900px it is 700px. The worst
desktop case is therefore not the minimum window; it is the pixel
immediately above the collapse. Anything that tests "the minimum" and
stops has not tested the worst case, which is what
`layout-overflow.spec.ts` does today.
**At phone widths the queue is not a drawer, it is an amputation.**
`queue-panel`'s host is `flex-shrink: 0; width: 0`, going to
`width: var(--queue-width, 320px)` under `[open]` — it is *in the flow*
of `.content-area`, so it takes its width from the main panel rather
than covering it. At 390px that leaves 69px of the page; at 320px it
leaves **0px**, and the app is not degraded but gone. This is the
measurement #55 needs and did not have.
**Only Playlists overflows.** Sweeping all ten primary views at 900×600
and at 390×780, every other header reports `scrollWidth ==
clientWidth`, and Albums at 390px renders title, count and sort
legibly (checked on a screenshot, not just the number). #69 is
therefore one view's action set — three text buttons totalling 390px —
and not a systemic header failure, though the *rule* still belongs in
`page-header`.
**Both reasons in `MinWidth`'s comment are stale.** It says the floor is
800×600 because "below ~780 the header's subtitle wraps" and "below
~600 tall the eleven sidebar items no longer fit". The subtitle is
`display: none` below 900 (index.css), and the sidebar host is
`overflow-y: auto` — at 600×460 its `scrollHeight` is 434 against a
332px client, and Settings is reachable after scrolling. Neither
mechanism can happen any more. That does not mean the floor should
move; it means its stated reason no longer supports it, which is worse
than either answer.
*(Care needed: my first probe for the sidebar scroller searched
`shadowRoot.querySelectorAll('*')` and reported "items are
unreachable", because the scroller is the **host** and a host is not in
its own shadow root. The claim in CLAUDE.md is correct.)*
---
## Decision 1 — the supported size matrix
Three bands. Two of them already exist and are already argued; what is
new is that they are written down as a *promise*, and that the queue is
part of it.
| band | width | navigation | queue | promise |
|---|---|---|---|---|
| **Phone** | < 600 | `bottom-nav` + drawer | overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
| **Compact** | 600 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
| **Desktop** | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
And one promise across all three: **no action is ever unreachable.**
That is the sentence #69 asks for and it is the one the matrix exists
to make checkable.
**400% zoom** keeps the meaning it already has: WCAG 1.4.10 names 320px
as the reflow target, the phone band covers it, and
`layout-overflow.spec.ts` already asserts a 320px viewport needs no
sideways scrolling. What changes is that the *queue* must be part of
that assertion — it is not today, and with the queue open at 320px the
main panel is 0px wide, which no current test can see.
**The window minimum stays 800×600**, and its comment gets the real
reason. The old mechanisms are gone, but the floor is still where the
Compact band's chrome stops being comfortable, and lowering it would
mean promising the desktop layout at sizes where only the phone layout
works. The interesting consequence is decision 4.
---
## Decision 2 — the queue is an overlay when it cannot afford to be a column
**The rule.** The queue panel renders inline — in the flow, as today —
only while
```
viewport sidebar queueWidth ≥ 480
```
and as an overlay with a scrim otherwise.
**Why it cannot be a media query**, which is the load-bearing half:
the queue's width is *user state*. It is drag-resizable between 200 and
500px and persisted (`--queue-width`, `MIN_WIDTH`/`MAX_WIDTH` in
`queue-panel.ts`). A breakpoint at a fixed viewport width silently
assumes the default 320, and is wrong by 180px for a user who has
dragged the panel wide — in the direction that hurts, since a wider
queue is exactly when the content can least afford it. So the mode is
computed from the measured widths and published as an attribute, the
way `data-active-view` already is, and the CSS keys off that.
**Why 480, honestly.** There is no cliff to derive it from. The track
list rescales its columns continuously — at main widths from 900 down
to 544 its `--grid-cols` shrink from 213px to 124px with
`rowOverflow=0` throughout — and the album grid steps 3 columns to 2
somewhere between 564 and 644 without breaking. So this is a judgement,
anchored on two things: it keeps the *default* window (1100 wide, main
= 580) inline, because the inline queue is a desktop affordance people
choose and turning it into an overlay for the common case would be a
regression in feel; and it puts every case measured as broken —
900×600 at main=379, and every phone width — on the overlay side.
1024×768 lands at main=504 and stays inline.
**The scrim is the other half of the issue's complaint** ("make the
queue obviously an overlay *over* the content so it reads as something
to close"). An overlay queue gets a scrim, closes on scrim click and on
Escape, and returns focus to `#queue-button`.
**What must not change**: #55's Direction is explicit — one component,
two mount points, do not fork it. The overlay is a *presentation* of
the same `queue-panel`, so the roving tab stop, Alt+Arrow reorder, drag
reorder, selection semantics and the `virtualizer.requestUpdate()` on
selection and current-track change all come along untouched. This
decision deliberately stops short of #55's detail-view mount, but it is
the shape that makes it possible, and it unblocks it.
---
## Decision 3 — #69 is its own PR, and here is the finding that decides it
`page-header` **cannot collapse its own actions**, and that is not an
effort estimate but a fact about the API. Actions arrive through
`<slot name="actions">` as arbitrary light-DOM markup — Playlists slots
a `<div class="header-actions">` of three `<button>`s with click
handlers, drag handlers and a conditional class. A component cannot
move another component's light-DOM children into a dropdown and keep
their behaviour; there is nothing generic to render as a menu item.
So the overflow rule needs an *actions API* — hosts declaring
`{icon, label, handler, priority}` data that `page-header` can render
either as buttons or as menu items — which is a change to all three
hosts that slot actions, not a rule added in one place. That is a
different piece of work from this one, it is independently verifiable,
and the desktop half of #69's symptom is removed by decision 2 anyway
(the queue stops eating the header's width).
It therefore stays #69, gets the finding above recorded on it, and
follows immediately after this. What *this* plan owes it is the
promise in the matrix — no action unreachable at any supported size —
and the measurement that the only offender today is Playlists.
**And the promise is not kept yet, which is the honest version of a
claim this document made in its first draft.** "Decision 2 removes the
desktop half of #69's symptom" was too strong. Measured after phase 2,
at 900×600 on Playlists:
| | before | after |
|---|---|---|
| queue open | main 379px, **all three** actions clipped | main 700px, **one** clipped |
| queue closed | main 700px, one clipped | unchanged |
So the queue's *contribution* is gone — open and closed are now
identical, which is the whole of what this decision owed — and the
residual "New Smart Playlist: 114/162px" is the header overflowing on
its own, at a size the queue never touched. #69 is still a live defect
at a supported size, and the matrix's promise is what will close it.
---
## Decision 4 — a very small window becomes the phone layout, not the mini-player
#24 asks whether a very small window should switch to the mini-player
(#12) "or simply refuse to go there". Both options in the question are
worse than the one the codebase already has.
**#12 is a second window, not a mode.** Its findings say so: v3
supports multiple windows, `AlwaysOnTop` is a window *option*, and the
frontend would need an entry branch mounting only the mini-player root
for a second window loading the same bundle. Turning the main window
into a mini-player at some width conflates the two: it would throw away
the user's navigation state on a resize, and it puts the MPRIS question
(#12's own open question — media controls are process-level and must
not be per-window) on a code path that a drag can trigger by accident.
**And "refuses" is unnecessary, because the reflow already exists.**
The phone band is real, tested, and reached by width alone — a desktop
window narrowed below 600px already gets `bottom-nav` and the phone
shell. That is a better answer than refusing: it is strictly more
usable than a hard minimum, it costs nothing new, and it is the same
code Android runs, so it stays exercised.
So: the main window reflows and never becomes a mini-player; #12 stays
a separate always-on-top window and is not blocked by, or coupled to,
this decision. The window minimum stays 800×600 for the reason in
decision 1 — but the phone band is what happens below it, not a
refusal, which is why the minimum is a comfort floor rather than a
correctness one.
---
## Phases
1. **This document**, linked from #24, with the matrix reported on the
issue and #55 told whether it is unblocked. *(no code)***done**
2. **The queue's overlay mode** — computed mode attribute, scrim,
Escape and scrim-click close, focus return. The inline path is
unchanged above the threshold. — **done**
3. **The window minimum's comment** — replace both stale reasons with
the measured ones. No value change. — **done**
4. **Verification**, below. Including the specs that must change
because they assert the old behaviour. — **done**
#69 follows as its own branch; #55 became unblocked at phase 2.
## What landed, measured
Main panel width with the queue open, before and after:
| viewport | before | after | mode |
|---|---|---|---|
| 1280×800 | 759 | 759 | inline |
| 1100×720 (default window) | 579 | 579 | inline |
| 1024×768 | 503 | 503 | inline |
| 900×600 | **379** | **700** | overlay |
| 800×600 | 423 | 744 | overlay |
| 390×780 | **69** | **390** | overlay |
| 320×600 | **0** | **320** | overlay |
The scrim is perceptible but subtle on a dark ramp, which is worth
knowing before someone "fixes" it: sampled from the screenshots at
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
row's text 242 → 133. It covers the **content area only** — not the
sidebar or the transport — on purpose: the queue is not modal, and
leaving the navigation live means the scrim reads as "this is over the
content" (which is what #24 asked for) without pretending the rest of
the app is unavailable.
## What #69 did with the promise, and one thing this plan got wrong
#69 landed on its own branch as decision 3 said it would, and the
matrix's *no action is ever unreachable at any supported size* is now
kept rather than promised. Measured on Playlists, actions clipped:
| viewport | before #24 | after #24 | after #69 |
|---|---|---|---|
| 900×600, queue open | all three | one (114/162px) | none |
| 900×600, queue closed | one | one | none |
| 800×600, queue closed | one (158/162px) | one | none |
| 390×780 | all three | all three | none |
| 320×600 | all three | all three | none |
The shape was the one decision 3 predicted — an actions API first, an
overflow rule second — and all three hosts that slot actions migrated.
**What this document got wrong is smaller and worth keeping.** Decision
1 says the header's minimum is a *comfort* floor and that only the
queue and the actions compete for the header's width. They are not the
only two: every child of that flex row was `flex-shrink: 0`, so
whatever came last lost, and the actions come last. At 320px the sort
control alone is 172px of the header — so with every action already
collapsed into the menu, the *menu button* was 76px off the right edge.
The promise was still broken with nothing left to collapse.
That is why #69 also had to decide what gives way: the title (which the
navigation also states) and, below 600px, the word "Sort:" (which the
direction arrow implies). Neither is an action, which is the rule the
matrix actually encodes — **an action is a capability and everything
else on that row is a label.**
## Verification, and what each tier cannot see
- `make ui-test` — the queue panel's mode logic is component-tier
work and belongs there. It **cannot** see the shell: the threshold is
computed from the sidebar and viewport, which do not exist in that
tier.
- `make e2e``layout-overflow.spec.ts` gains the queue-open case at
every band (it has none today, which is why main=0px at 320px has
never failed anything) and **gains 900×600**, since the minimum is
not the worst case. `queue-toggle-state.spec.ts` and
`phone-shell.spec.ts` both touch the panel and must be re-read before
editing.
- **Screenshots at every band, read by a human.** This is not optional
here: `layout-overflow.spec.ts` asserts the *shell* needs no sideways
scrolling and passes on a build whose album header clips its own
buttons (measured this session at 390px; filed on #66). Clipping
*inside* a component is invisible to it, and clipping is this issue.
- `make ui-visual` **cannot help at all** — the component tier renders
the token fallbacks, because the theme only reaches `:root` in the
real app.
- Accessible names via `page.getByRole(...)`, never a shadow-root
query. A drawer with a scrim is exactly the shape that grows a
nameless control, and this repo has shipped one three times.
@@ -1,408 +0,0 @@
# 019 — The Android touch model
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
being diverged from)
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
#73 puts #60 first in Phase 4 because it is "the presentation every
other item needs", and this is the next one. The Direction on #63 asks
for the interaction model to be designed as one piece before any of it
is built, because it *reassigns an existing gesture* rather than adding
one — `utils/long-press.ts` currently owns the 500ms hold, and every
context menu in the app is downstream of it.
This document is that design. Everything below is a measurement, or an
argument for one of the choices #63 leaves open.
---
## The mapping
| gesture | pointer is a finger | pointer is a mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press (500ms) | **enter selection mode** | — |
| right-click | — | context menu |
| swipe right | **add to queue** | — |
| drag | reorder / drag to playlist | reorder / drag to playlist |
Three of those are #63's report unchanged. Two are decisions it left
open, and one is a deliberate divergence.
---
## Decision 1 — the predicate is the pointer, not the platform
#63 says "the row component needs a platform-aware interaction layer
rather than shared handlers". It needs an interaction layer; it should
not be platform-aware.
**The question a row has to answer is not "am I on Android" or "is the
viewport under 600px" but "what made this event".** `pointerType ===
'touch'`, read off the event that is being handled, which is already
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
and is the only such test in the frontend today.
This is #64's rule — the predicate is named after the capability, not
the platform — and it carries #64's warning with it. Keyed on a width:
- an Android **tablet** at 600px or more gets click-selects /
double-click-plays on a touchscreen, which is the exact inversion
this issue exists to fix, on the platform it exists for;
- a **touchscreen laptop** cannot be described at all, because both
pointers are live in the same session on the same row;
- and a narrow desktop window gets phone semantics with a mouse.
Per event, all three are right for free, and there is no second
declaration of what a phone does — the thing CLAUDE.md declines to add
every time it comes up.
**Measured, so this is not an assumption about the WebView.** On the
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
driving a real tap with `adb shell input tap`:
```
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
```
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
5, and `(pointer: coarse)` / `(hover: none)` both match.
---
## Decision 2 — there is no double-tap, and the number is why
#63 asks for *single tap → play* **and** *double tap → context menu*.
Those two cannot both be honoured. The first tap of a double tap is
indistinguishable from a single tap until the interval expires, so
"tap plays" necessarily becomes "tap waits to find out whether you
meant something else, then plays". The app already owns that constant:
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
= 250` for precisely this reason.
**What it would be added to, measured on the device.** Six runs, from
the play command to the backend's `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
So the app's primary interaction is ~100ms, and a double-tap
discriminator makes it ~350 — **3.5x, of which 250ms is spent
deliberately doing nothing** — paid on every track anyone ever plays,
in order to reach a menu.
It is also against the platform's convention, which counts for more
than usual here because this is the phone build and nothing else:
long-press is *how you select* on Android (Gmail, Files, Photos),
double-tap is zoom or nothing, and a list's menu is either the
long-press sheet or a per-row overflow.
**So the menu and the selection action bar become the same surface**,
which is the convention and removes a concept rather than adding one.
Long-press selects the row it was made on and raises the action bar;
the bar's actions *are* the context menu's actions, contextualised to
whatever is selected — one row or forty. #60's bottom sheet stays
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
`menu-surface` are reused rather than reimplemented.
---
## Decision 3 — tap-to-play and selection mode ship together
The obvious phase order is "tap plays first, it is the smallest
change". It is wrong, and the reason is a capability that exists today
and is easy to miss.
**A touch user can already multi-select**: tap selects (the desktop
semantics, which a finger currently gets), and the long-press menu then
acts on the selection. Move tap to play without shipping selection mode
in the same change and there is a window — a release, if it lands — in
which selecting forty tracks to add to a playlist is impossible on a
phone. That is a regression dressed as an increment.
So phase 1 is both, or neither.
---
## What the code looks like now
| surface | how it binds | selection |
|---|---|---|
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
| `queue-panel` | delegated, same shape | `SelectionController` |
| `playlist-details` | per row | `SelectionController` |
| `smart-playlist-details` | per row | `SelectionController` |
All four already share `SelectionController`, and all four resolve a
row from an event by `data-index` / `data-file-path` on the row. So the
gesture layer has one shape to talk to, and "selection mode" is a flag
on the controller they already have rather than a fifth concept.
`utils/long-press.ts` is one document-capture listener that synthesises
a `contextmenu` — the seam that needed no component to opt in. **This
plan keeps that shape and changes what the gesture means**, which is
why it is a rewrite of that file rather than a second listener set: two
document listeners both claiming the 500ms hold is the fault the file's
own header warns about.
---
## Two measurements that decide the implementation
**`touch-action` is `auto` on both the virtualizer and the rows.** With
`auto` the browser owns panning on both axes, so a horizontal drag can
be claimed as a scroll and our gesture ends in `pointercancel`
mid-swipe. A row that wants a horizontal swipe has to declare
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
virtualizer's scroll, and must stay native or the list stutters) and
hands us the horizontal axis. This is the single most likely way for
swipe-to-queue to "work in Chromium and not on the phone".
**The row is 424x52 on the device**, so a swipe threshold in px is a
fraction of a row height, not of a screen.
**And the third one was found by building phase 1 and then running it**
— it is not something any browser tier can report. Chrome 113's Android
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
stood down when a trusted one arrived, which was right while both paths
ended in the same place; once a hold can mean selection mode they end
in different places, and standing down means the gesture silently does
the *old* thing. Measured, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced at all, the context menu opened,
and all 26 tests in the component tier passed — dispatched pointer
events do not make a browser synthesise a `contextmenu`.
So the browser's event is a **trigger, not a competitor**: the gesture
is announced from it, and only a component that claims it suppresses
the native menu. Unclaimed, it propagates untouched. That is the same
"browser wins" outcome, reached by asking instead of assuming — and
verified both ways on the device, a track row entering selection mode
and an album card still opening its menu.
The tier could not *find* it and can *hold* it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than `isTrusted`, so an untrusted one from a test takes
exactly the browser's path.
---
## A tier note: this one can be driven, not only measured
`adb shell input tap|swipe` reaches the WebView as real pointer events,
which the log above is evidence of. So for the first time the Android
tier can *perform* the thing under test rather than describe the page
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
is `input swipe X Y X+N Y 120`.
Device CSS pixels from device pixels, on this phone:
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
horizontally (measured from the tap above: 200,300 arrived as 78,94).
This does not make the device a spec tier — it does not run in CI and
`make ui-test` still has to carry the assertions. It makes "does the
gesture actually fire on Chrome 113" answerable in seconds.
---
## Phases
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
recognition of tap / long-press / horizontal swipe, dispatched as
composed custom events so a delegated listener in any shadow root
still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
finding above and a reveal-and-snap affordance. **Shipped**; what the
device said about it is the section below.
**Phase 3 — the other three surfaces**, which is mostly wiring, since
they already share the controller. **Shipped**, and it was not entirely
wiring — see below.
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
row are a single-click target inside a row whose single tap now plays;
that conflict is #67's, and this plan should not pre-empt its answer
beyond making tap-to-play win on touch. **Shipped.**
## Phase 3 was not symmetric, in two places
**A tap on a queue row plays that position**, not the list. Copying
`track-list`'s tap — which sets the queue to the list the row is in —
would rebuild the queue *from* the queue, discarding its source, its
shuffle order and everything a user had inserted by hand. It reads as a
no-op and is not one.
**The queue panel has no swipe, deliberately.** A right swipe means
*add to the queue* everywhere else it exists, and a queue row is
already in the queue; the only thing it could mean there is *remove*,
which is the same gesture with the opposite effect one screen away —
the fault `utils/icon-language.ts` exists to have fixed for glyphs.
Removing a queue row is on the row itself (the ×), on its bottom sheet
since #60, and on the selection bar this phase gave it. The assertion
is that its rows do **not** carry `data-swipe`, so a swipe there cannot
silently become a second meaning for the app's one horizontal gesture.
And the affordance became `utils/swipe-to-queue.ts` rather than being
copied twice. Three lists want it; three copies of "how far is far
enough" is three chances for them to disagree, which is what
`utils/library-status.ts` and `utils/ownership.ts` each exist to have
stopped happening. The shared stylesheet is keyed on `[data-swipe]`
rather than on a class name, because the three lists call their rows
two different things and the `touch-action` half of the device fix has
to reach all of them.
## Phase 4 was already true, which is why it is asserted
A claimed tap has its click swallowed at document capture, so an
`explore-link` inside the row never sees one and tap-to-play wins with
no rule of its own. Nothing in the suite would have failed if that
stopped covering the link, and the symptom — tapping a track's *title*
navigating to its album instead of playing it — is one a phone user
meets constantly and a mouse user never does.
**Its test was vacuous when written**, in the way this file keeps
finding: the tap helper dispatched `pointerdown` and `pointerup` and no
`click`, so there was nothing to swallow and the assertion held on any
build. It sends the trailing click now, which also strengthened phase
1's "a tap plays and does not also select". The fixture needed an MBID
for the same reason — without one the link asks the backend for a local
album first and gives up when nothing answers, so "it did not navigate"
was true of a working build and a broken one alike.
---
## What phase 2 measured, which was not what phase 2 predicted
The `touch-action` finding above is **half** of the answer, and
shipping only that half would have been the exact failure it warns
about. Driving a real finger with `adb shell input swipe` across a
track row, three values, all three on the device:
```
touch-action: auto pointerdown, 1 move, pointercancel
touch-action: pan-y pointerdown, 2 moves, pointercancel
touch-action: none pointerdown, 2 moves, pointercancel
```
`touchmove` kept firing in all three. So **Chrome 113's WebView
cancels the pointer stream ~16px into any drag whatever `touch-action`
says**, and a swipe recognised from `pointermove` — which is what the
rest of this module is built on — is a swipe that dies 16px in.
The other half is a **non-passive `touchmove` calling
`preventDefault()`**: with it, the same swipe ran to 12 moves and a
`pointerup` at full travel. And both halves are required, which was
measured rather than assumed — with the `preventDefault` in place and
`touch-action` back at `auto`, the gesture died after **one** move.
The reading is that `auto` lets the browser commit to a horizontal pan
on the first move past slop, before any threshold of ours can have
been crossed, while `pan-y` leaves it undecided long enough for the
second move to claim it.
`none` is the one value to avoid: the list stopped scrolling at all.
With the shipped pair, a vertical drag still scrolls the virtualizer
81px on the same run that a horizontal one survives.
**`draggable="true"` is not a competitor**, which is the other thing
the device was asked. No `dragstart` fires from a touch drag on this
WebView at all, so the drag-to-playlist attribute on every row needs no
pointer-type gate.
### And it found a phase 1 defect that no tier can see
The native `contextmenu` arrives in **either** order, and phase 1 only
handled one of them. `nativeSeen` covers the browser's menu arriving
*during* the hold. The reverse — our 500ms timer firing first, a
component claiming it, and Chrome delivering its own `contextmenu`
5070ms *later* — was suppressed by nothing, so the context menu
opened on top of the selection bar. Measured over four holds:
```
hold 1 yj-long-press, then contextmenu isTrusted=true menu open
hold 2 yj-long-press clean
hold 3 yj-long-press, then contextmenu isTrusted=true menu open
hold 4 yj-long-press clean
```
Two in four, on the one surface #63 exists to have changed, and
invisible to both browser tiers because neither synthesises a
`contextmenu` from a dispatched press. A press that has produced its
outcome now suppresses a late one whichever branch it took; six holds
on the fixed build, six clean.
### The rules phase 2 settled
- **A swipe is not a selection.** It queues the row it was made on,
unless that row is one of several *explicitly* selected — the same
rule the context menu answers with, because a bar reading "40
selected" beside a gesture that quietly queues one of them is two
answers to one question. It never changes the selection, which is
where it differs from a right-click.
- **Rightward only.** Nothing is bound to a leftward swipe and
claiming one would take a gesture away to do nothing with it.
- **The commit threshold is a fraction of the row** (0.3, floor 72px),
because the row is 424x52 on this device and a bare pixel count is a
fraction of a row height on one screen and a third of the width on
the next.
- **The affordance is not only a colour** (WCAG 1.4.1, the rule the
playing-row marker exists for): the pane carries the queue icon and
words, the words change at the threshold ("Add to queue" → "Release
to add" → "Added"), and the outcome is announced in a live region.
- **The row does not move; its cells do.** `.track-row` is
`contain: strict` with `overflow: hidden`, so a pane held at the
row's original position while the row translates is a pane at a
negative offset inside a clipping box and is simply not painted.
Sliding the cells needs no wrapper element in a row that is already
a grid.
- **The travel is written to the row's own style, not rendered.** One
render at the start, one at the threshold, one at the end; a
virtualizer re-rendering every visible row per frame of one finger's
travel is the thing `perf.m1` is about.
## Open questions
1. **Does selection mode have an escape other than the bar's own
close?** *Settled: Escape, here; back, not here.*
Escape leaves the mode, from `selection-bar` rather than from each
of the four hosts — that element exists only while the mode does, so
it is the one place a dismissal can be attached and detached with
the thing it dismisses. It is the same documented exception the
overlaid queue's Escape is: **a dismissal, not a shortcut**, so it
is not a panel-scoped binding.
The back gesture is the half that is *not* done, and deliberately.
The obvious version — `selection-bar` pushing a history entry — is
precisely the fault `navStack` was deleted for: the shell owns the
stack (#6/#55) and is the only thing that calls `pushState`, so that
two stacks cannot disagree about what one press means. Four lists
each reaching for `history` is four stacks. It is also wrong on its
own terms, since a mode is per-component and a user who enters one,
navigates away and returns has an entry for a mode that no longer
exists. #55 settled the shape for a *place*; a mode is not one,
which is why it could not simply inherit that answer.
What it wants is one shell-owned register of dismissible surfaces,
which would retro-fit the queue overlay, the dialogs and this alike
rather than adding a fourth private answer. **#200.**
2. **Does a tap on a row's favourite icon still toggle it in normal
mode?** *Settled in phase 1: yes.* A control inside the row keeps
its own tap — the gesture is simply not claimed there, so the click
behind it falls through untouched. It is the same rule the shortcut
service has for a focused control that owns a key, and it is what
keeps the 44px favourite target (#56) from becoming a 44px play
target. The queue row's × is the second instance of it.
+17 -1113
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -172,17 +172,12 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if the generated bindings are stale
@./scripts/bindings-check.sh
# Two CSS traps that report a long way from their cause, or not at all.
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. And a nested
# rule starting with an element name is dropped by the device's
# Chrome 113 in silence -- no tier here runs an engine that can see it.
# Instant.
# the suite failing to import. Four sessions, three plans. Instant.
.PHONY: css-check
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs
@cd frontend && node scripts/check-css-nesting.mjs
# .pi/ and CLAUDE.md document commands, and a doc that documents a
# command wrongly is worse than no doc: an agent runs it confidently.
-55
View File
@@ -1,55 +0,0 @@
//go:build android
// The write itself, and nothing else. Everything decidable off a phone
// is in androidlog.go; see the package comment for why.
package androidlog
/*
#cgo LDFLAGS: -llog
#include <stdlib.h>
#include <android/log.h>
*/
import "C"
import (
"log/slog"
"unsafe"
)
// The priorities in androidlog.go are android/log.h's own values, and
// these are what says so. A constant expression that would be negative
// does not compile as a uint, so a renumbered header fails the build
// here rather than logging everything at the wrong severity -- which is
// the failure that would otherwise be invisible, since logcat would
// happily print whatever number it was handed.
const (
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
)
// New returns the handler main() installs on Android.
func New(opts *slog.HandlerOptions) slog.Handler {
return NewHandler(opts, write)
}
// write hands one line to liblog.
func write(prio int, tag, msg string) {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
cMsg := C.CString(msg)
defer C.free(unsafe.Pointer(cMsg))
C.__android_log_write(C.int(prio), cTag, cMsg)
}
-250
View File
@@ -1,250 +0,0 @@
// Package androidlog routes slog to logcat.
//
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
// app writes with slog is discarded on that platform -- including the
// one naming the error it is about to os.Exit on. #52 is what that
// cost: a process that vanished with no tombstone, no AndroidRuntime
// stack and nothing in `logcat -b crash`, at Priority/Critical for
// months, whose entire diagnosis was one slog.Error main.go was
// already writing.
//
// The platform's own sink is __android_log_write, which is a handful
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
// runs, since the only toolchain that builds the android tag is a
// cross-compiler and the only thing that runs it is a phone. So the
// split here is the one backend/mediacontrols/androidpayload.go makes,
// pushed as far as it will go: **everything except the write itself is
// in this file, untagged**. The priority mapping, the formatting, the
// chunking and the handler's own attr and group bookkeeping are
// ordinary Go that `go test` exercises on any platform; android.go is
// fifteen lines that hand a string to liblog.
package androidlog
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"sync"
)
// Tag is what logcat labels these lines with.
//
// It is a constant of ours rather than the application id, because the
// debug build carries `applicationIdSuffix ".dev"` so that it can be
// installed beside the release app -- so a tag derived from the package
// name is a *different* tag on the one build that can be inspected, and
// the filter that is supposed to show these lines would hide them on
// exactly the build used to look for them.
const Tag = "yellowjacket"
// Android's priorities, from android/log.h. These are the values
// __android_log_write takes; android.go asserts at compile time that
// they still match the header, so a renumbered platform is a build
// failure here rather than a warning silently logged as an error.
const (
PrioVerbose = 2
PrioDebug = 3
PrioInfo = 4
PrioWarn = 5
PrioError = 6
PrioFatal = 7
)
// maxPayload is how much of one line liblog will carry.
//
// The kernel logger's entry is 4068 bytes for the tag, the message and
// their two NULs together, and what does not fit is **dropped without
// comment** -- so a long line would be truncated in the middle of the
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
// a continuation carries.
const maxPayload = 3500
// WriteFunc is the platform sink: one already-formatted line, at one
// priority, under one tag.
//
// It is a parameter rather than a package-level function so that the
// handler can be driven by a test on a machine with no liblog at all.
type WriteFunc func(prio int, tag, msg string)
// Priority maps a slog level onto an Android one.
//
// slog's levels are open -- a caller may define its own at any int --
// so this is a banding rather than a lookup: anything below Info is
// debug, anything at or above Error is error. A custom level between
// two of the standard ones lands in the band beneath it, which is what
// slog's own level naming does.
func Priority(level slog.Level) int {
switch {
case level < slog.LevelDebug:
return PrioVerbose
case level < slog.LevelInfo:
return PrioDebug
case level < slog.LevelWarn:
return PrioInfo
case level < slog.LevelError:
return PrioWarn
default:
return PrioError
}
}
// Handler formats records with slog's own TextHandler and hands each
// line to a WriteFunc.
//
// It delegates the formatting rather than doing it, because WithAttrs
// and WithGroup are the half of slog.Handler that is easy to get subtly
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
// What it does own is what logcat needs and TextHandler does not know
// about: the priority, and the fact that a line has a maximum length.
type Handler struct {
write WriteFunc
// mu guards buf, which the delegate writes into. slog.Handler is
// documented as safe for concurrent use.
mu *sync.Mutex
buf *bytes.Buffer
delegate slog.Handler
}
// NewHandler builds a handler over an arbitrary sink.
//
// The time and the level are dropped from the formatted line: logcat
// stamps every entry with both, and repeating them costs a quarter of
// the width of a phone-sized terminal to say the same thing twice.
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
buf := &bytes.Buffer{}
inner := &slog.HandlerOptions{}
if opts != nil {
*inner = *opts
}
user := inner.ReplaceAttr
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && isBuiltin(a) {
return slog.Attr{}
}
if user != nil {
return user(groups, a)
}
return a
}
return &Handler{
write: write,
mu: &sync.Mutex{},
buf: buf,
delegate: slog.NewTextHandler(buf, inner),
}
}
// isBuiltin reports whether an attr is slog's own time or level,
// rather than a caller's attribute that happens to share the name.
//
// ReplaceAttr cannot tell those apart by key. It is called with an
// empty group path for the built-ins *and* for every top-level
// attribute, so a key comparison alone silently eats a caller's own
// "level" or "time" -- which is not hypothetical: the probe that
// verified this package on the device logged one, and the attribute
// vanished. The kinds are what separate them, because slog builds the
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
// and an attribute value of type slog.Level is not something a caller
// passes by accident.
func isBuiltin(a slog.Attr) bool {
switch a.Key {
case slog.TimeKey:
return a.Value.Kind() == slog.KindTime
case slog.LevelKey:
_, ok := a.Value.Any().(slog.Level)
return ok
default:
return false
}
}
// Enabled reports whether the level is worth formatting.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.delegate.Enabled(ctx, level)
}
// Handle formats one record and writes it out, in as many entries as
// its length demands.
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.buf.Reset()
if err := h.delegate.Handle(ctx, rec); err != nil {
return err
}
prio := Priority(rec.Level)
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
h.write(prio, Tag, line)
}
return nil
}
// WithAttrs returns a handler carrying the given attributes.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h.derive(h.delegate.WithAttrs(attrs))
}
// WithGroup returns a handler that qualifies subsequent attributes.
func (h *Handler) WithGroup(name string) slog.Handler {
return h.derive(h.delegate.WithGroup(name))
}
// derive shares the buffer and its mutex with the parent.
//
// They must be shared rather than copied: the delegate returned by
// WithAttrs writes into the *same* buffer this one does, so a second
// mutex would guard nothing and two loggers derived from one would
// interleave their bytes into a single line.
func (h *Handler) derive(delegate slog.Handler) *Handler {
return &Handler{
write: h.write,
mu: h.mu,
buf: h.buf,
delegate: delegate,
}
}
// Chunk splits a formatted record into entries liblog will carry
// whole.
//
// A record short enough to fit is returned as it is, which is nearly
// every record; the numbering only appears where something was going
// to be silently truncated anyway. It splits on bytes rather than runes
// because the limit is a byte count -- a multi-byte rune straddling the
// boundary is a mojibake character in a log line, against a lost one.
func Chunk(msg string) []string {
if len(msg) <= maxPayload {
return []string{msg}
}
var parts []string
for rest := msg; rest != ""; {
n := min(maxPayload, len(rest))
parts = append(parts, rest[:n])
rest = rest[n:]
}
numbered := make([]string, 0, len(parts))
for i, p := range parts {
numbered = append(
numbered,
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
)
}
return numbered
}
-355
View File
@@ -1,355 +0,0 @@
package androidlog_test
import (
"log/slog"
"strings"
"sync"
"testing"
"yellowjacket/backend/androidlog"
)
// entry is one call to the sink.
type entry struct {
prio int
tag string
msg string
}
// recorder is the platform write, on a machine with no platform.
type recorder struct {
mu sync.Mutex
entries []entry
}
func (r *recorder) write(prio int, tag, msg string) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
}
func (r *recorder) only(t *testing.T) entry {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) != 1 {
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
}
return r.entries[0]
}
func newLogger(r *recorder, level slog.Level) *slog.Logger {
return slog.New(androidlog.NewHandler(
&slog.HandlerOptions{Level: level},
r.write,
))
}
// TestPriorityMapsEveryLevel pins the level banding.
//
// This is the one thing in #160 that a wrong answer hides rather than
// breaks: logcat prints whatever priority it is handed, so an Error
// filed as Info is a line that is present, correct and invisible to
// every filter anyone would use to look for it.
func TestPriorityMapsEveryLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
level slog.Level
want int
}{
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
{"debug", slog.LevelDebug, androidlog.PrioDebug},
{"info", slog.LevelInfo, androidlog.PrioInfo},
{"warn", slog.LevelWarn, androidlog.PrioWarn},
{"error", slog.LevelError, androidlog.PrioError},
// slog's levels are open, so a caller may sit between two of
// the named ones. Each lands in the band beneath it, which is
// what slog's own level naming does ("INFO+2").
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
{"above error", slog.LevelError + 4, androidlog.PrioError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := androidlog.Priority(tt.level); got != tt.want {
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
}
})
}
}
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
//
// android.go asserts these against android/log.h at compile time, but
// only a cross-compiler ever builds that file. This is the assertion
// that runs in CI, and the numbers are written out longhand on purpose
// -- comparing a constant to itself would pass on any renumbering.
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
got int
want int
}{
{"verbose", androidlog.PrioVerbose, 2},
{"debug", androidlog.PrioDebug, 3},
{"info", androidlog.PrioInfo, 4},
{"warn", androidlog.PrioWarn, 5},
{"error", androidlog.PrioError, 6},
{"fatal", androidlog.PrioFatal, 7},
} {
if tt.got != tt.want {
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
}
}
}
// TestRecordReachesTheSink is the whole point of the package: a line
// written with slog arrives, under the app's tag, at the right
// priority.
func TestRecordReachesTheSink(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
got := rec.only(t)
if got.prio != androidlog.PrioError {
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
}
if got.tag != androidlog.Tag {
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
}
if !strings.Contains(got.msg, "application error") {
t.Errorf("message %q does not carry the message", got.msg)
}
if !strings.Contains(got.msg, `err=boom`) {
t.Errorf("message %q does not carry the attribute", got.msg)
}
}
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
// avoid.
//
// The debug build carries `applicationIdSuffix ".dev"`, so it is
// installed as app.yellowjacket.dev -- and it is the *only* build whose
// WebView can be inspected, so it is the build anyone debugging this
// app is running. A tag derived from the application id therefore
// differs between the build being looked at and the build the filter
// was written for, which is the failure this whole issue is about
// wearing a different hat.
func TestTheTagIsNotTheApplicationID(t *testing.T) {
t.Parallel()
if strings.Contains(androidlog.Tag, ".") {
t.Errorf(
"tag %q looks like an application id; it must be stable "+
"across the debug suffix",
androidlog.Tag,
)
}
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
// truncated tag matches no filter.
if len(androidlog.Tag) > 23 {
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
}
}
// TestTimeAndLevelAreDropped checks the formatting decision.
//
// logcat stamps every entry with a timestamp and a priority letter, so
// carrying slog's own is the same information twice on a 424px screen.
func TestTimeAndLevelAreDropped(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
got := rec.only(t).msg
if strings.Contains(got, "time=") {
t.Errorf("message %q still carries a timestamp", got)
}
if strings.Contains(got, "level=") {
t.Errorf("message %q still carries a level", got)
}
if !strings.Contains(got, "files=1577") {
t.Errorf("message %q lost its attributes with them", got)
}
}
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
// the phone rather than here.
//
// Dropping slog's built-in time and level by key alone also drops a
// caller's attribute of the same name, because ReplaceAttr sees an
// empty group path for both. The probe that verified this package on
// the device wrote slog.Info("...", "level", "info") and logcat showed
// the message with no attributes at all.
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
got := rec.only(t).msg
for _, want := range []string{"level=info", "time=soon"} {
if !strings.Contains(got, want) {
t.Errorf("message %q lost the caller's %q", got, want)
}
}
// And slog's own are still gone: the built-in level renders as a
// bare word like INFO, never as the caller's value.
if strings.Contains(got, "level=INFO") {
t.Errorf("message %q carries slog's own level", got)
}
}
// TestLevelIsHonoured checks that Enabled reaches the delegate.
func TestLevelIsHonoured(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelWarn)
log.Info("not this one")
log.Warn("this one")
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
t.Errorf("wrong record survived: %q", got)
}
}
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
// delegates rather than implements -- the reason it delegates at all.
func TestGroupsAndAttrsSurvive(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelInfo).
With("component", "player").
WithGroup("track")
log.Info("loaded", "path", "/sdcard/Music/a.flac")
got := rec.only(t).msg
for _, want := range []string{
"component=player",
"track.path=/sdcard/Music/a.flac",
} {
if !strings.Contains(got, want) {
t.Errorf("message %q is missing %q", got, want)
}
}
}
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
// mutex rather than taking a new one.
//
// Two loggers derived from one write into the same buffer, so a second
// mutex would guard nothing and a concurrent pair would splice each
// other's bytes into a single line -- which reads as corrupted logs
// under load and as nothing at all in a test that logs once.
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
t.Parallel()
rec := &recorder{}
base := newLogger(rec, slog.LevelInfo)
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
log := base.With("worker", i).WithGroup("g")
for range 50 {
log.Info("tick", "n", i)
}
}()
}
wg.Wait()
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.entries) != 8*50 {
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
}
for _, e := range rec.entries {
if strings.Count(e.msg, "msg=tick") != 1 {
t.Fatalf("interleaved line: %q", e.msg)
}
}
}
// TestChunkLeavesShortLinesAlone is the common case: no numbering
// appears on a record that was never going to be truncated.
func TestChunkLeavesShortLinesAlone(t *testing.T) {
t.Parallel()
got := androidlog.Chunk("msg=short")
if len(got) != 1 || got[0] != "msg=short" {
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
}
}
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
// silently.
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
t.Parallel()
const n = 9000
long := strings.Repeat("x", n)
parts := androidlog.Chunk(long)
if len(parts) < 2 {
t.Fatalf("a %d-byte line was not split", n)
}
var payload strings.Builder
for i, p := range parts {
if len(p) > 4000 {
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
}
_, rest, found := strings.Cut(p, ") ")
if !found {
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
}
payload.WriteString(rest)
}
if payload.String() != long {
t.Errorf("the parts do not reassemble into the input")
}
}
+1 -119
View File
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults()
}
c.General.DefaultPage = View(page)
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
@@ -666,124 +666,6 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
return nil
}
// GetPopupVolume reports whether the bottom bar's volume control is a
// click-to-open popup rather than an inline slider (#42).
func (c *Config) GetPopupVolume() bool {
if c.General == nil {
return false
}
return c.General.PopupVolume
}
// SetPopupVolume saves the volume control's presentation.
//
// Nothing to validate: both values are legal at every width, and the
// frontend additionally stands the inline slider down below the phone
// breakpoint whatever this says, because that is about room rather than
// about preference.
func (c *Config) SetPopupVolume(popup bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.PopupVolume = popup
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"PopupVolume": popup,
},
)
c.logger.Info("volume control presentation updated", "popup", popup)
return nil
}
// GetViewVisibility reports which primary views the sidebar should
// show, answered for every known view rather than only the ones the
// config mentions -- so the frontend filters on a value and never has
// to hold a second copy of the defaults.
func (c *Config) GetViewVisibility() map[string]bool {
if c.General == nil {
general := &GeneralConfig{}
general.ApplyDefaults()
return general.ResolvedViewVisibility()
}
return c.General.ResolvedViewVisibility()
}
// SetViewVisible shows or hides one primary view.
//
// Two refusals, both about a state the user cannot get out of from the
// UI they would be left with: Settings is never hideable, and the
// launch page is never hideable while it is the launch page (change it
// first). Hiding a view does not make it unreachable -- `navigate`
// still resolves it, which detail views depend on -- it only takes the
// nav item away.
func (c *Config) SetViewVisible(view string, visible bool) error {
spec, known := LookupView(view)
if !known {
return fmt.Errorf("%w: %q", errUnknownView, view)
}
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
if !visible {
if !spec.Hideable {
return fmt.Errorf("%w: %q", errViewNotHideable, view)
}
if spec.ID == c.General.DefaultPage {
return fmt.Errorf("%w: %q", errViewIsLaunchPage, view)
}
}
if c.General.ViewVisibility == nil {
c.General.ViewVisibility = make(map[string]bool, len(Views))
}
c.General.ViewVisibility[view] = visible
if err := c.General.Validate(); err != nil {
return fmt.Errorf("invalid view visibility: %w", err)
}
if err := c.Save(); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"ViewVisibility": c.General.ResolvedViewVisibility(),
},
)
c.logger.Info(
"view visibility updated",
"view", view,
"visible", visible,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
-40
View File
@@ -188,43 +188,3 @@ func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
}
}
}
// TestEmit_PopupVolumeRoundTripsAndDefaultsToInline pins both halves of
// #42's storage decision.
//
// The **default** is the load-bearing one: inline is what a fresh
// install and an existing `config.toml` with no such key must both
// produce, which is why the field names the popup rather than the
// inline slider. A flag spelled the other way round would default to
// false, hand every existing install the popup this issue exists to
// stop being the only option, and need a migration to say otherwise.
func TestEmit_PopupVolumeRoundTripsAndDefaultsToInline(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if conf.GetPopupVolume() {
t.Error("a config with no PopupVolume key wants the popup, want inline")
}
if err := conf.SetPopupVolume(true); err != nil {
t.Fatalf("SetPopupVolume: %v", err)
}
if !conf.GetPopupVolume() {
t.Error("GetPopupVolume = false after setting it true")
}
data := payloadMap(t, rec, events.GeneralConfigChanged)
if data["PopupVolume"] != true {
t.Errorf("PopupVolume = %v, want true", data["PopupVolume"])
}
if err := conf.SetPopupVolume(false); err != nil {
t.Fatalf("SetPopupVolume(false): %v", err)
}
if conf.GetPopupVolume() {
t.Error("GetPopupVolume = true after setting it false")
}
}
+26 -96
View File
@@ -5,14 +5,28 @@ import (
"fmt"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = ViewHome
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
var (
errUnknownDefaultPage = errors.New("unknown default page")
errViewCannotLaunch = errors.New("view cannot be the launch page")
// Valid DefaultPage values, matching the frontend's top-level route ids.
const (
DefaultPageHome DefaultPage = "home"
DefaultPageTracks DefaultPage = "tracks"
DefaultPageAlbums DefaultPage = "albums"
DefaultPageArtists DefaultPage = "artists"
DefaultPageGenres DefaultPage = "genres"
DefaultPagePlaylists DefaultPage = "playlists"
DefaultPageExplore DefaultPage = "explore"
DefaultPageDownloads DefaultPage = "downloads"
DefaultPageAutotag DefaultPage = "autotag"
DefaultPageJobs DefaultPage = "jobs"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = DefaultPageHome
var errUnknownDefaultPage = errors.New("unknown default page")
// QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to.
type QueueFallback string
@@ -32,52 +46,18 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem.
type GeneralConfig struct {
DefaultPage View `toml:"DefaultPage"`
DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"`
// ViewVisibility says which sidebar destinations are shown, keyed by
// view id.
//
// **An absent key means that view's own default** (`Views`), and that
// is the whole reason this is a map rather than a `HiddenViews
// []string` or a struct of booleans. A list's zero value is "hide
// nothing", which cannot express Autotag being off by default without
// a migration; a struct field for a view that later stops existing is
// stored garbage somebody has to deprecate. Here a view added later
// gets its own default rather than being invisible or forcibly
// visible, an unknown key is dropped on load, and no install needs
// migrating in either direction. Same polarity rule as
// AllowMeteredCatalogDownload: the zero value is the intended answer.
ViewVisibility map[string]bool `toml:"ViewVisibility"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one,
// so an existing config with no such key refuses by default rather
// than needing a migration to become careful.
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
// PopupVolume draws the bottom bar's volume as a click-to-open popup
// instead of a slider that is always there (#42).
//
// The polarity is the rule this file already states twice: **the
// zero value is the intended answer**. Inline is the new default, so
// the flag has to name the *other* choice — an `InlineVolume bool`
// would default to false and give every existing install the popup
// this issue exists to stop being the only option, and would need a
// migration to say otherwise.
PopupVolume bool `toml:"PopupVolume"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
//
// A launch page naming a *retired* view is treated as a zero value
// rather than as an error, because the alternative is an app that will
// not start for anyone who had that page selected when it was removed.
// An unknown-but-not-retired name still fails Validate: that is a typo,
// and telling someone about it is the useful answer.
func (c *GeneralConfig) ApplyDefaults() {
if _, retired := RetiredViews[c.DefaultPage]; retired {
c.DefaultPage = ""
}
if c.DefaultPage == "" {
c.DefaultPage = DefaultDefaultPage
}
@@ -91,17 +71,15 @@ func (c *GeneralConfig) ApplyDefaults() {
func (c *GeneralConfig) Validate() error {
c.ApplyDefaults()
spec, known := LookupView(string(c.DefaultPage))
if !known {
switch c.DefaultPage {
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
}
if !spec.CanLaunch {
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
}
c.normalizeViewVisibility()
switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid.
@@ -111,51 +89,3 @@ func (c *GeneralConfig) Validate() error {
return nil
}
// normalizeViewVisibility drops what the stored map may not say, and
// repairs the one invariant the shell depends on.
//
// Three things are dropped or forced, and all three are reachable only
// from a hand-edited config or from a version that knew different
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
// into Settings) says nothing to anybody; a view that is not Hideable
// cannot be false; and **the launch page is always visible**, because
// otherwise an install lands on a page with no nav item pointing at it.
//
// That last one is a *repair* here and an *error* at the setter
// (SetViewVisible), deliberately. On load there is nobody to tell and
// the honest reading of "my launch page is Autotag" is that this user
// wants Autotag, so it is un-hidden rather than the launch page being
// silently reset to something they did not choose. At the setter the
// user is right there and can act, so it refuses and says why.
func (c *GeneralConfig) normalizeViewVisibility() {
for id := range c.ViewVisibility {
spec, known := LookupView(id)
if !known || !spec.Hideable {
delete(c.ViewVisibility, id)
}
}
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
c.ViewVisibility[string(c.DefaultPage)] = true
}
}
// ResolvedViewVisibility answers for every known view, so no caller has
// to know the defaults -- the frontend included, which is why the
// binding returns this rather than the stored map.
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
resolved := make(map[string]bool, len(Views))
for _, v := range Views {
visible := v.VisibleByDefault
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
visible = stored
}
resolved[string(v.ID)] = visible
}
return resolved
}
-103
View File
@@ -1,103 +0,0 @@
package config
import "errors"
var (
errUnknownView = errors.New("unknown view")
errViewNotHideable = errors.New("view cannot be hidden")
errViewIsLaunchPage = errors.New("view is the launch page")
)
// View identifies one of the shell's primary destinations -- the
// things the sidebar lists and `index.ts` knows as `VIEW_TAGS`.
type View string
// The primary views, in no particular order: the sidebar owns the order
// it draws them in, because that is presentation.
const (
ViewHome View = "home"
ViewPlaylists View = "playlists"
ViewArtists View = "artists"
ViewGenres View = "genres"
ViewAlbums View = "albums"
ViewTracks View = "tracks"
ViewExplore View = "explore"
ViewDownloads View = "downloads"
ViewAutotag View = "autotag"
ViewSettings View = "settings"
)
// RetiredViews are destinations that used to exist and no longer do.
//
// A *visibility* entry for a removed view needs no such list: it is a
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
// is a **value**, and an unknown one fails validation -- which on the
// load path means the app refuses to start rather than a setting being
// ignored. So the one shape that cannot be retired for free is named
// here and reset to the default instead.
//
// `jobs` was folded into Settings by #27: library scans under
// Libraries, index work under Search Index, downloads under the
// download clients, and the autotag apply into the Autotag view.
var RetiredViews = map[View]struct{}{
"jobs": {},
}
// ViewSpec is what the backend knows about a destination. The label and
// the icon are deliberately absent: those are presentation, they live
// beside the rest of the app's icon vocabulary in
// `frontend/src/utils/icon-language.ts`, and a Go copy of them would be
// a second thing to keep in step for nothing.
type ViewSpec struct {
// ID is the view name the frontend navigates by.
ID View
// VisibleByDefault is what an install gets when the config says
// nothing about this view -- which is every install until somebody
// changes it, and every view added after this one shipped.
VisibleByDefault bool
// Hideable is false for Settings alone. It is a property of the
// view rather than a check in the setter because `config.toml` is
// hand-editable, and an app that can be locked out of its own
// Settings by a typo is a support problem nobody can debug
// remotely.
Hideable bool
// CanLaunch reports whether the view may be the launch page.
// Settings is the only one that may not, which is the shape the
// DefaultPage enum already had.
CanLaunch bool
}
// Views is the one list of primary destinations, in the order Settings
// offers them.
//
// It is the single source for three things that used to be written down
// separately: which views exist, which of them may be the launch page
// (`DefaultPage`'s validation reads it), and what an unconfigured
// install shows.
//
// Autotag is the one view hidden by default: it rewrites tags on disk,
// which is not what most libraries want on day one, and #25 asks for it
// to be turned on deliberately.
var Views = []ViewSpec{
{ID: ViewHome, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewPlaylists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewArtists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewGenres, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAlbums, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewTracks, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
}
// LookupView returns the spec for a view id.
func LookupView(id string) (ViewSpec, bool) {
for _, v := range Views {
if string(v.ID) == id {
return v, true
}
}
return ViewSpec{}, false
}
-307
View File
@@ -1,307 +0,0 @@
package config
import (
"errors"
"log/slog"
"path/filepath"
"testing"
)
// newViewTestConfig builds a Config backed by a temp file, which is all
// SetViewVisible needs: it saves and emits, and the emit is a no-op
// without a running app.
func newViewTestConfig(t *testing.T) *Config {
t.Helper()
c := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
}
// Load a file that is not there: that is what marks the config
// loaded, without which Save refuses on the *second* write.
if err := c.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
return c
}
// A view the config says nothing about takes its own default, which is
// what makes this need no migration in either direction: an existing
// install gets Autotag hidden without a key, and a view added later
// gets its own answer rather than the list's.
func TestViewVisibilityDefaults(t *testing.T) {
t.Parallel()
general := &GeneralConfig{}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if len(resolved) != len(Views) {
t.Fatalf("resolved %d views, want %d", len(resolved), len(Views))
}
if resolved[string(ViewAutotag)] {
t.Error("autotag should be hidden by default")
}
for _, v := range Views {
if v.ID == ViewAutotag {
continue
}
if !resolved[string(v.ID)] {
t.Errorf("%s should be visible by default", v.ID)
}
}
}
// A stored answer wins over the default, in both directions -- turning
// Autotag on is the whole user-facing point.
func TestViewVisibilityStoredWins(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
string(ViewAutotag): true,
string(ViewExplore): false,
},
}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore was switched off and should be hidden")
}
}
// A key for a view that no longer exists is discarded rather than
// migrated. This is the property the #25-before-#27 ordering rests on:
// when Jobs folds into Settings, `jobs = true` in somebody's config is
// a key nothing asks about, not a cleanup task.
func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
"a-view-that-was-removed": true,
string(ViewSettings): false,
string(ViewAutotag): true,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok {
t.Error("an unknown view id should be dropped on load")
}
if _, ok := general.ViewVisibility[string(ViewSettings)]; ok {
t.Error("settings is not hideable and should not be stored")
}
if !general.ResolvedViewVisibility()[string(ViewSettings)] {
t.Error("settings must resolve visible whatever the file said")
}
}
// On load there is nobody to tell, so a launch page hidden by a
// hand-edited file is un-hidden rather than the launch page being
// reset to something the user did not choose.
func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
DefaultPage: ViewAutotag,
ViewVisibility: map[string]bool{
string(ViewAutotag): false,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if !general.ResolvedViewVisibility()[string(ViewAutotag)] {
t.Error("the launch page must be visible")
}
}
// A launch page naming a view that no longer exists resets to the
// default instead of failing validation, which on the load path would
// mean the app refusing to start for whoever had it selected.
//
// This is the one shape #25's storage decision does *not* make free: a
// visibility entry is a key and an unknown key is dropped, but a launch
// page is a value.
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "jobs"}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if general.DefaultPage != DefaultDefaultPage {
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
}
}
// A name that is merely wrong is still an error: that is a typo, and
// saying so is more useful than ignoring it.
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "nonsense"}
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
}
}
// A retired view is not a view, so nothing offers it and nothing
// resolves it -- the visibility map included.
func TestARetiredViewIsGone(t *testing.T) {
t.Parallel()
for id := range RetiredViews {
if _, ok := LookupView(string(id)); ok {
t.Errorf("%s is retired but still in Views", id)
}
general := &GeneralConfig{}
general.ApplyDefaults()
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
t.Errorf("%s is retired but still resolves a visibility", id)
}
}
}
// Settings may not be the launch page, which is the shape the old
// DefaultPage enum had and is now read off the same table.
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: ViewSettings}
err := general.Validate()
if !errors.Is(err, errViewCannotLaunch) {
t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err)
}
}
// At the setter the user is present and can act, so the two states
// they could not get out of are refused rather than repaired.
func TestSetViewVisibleRefusals(t *testing.T) {
t.Parallel()
tests := []struct {
name string
view string
visible bool
want error
}{
{"settings is never hideable", string(ViewSettings), false, errViewNotHideable},
{"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage},
{"an unknown view is not a setting", "nonsense", false, errUnknownView},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
err := c.SetViewVisible(tt.view, tt.visible)
if !errors.Is(err, tt.want) {
t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want)
}
})
}
}
// Showing a view is never refused, including Settings and the launch
// page -- there is no state to be stuck in.
func TestSetViewVisibleShowsAnything(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
for _, v := range Views {
if err := c.SetViewVisible(string(v.ID), true); err != nil {
t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err)
}
}
if !c.GetViewVisibility()[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
}
// The stored map survives a save/load round trip, which is what a
// map-valued TOML key is worth checking for.
func TestViewVisibilityRoundTrips(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.toml")
original := &Config{logger: slog.Default(), filePath: path}
if err := original.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if err := original.SetViewVisible(string(ViewAutotag), true); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
loaded := &Config{logger: slog.Default(), filePath: path}
if err := loaded.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
resolved := loaded.GetViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag should have loaded as visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore should have loaded as hidden")
}
}
// Every view the shell can launch into is a view the sidebar can show,
// or an install could land on a page with no nav item and no setting
// pointing at it.
func TestEveryLaunchableViewIsAView(t *testing.T) {
t.Parallel()
for _, v := range Views {
if !v.CanLaunch {
continue
}
if !v.Hideable {
continue
}
if _, ok := LookupView(string(v.ID)); !ok {
t.Errorf("%s is launchable but not a known view", v.ID)
}
}
}
+7 -25
View File
@@ -13,31 +13,13 @@ const (
// enforces this at runtime; it is also the floor below which a
// reported size is treated as bogus and not persisted.
//
// **Both reasons this comment used to give have expired**, and the
// value is right for a third one. It said the floor was 800x600
// because "below ~780 the header's subtitle wraps and pushes the
// title out of the 4em top bar" and "below ~600 tall the eleven
// sidebar items no longer fit at once". Neither mechanism can
// happen now: the subtitle is display:none from 899px down
// (index.css), and the sidebar host is overflow-y:auto — measured
// at 600x460, its scrollHeight is 434 against a 332px client and
// Settings is reachable after scrolling. A floor defended by two
// mechanisms that no longer exist is a number nobody can argue
// with, which is worse than either answer.
//
// It stays 800x600 because that is where the *desktop* chrome
// stops being comfortable — the Compact band of plan 018's size
// matrix (#24) — and not because the app breaks below it. It does
// not: under 600px wide the phone layout takes over (bottom-nav,
// no sidebar) and the shell fits 320px exactly, which is what
// makes this a comfort floor rather than a correctness one, and
// why a very small window reflows instead of becoming a
// mini-player (#12 is a second always-on-top window, not a mode of
// this one).
//
// The previous 512x384 was aspirational — at 700x480 the sidebar
// overflowed behind the player bar with no scroll and Settings and
// Jobs could not be reached at all.
// 800x600 is where the shell was measured to still work, rather
// than a round number: below ~780 the header's subtitle wraps and
// pushes the title out of the 4em top bar, and below ~600 tall the
// eleven sidebar items no longer fit at once. The previous
// 512x384 was aspirational — at 700x480 the sidebar overflowed
// behind the player bar with no scroll and Settings and Jobs could
// not be reached at all.
MinWidth = 800
// MinHeight is the smallest allowed window height in pixels.
MinHeight = 600
+1 -2
View File
@@ -40,8 +40,7 @@ WHERE id = ? AND (mbid IS NULL OR mbid = '');
-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?;
AND (mbid IS NULL OR mbid = '');
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
+2 -3
View File
@@ -331,7 +331,6 @@ const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBI
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?
`
type GetAlbumsWithPendingReleaseMBIDRow struct {
@@ -339,8 +338,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
if err != nil {
return nil, err
}
+31 -35
View File
@@ -2,7 +2,6 @@ package explore
import (
"context"
"database/sql"
"log/slog"
"math"
"sort"
@@ -13,7 +12,6 @@ import (
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -281,32 +279,37 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
// pendingReleaseMBIDs is the albums this pass has work to do on.
//
// It is separate from the pass, and returns its error rather than
// logging it, so that a test can assert the statement runs against the
// real schema. That is not a general preference -- it is this
// statement's history: it named `release_groups`, a table plan 013
// renamed to `albums`, so it failed on every launch since e7748f1 and
// the pass returned quietly having done nothing. A test of the pass
// as a whole cannot see that, because a query error and an empty
// library are the same early return.
func (e *Service) pendingReleaseMBIDs(
ctx context.Context,
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
ctx, releaseGroupMBIDBackfillMaxPerRun,
)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
pending, err := e.pendingReleaseMBIDs(ctx)
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
if len(pending) == 0 {
return
}
@@ -332,7 +335,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
job.progress(i, len(pending))
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
@@ -341,19 +344,12 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
continue
}
// The writer, not ReadQueries: an UPDATE issued on the
// query-only pool fails at runtime with "attempt to write a
// readonly database".
if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{
String: release.ReleaseGroupMBID,
Valid: true,
},
ID: p.ID,
},
); err != nil {
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
-250
View File
@@ -1,250 +0,0 @@
package explore
import (
"database/sql"
"log/slog"
"strconv"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The release-group MBID backfill queried `release_groups`, a table
// plan 013 renamed to `albums`, so it failed on its first statement on
// every launch from e7748f1 until #189 -- and the pass swallowed that,
// because a query error and an empty library are the same early
// return. Nothing noticed for two reasons worth keeping in mind:
//
// - the statement was **raw SQL**, so sqlc never read it. Every other
// statement in the repo was renamed by the same change because sqlc
// reads sql/schemas/ and cannot generate against a table that is not
// declared. The two sqlc queries this now calls were written by 013
// and left uncalled.
// - it needs no network and no fixture library to reproduce. The
// failure is at prepare time.
// seedPendingAlbum inserts an album whose files carried a release MBID
// but no release-group MBID, which is what `library.updateMBIDs`
// leaves behind for this pass to resolve.
func seedPendingAlbum(
t *testing.T,
db *database.DB,
name, pendingMBID string,
) int64 {
t.Helper()
res, err := db.ExecContext(
"INSERT INTO albums (name, artist_credit, pending_release_mbid) "+
"VALUES (?, ?, ?)",
name, "Test Artist", pendingMBID,
)
if err != nil {
t.Fatalf("insert albums row: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
func newPendingTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
// TestPendingReleaseMBIDsRunsAgainstTheRealSchema is the regression.
//
// It asserts the statement *runs*, which is the whole of what was
// broken: against the old raw SQL this returns
// "no such table: release_groups" rather than a row.
func TestPendingReleaseMBIDsRunsAgainstTheRealSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
want := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 {
t.Fatalf("got %d pending albums, want 1", len(pending))
}
if pending[0].ID != want {
t.Errorf("got album id %d, want %d", pending[0].ID, want)
}
if got := pending[0].PendingReleaseMbid.String; got != "release-mbid-1" {
t.Errorf("got pending mbid %q, want %q", got, "release-mbid-1")
}
}
// TestOnlyUnresolvedAlbumsAreReturned pins the two conditions that make
// the pass idempotent, since between them they are what stops it doing
// the same MusicBrainz lookups on every launch forever.
func TestOnlyUnresolvedAlbumsAreReturned(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
pendingID := seedPendingAlbum(t, db, "Still Pending", "release-mbid-1")
// Already resolved: it has a real MBID, so there is nothing to
// look up even though a marker is still sitting on it.
resolved := seedPendingAlbum(t, db, "Already Resolved", "release-mbid-2")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "rg-mbid", Valid: true},
ID: resolved,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
// Never had a release MBID to resolve in the first place, which is
// most of a library.
seedPendingAlbum(t, db, "Nothing Pending", "")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 || pending[0].ID != pendingID {
t.Fatalf(
"got %d albums %v, want only the unresolved one (%d)",
len(pending), pending, pendingID,
)
}
}
// TestResolvingClearsTheMarker is the other half: once the lookup has
// answered, the album must stop being a candidate, or the pass repeats
// the same live MusicBrainz call on every launch.
func TestResolvingClearsTheMarker(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
// The writer, deliberately: this is an UPDATE, and the read pool
// would refuse it at runtime.
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "resolved-rg-mbid", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 0 {
t.Fatalf("a resolved album is still a candidate: %v", pending)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "resolved-rg-mbid" {
t.Errorf("album mbid = %q, want the resolved one", album.Mbid.String)
}
if album.PendingReleaseMbid.Valid &&
album.PendingReleaseMbid.String != "" {
t.Errorf(
"the pending marker survived as %q",
album.PendingReleaseMbid.String,
)
}
}
// TestAResolvedMBIDIsNeverOverwritten covers the guard in the UPDATE.
//
// The pass runs against rows it read earlier, and a rescan can resolve
// an album from its tags in between -- a real MBID from the file must
// win over one this pass inferred from a release.
func TestAResolvedMBIDIsNeverOverwritten(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "from-the-tags", Valid: true},
ID: id,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "from-the-backfill", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "from-the-tags" {
t.Errorf(
"album mbid = %q, want the tagged one to have won",
album.Mbid.String,
)
}
}
// TestThePassIsBounded checks the LIMIT.
//
// Each row costs a live MusicBrainz lookup on a 1 req/s limiter shared
// with every page the user can open, so an unbounded read is a run that
// lasts as long as the library is untagged. The sqlc query 013 wrote
// had no LIMIT; the raw statement it was replacing did.
func TestThePassIsBounded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
for i := range releaseGroupMBIDBackfillMaxPerRun + 10 {
seedPendingAlbum(
t, db,
"Album "+string(rune('A'+i%26))+strconv.Itoa(i),
"release-mbid-"+strconv.Itoa(i),
)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != releaseGroupMBIDBackfillMaxPerRun {
t.Errorf(
"got %d albums, want the run bounded at %d",
len(pending), releaseGroupMBIDBackfillMaxPerRun,
)
}
}
-94
View File
@@ -82,100 +82,6 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) {
}
}
// TestPruneClearsInLibraryWithNoLocalID covers the fixed point: a row
// carrying in_library with a NULL local_*_id. The upsert's conflict
// clause is `in_library = MAX(in_library, excluded.in_library)`, so it
// can only ever raise the flag, and this pass used to be gated on the id
// being present — which meant nothing in the app could clear such a row,
// ever. It is asserted for all three entity types because the gate was
// written once and used three times, so a fix applied to one is a fix
// that looks complete.
//
// The rows are seeded with raw SQL rather than through seedIndexResult
// deliberately: upsertBatch writes a zero LocalArtistID as literal 0,
// not NULL, and 0 satisfies `IS NOT NULL` — so the old gate already
// caught that shape and a fixture built through the upsert cannot
// reproduce this at all. NULL is what the artifact importer and any
// older writer leave behind, the column being nullable with no default.
func TestPruneClearsInLibraryWithNoLocalID(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, slog.Default())
// A genuinely owned artist, to prove the wider gate does not simply
// clear everything it now looks at.
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: "/music/owned.mp3",
Artist: "Owned",
})
artist, err := db.Queries.GetArtistByName(t.Context(), "Owned")
if err != nil {
t.Fatalf("read seeded artist: %v", err)
}
seedIndexResult(t, db, SearchIndexResult{
EntityType: EntityArtist,
MBID: testMBID("owned"),
Title: "Owned",
ArtistName: "Owned",
ArtistMBID: testMBID("owned"),
InLibrary: true,
LocalArtistID: artist.ID,
})
orphans := []struct {
name string
entityType string
mbid string
}{
{"artist", EntityArtist, "orphan-artist"},
{"release group", EntityReleaseGroup, "orphan-release-group"},
{"recording", EntityRecording, "orphan-recording"},
}
for _, o := range orphans {
if _, err := db.ExecContext(
`INSERT INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid,
in_library,
local_artist_id, local_release_group_id, local_recording_id)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
dbEntityType(o.entityType), dbMBID(testMBID(o.mbid)), o.name, o.name,
dbMBID(testMBID(o.mbid)),
nil, nil, nil,
); err != nil {
t.Fatalf("seed %s orphan: %v", o.name, err)
}
}
si.pruneStaleLocalCrossReferences()
inLibrary := func(t *testing.T, mbid string) int {
t.Helper()
var flag int
if err := db.QueryRowWriter(
"SELECT in_library FROM explore_index WHERE mbid = ?", dbMBID(mbid),
).Scan(&flag); err != nil {
t.Fatalf("read in_library for %q: %v", mbid, err)
}
return flag
}
for _, o := range orphans {
if got := inLibrary(t, testMBID(o.mbid)); got != 0 {
t.Errorf("%s with a NULL local id: in_library = %d, want 0", o.name, got)
}
}
if got := inLibrary(t, testMBID("owned")); got != 1 {
t.Errorf("owned artist: in_library = %d, want 1 (it still has a file)", got)
}
}
// TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the
// backfill queue prioritizes artists by how many tracks the user actually
// owns, not by how many duplicate-mbid artist rows happen to exist (the
+1 -15
View File
@@ -2562,19 +2562,6 @@ func (si *SearchIndex) PopulateLocalCrossReferences() {
// The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared.
//
// It is gated on the flag *or* the id, not on the id alone. Gated on
// the id, `in_library = 1 AND local_*_id IS NULL` is a fixed point: the
// upsert can only ever raise the flag and this pass skipped such a row
// by construction, so nothing in the app could clear it — a row claiming
// to be owned, permanently, with no local row to check the claim
// against. Nothing in the tree writes that shape today
// (collectLibraryEntities sets both together), which is exactly why it
// is worth closing now: the exposure is a database written by an older
// version, and the next writer that sets the flag without an id, which
// nothing structurally prevents. A NULL id fails the existence test on
// its own, so the wider gate needs no second clause to say what "not
// owned" means.
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
type prune struct {
entityType string
@@ -2607,8 +2594,7 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() {
result, err := si.db.ExecContext(
`UPDATE explore_index
SET in_library = 0, `+p.column+` = NULL
WHERE entity_type = ?
AND (`+p.column+` IS NOT NULL OR in_library = 1)
WHERE entity_type = ? AND `+p.column+` IS NOT NULL
AND NOT EXISTS (`+p.exists+`)`,
dbEntityType(p.entityType),
)
-58
View File
@@ -47,39 +47,6 @@ type BufferedStreamer struct {
// seek bar and suppresses its interpolation.
starved int
starvedSince time.Time
// underruns accumulates for the life of this streamer, where
// starved is reset by every arriving sample.
//
// The two answer different questions and only the first was being
// asked. starved is a *stall* detector: it exists to end a track
// whose source has died, so it forgets a run the moment audio
// resumes -- which is exactly the case this counts. A hundred 20ms
// underruns a minute never approach the give-up threshold and were
// invisible to the log, the UI and every test tier, while being
// audible as static: an underrun is served as a run of zeros
// spliced into the waveform, and a step discontinuity at each edge
// is what a click is.
underruns UnderrunStats
}
// UnderrunStats is what the ring buffer missed, cumulatively.
//
// Samples rather than milliseconds because this type does not know the
// sample rate -- the player does, and converts at the point of
// reporting.
type UnderrunStats struct {
// Runs is the number of *episodes*: transitions from healthy into
// starved. Calls is how many Stream calls were served with
// silence, and Samples is how much silence that was.
//
// Runs is the count that means something audible. One episode is
// one pop however many calls it spans, and the ratio of the two is
// how long the average episode was -- which is what separates
// "clicking" from "dropping out".
Runs int64
Calls int64
Samples int64
}
// The silence fill is bounded by both a duration and a run of calls,
@@ -259,13 +226,6 @@ func (bs *BufferedStreamer) Stream(
// but only for a bounded stretch, because "forever" is
// reported upward as healthy playback and there is no watchdog
// above this to notice otherwise.
if bs.starved == 0 {
bs.underruns.Runs++
}
bs.underruns.Calls++
bs.underruns.Samples += int64(len(samples))
bs.starved++
if bs.starvedSince.IsZero() {
@@ -309,20 +269,6 @@ func (bs *BufferedStreamer) Stream(
return n, true
}
// Underruns returns the cumulative underrun count.
//
// It is a snapshot rather than a live view, and it is read from
// outside the audio callback: counting happens in Stream, under the
// lock it already takes, because that path has a real-time deadline
// and anything that allocates or formats on it is a cause of the
// defect it is measuring rather than a measurement of it.
func (bs *BufferedStreamer) Underruns() UnderrunStats {
bs.mu.Lock()
defer bs.mu.Unlock()
return bs.underruns
}
// Err returns any error encountered by the source streamer.
func (bs *BufferedStreamer) Err() error {
bs.mu.Lock()
@@ -352,10 +298,6 @@ func (bs *BufferedStreamer) Flush() {
// resetStarvationLocked forgets an underrun run. Must be called with
// bs.mu held.
//
// Deliberately does not touch bs.underruns: forgetting the run is what
// makes starved a stall detector, and remembering it is the whole
// point of the counter beside it.
func (bs *BufferedStreamer) resetStarvationLocked() {
bs.starved = 0
bs.starvedSince = time.Time{}
-279
View File
@@ -1,279 +0,0 @@
package player
import (
"sync"
"testing"
"time"
)
// An underrun is audible and nothing counted it (#135).
//
// The distinction these tests exist for is that `starved` and
// `underruns` disagree on purpose. `starved` is a stall detector: it is
// reset by every arriving sample, because its job is to end a track
// whose source has died and a source that is merely slow must not be
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
// door). That reset is exactly what made the audible case invisible --
// a hundred short underruns a minute never approach the give-up
// threshold, and each one is a run of zeros spliced into the waveform
// with a step discontinuity at both edges.
// TestAnEmptyRingIsCounted is the measurement itself: silence served
// for a missing sample is recorded rather than merely tolerated.
func TestAnEmptyRingIsCounted(t *testing.T) {
t.Parallel()
// A source that never produces is the cleanest way to make the
// ring empty on demand; the stall budget is far longer than the
// handful of calls below.
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
if got := bs.Underruns(); got != (UnderrunStats{}) {
t.Fatalf("a fresh streamer already reports %+v", got)
}
buf := make([][2]float64, 256)
for range 3 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
got := bs.Underruns()
if got.Calls != 3 {
t.Errorf("Calls = %d, want 3", got.Calls)
}
if got.Samples != int64(3*len(buf)) {
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
}
// Three consecutive silent calls are one episode, not three. That
// is the number that means something audible: one interruption is
// one pop however many callbacks it spans.
if got.Runs != 1 {
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
}
}
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
// waveform, which is the reason to count it at all.
func TestSilenceIsWhatIsCounted(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 64)
for i := range buf {
buf[i] = [2]float64{0.5, 0.5}
}
n, ok := bs.Stream(buf)
if !ok || n != len(buf) {
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
}
for i := range buf {
if buf[i] != ([2]float64{}) {
t.Fatalf("sample %d is %v, want silence", i, buf[i])
}
}
if got := bs.Underruns().Samples; got != int64(len(buf)) {
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
}
}
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
// audio arriving between two underruns makes them two, because that is
// two interruptions and two clicks.
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
t.Parallel()
// A source that yields nothing until it is fed, so the ring can be
// emptied, filled and emptied again on demand.
src := &gatedStreamer{}
bs := NewBufferedStreamer(src, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
starve := func() {
t.Helper()
for range 2 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
}
// feed lets exactly one bufferful through and drains it, so the
// ring is empty again on return. Allowing more would mean the
// starve() after it drained real audio instead of underrunning,
// which is what the first version of this test did -- it reported
// one episode and looked like the counter was wrong.
feed := func() {
t.Helper()
src.allow(len(buf))
// The read-ahead is a goroutine, so wait for real samples
// rather than assuming they have landed.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, ok := bs.Stream(buf)
if ok && n > 0 && buf[0] != ([2]float64{}) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the source never delivered a sample")
}
starve()
feed()
starve()
if got := bs.Underruns().Runs; got < 2 {
t.Errorf(
"Runs = %d, want at least 2 -- audio in between makes two "+
"episodes, not one",
got,
)
}
}
// TestTheStallResetDoesNotClearTheCounter is the regression this file
// is really about.
//
// resetStarvationLocked runs on every arriving sample and on every
// Flush. If it cleared the cumulative count too, the counter would
// report zero on exactly the workload it exists to measure -- a stream
// that underruns repeatedly but always recovers -- which is
// indistinguishable from healthy playback and is what the code did
// before #135.
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
before := bs.Underruns()
if before.Runs == 0 {
t.Fatal("nothing was counted, so the reset cannot be tested")
}
// Both of the ways a run is forgotten.
bs.mu.Lock()
bs.resetStarvationLocked()
bs.mu.Unlock()
bs.Flush()
if got := bs.Underruns(); got != before {
t.Errorf(
"forgetting the stall run also discarded the count: %+v, "+
"want %+v",
got, before,
)
}
}
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
// the reporting side.
//
// The counter belongs to the streamer and the streamer is replaced on
// every track, so a baseline carried across a track change is the
// previous track's total subtracted from a fresh zero. The load path
// resets the baseline, and this clamps as well -- a negative count in a
// log line reads as a broken instrument, which would discredit the
// measurement rather than merely mis-state it.
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
t.Parallel()
tests := []struct {
name string
now UnderrunStats
last UnderrunStats
want UnderrunStats
}{
{
name: "ordinary progress",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
},
{
name: "nothing happened",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
want: UnderrunStats{},
},
{
name: "a new streamer, with a stale baseline",
now: UnderrunStats{},
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
want: UnderrunStats{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := underrunDelta(tt.now, tt.last); got != tt.want {
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
}
})
}
}
// gatedStreamer produces only what it has been allowed to, and
// otherwise stalls without ending -- so a test can decide exactly when
// the ring runs dry.
type gatedStreamer struct {
mu sync.Mutex
remaining int
}
func (g *gatedStreamer) allow(n int) {
g.mu.Lock()
defer g.mu.Unlock()
g.remaining += n
}
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.remaining <= 0 {
return 0, true
}
n := min(len(samples), g.remaining)
for i := range n {
samples[i] = [2]float64{0.25, 0.25}
}
g.remaining -= n
return n, true
}
func (g *gatedStreamer) Err() error { return nil }
+17 -146
View File
@@ -39,21 +39,16 @@ type Player struct {
// via the queue).
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
// lastUnderruns is the previous report, so the 1 Hz log can say
// what happened in the last second and stay quiet when nothing did.
// It is reset with the streamer, in loadFileLocked.
lastUnderruns UnderrunStats
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
@@ -76,19 +71,6 @@ 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
@@ -174,8 +156,6 @@ 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,
@@ -297,78 +277,9 @@ func (p *Player) emitPositionIfPlaying() {
return
}
p.reportUnderrunsLocked()
p.emitPositionLocked()
}
// underrunDelta is what happened since the last report.
//
// It clamps at zero rather than subtracting blind, because the counter
// belongs to the *streamer* and the streamer is replaced on every
// track: a baseline carried across that boundary is the previous
// track's total subtracted from a fresh zero, which is negative. That
// is repaired at the load (lastUnderruns is reset with the streamer)
// and clamped here as well, because a negative count in a log line
// reads as a broken instrument and would discredit the measurement
// this exists to make.
func underrunDelta(now, last UnderrunStats) UnderrunStats {
return UnderrunStats{
Runs: max(0, now.Runs-last.Runs),
Calls: max(0, now.Calls-last.Calls),
Samples: max(0, now.Samples-last.Samples),
}
}
// reportUnderrunsLocked logs what the ring buffer missed, at most once
// a second and only when the number moved. Must be called with p.mu
// held.
//
// **An underrun is audible and nothing counted it** (#135). The ring
// serves silence when it is empty, so a run of zeros is spliced into
// the waveform and the step discontinuity at each edge is a click; a
// series of short ones is static. Everything that makes one likelier
// is worse on a phone than on a desktop -- slower storage, a governor
// that parks cores, background work, GC -- and no tier here can see it,
// since CI's audio device is a null sink chosen because it keeps time.
//
// Three things about the reporting are deliberate.
//
// **It is on the 1 Hz position ticker rather than in Stream.** Stream
// runs on the speaker callback's real-time deadline, and a log line
// there would allocate, format and write on the exact path whose
// missed deadline is the defect -- measuring by making it worse.
//
// **An unchanged count is not logged.** That is emitStatus' rule one
// package over: a healthy player is silent, so anything in the log is
// news, and the line appears exactly while it is popping. Reading it
// off a device means `make android-logs` with the audio audible.
//
// **It is Info rather than Debug**, because the default level is Info
// and a phone has no convenient way to set YJ_LOG_LEVEL -- a debug
// line here would be a counter nobody on the affected platform can
// read, which is the shape of the bug that made #160 necessary.
func (p *Player) reportUnderrunsLocked() {
if p.buffered == nil {
return
}
stats := p.buffered.Underruns()
if stats == p.lastUnderruns {
return
}
since := underrunDelta(stats, p.lastUnderruns)
p.lastUnderruns = stats
slog.Info("audio underrun",
"runs", since.Runs,
"calls", since.Calls,
"silenceMs", speakerSampleRate.D(int(since.Samples)).Milliseconds(),
"trackRuns", stats.Runs,
"trackSilenceMs", speakerSampleRate.D(int(stats.Samples)).Milliseconds(),
)
}
// emitPositionLocked pushes the current position to the frontend.
// Must be called with p.mu held.
func (p *Player) emitPositionLocked() {
@@ -558,12 +469,6 @@ func (p *Player) updateStreamers(
p.resampled, int(speakerSampleRate)*2,
)
// The counter belongs to the streamer, so the baseline it is
// reported against has to go with it -- otherwise the first report
// of a new track is the previous track's total subtracted from
// zero, which is negative and looks like the instrument is broken.
p.lastUnderruns = UnderrunStats{}
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.buffered}
@@ -970,10 +875,6 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return
}
p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged()
p.saveState()
@@ -1022,10 +923,6 @@ 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()
@@ -1056,14 +953,6 @@ 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()
@@ -1514,15 +1403,7 @@ func (p *Player) saveState() {
volume := int64(DefaultUserVol)
muted := false
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:
if p.volume != nil {
volume = int64(p.getUserVolume())
muted = p.volume.Silent
}
@@ -1606,20 +1487,11 @@ func (p *Player) restoreStateLocked() {
}
}
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)
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.
@@ -1659,9 +1531,8 @@ func (p *Player) restoreStateLocked() {
}
p.logger.Info("Player state restored",
"volume", p.getUserVolume(),
"muted", p.volume.Silent,
"systemVolume", p.systemVolume,
"volume", vol,
"muted", state.Muted,
"trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds,
)
-42
View File
@@ -1,42 +0,0 @@
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
}
-11
View File
@@ -1,11 +0,0 @@
//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
-10
View File
@@ -1,10 +0,0 @@
//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
-215
View File
@@ -1,215 +0,0 @@
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])
}
}
}
+1 -10
View File
@@ -24,19 +24,10 @@ func DefaultBindings() map[string]string {
"player.repeat": "R",
"player.mute": "M",
// Navigation (Global scope). Back and forward are the browser's
// own combination on every platform, which is the whole design
// brief for them: the app has one global history and this is the
// gesture people already have for it. The modifier is what keeps
// them clear of `player.seekBack`/`seekForward`, which are the
// bare arrows -- a binding is matched on its full canonical
// string, so "Alt+Left" and "Left" are different keys and not a
// conflict.
// Navigation (Global scope)
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions
"app.selectAll": "Ctrl+A",
-69
View File
@@ -52,75 +52,6 @@ func UseHomeOverride(base string) {
_ = os.Setenv(envHomeOverride, base)
}
// envTempDir is the variable Go's os.TempDir() reads, and through it
// every library in the process that asks for a temporary file.
const envTempDir = "TMPDIR"
// tempDirName is the subdirectory of the app's own storage that
// becomes that answer.
const tempDirName = "tmp"
// UseTempDir gives the process a temporary directory that exists.
//
// **Android has no /tmp and hands an app no TMPDIR**, and Go's
// os.TempDir() falls back to "/tmp" when the variable is unset -- so
// every library in the process that wants scratch space is handed a
// path that has never existed. SQLite is the one that noticed: an
// INSERT ... SELECT large enough to spill returned
// SQLITE_IOERR_GETTEMPPATH (disk I/O error 6410), which is how the
// champion search index came to fail its rebuild on every launch while
// the app otherwise looked healthy (#190).
//
// It is the *class* that is fixed here rather than that statement.
// Anything that spills fails the same way on that platform -- large
// sorts, large joins, VACUUM -- so the repair belongs at the process's
// one answer to the question rather than at each caller. The
// alternative considered was PRAGMA temp_store = MEMORY, which is
// cheaper and more local and is a promise that every future spill fits
// in RAM on a phone; the catalog is the largest thing in this app and
// that is not a promise worth making silently.
//
// The rules are UseHomeOverride's, for the same reasons. **An empty
// base is a no-op**, because that is what
// application.Mobile.StoragePath() returns on desktop -- so this needs
// no build tag and changes nothing off mobile, where /tmp is real. And
// **an explicit TMPDIR wins**, so anyone who set one deliberately gets
// what they asked for; nothing sets it on the platform this exists for.
//
// It returns its error rather than swallowing it because a temp
// directory that could not be created is the same silent failure one
// step earlier, and since #160 a log line on that platform is
// something a person can actually read.
func UseTempDir(base string) error {
if base == "" || os.Getenv(envTempDir) != "" {
return nil
}
dir := filepath.Join(base, tempDirName)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("could not make the temp directory %s: %w", dir, err)
}
// Writability is checked rather than assumed: the whole failure
// this repairs is a directory that is named and cannot be used, and
// MkdirAll on an existing unwritable directory succeeds.
probe, err := os.CreateTemp(dir, "probe")
if err != nil {
return fmt.Errorf("temp directory %s is not writable: %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
if err := os.Setenv(envTempDir, dir); err != nil {
return fmt.Errorf("could not set %s: %w", envTempDir, err)
}
return nil
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
-113
View File
@@ -87,116 +87,3 @@ func TestUseHomeOverride(t *testing.T) {
})
}
}
// UseTempDir carries UseHomeOverride's two rules for the same reasons,
// plus one of its own: the directory it names has to be usable.
//
// **The only tier that can compile the platform this exists for is a
// phone**, so everything decidable off one is decided here -- which is
// androidpayload.go's discipline, and is why the platform call is a
// parameter rather than something this package reaches for. The
// device's half is a single measurement: no /tmp, no TMPDIR (#190).
func TestUseTempDir(t *testing.T) {
t.Run("an empty base is a no-op", func(t *testing.T) {
// This is the desktop case in full: StoragePath() answers ""
// off mobile, where /tmp is real and must be left alone.
t.Setenv(envTempDir, "")
if err := UseTempDir(""); err != nil {
t.Fatalf("UseTempDir(\"\") = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s = %q, want it untouched", envTempDir, got)
}
})
t.Run("an explicit TMPDIR wins", func(t *testing.T) {
const chosen = "/somewhere/deliberate"
// The base is taken before TMPDIR moves, because t.TempDir()
// reads TMPDIR too -- which is the same fact this function is
// about, met from the other side.
base := t.TempDir()
t.Setenv(envTempDir, chosen)
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != chosen {
t.Errorf("%s = %q, want the explicit %q", envTempDir, got, chosen)
}
})
t.Run("points at a real directory under the base", func(t *testing.T) {
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
got := os.Getenv(envTempDir)
want := filepath.Join(base, tempDirName)
if got != want {
t.Fatalf("%s = %q, want %q", envTempDir, got, want)
}
// The whole failure being repaired is a temp directory that is
// named and does not exist, so naming one is not enough.
info, err := os.Stat(got)
if err != nil {
t.Fatalf("the temp directory was named but not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("%s is not a directory", got)
}
})
t.Run("os.TempDir then answers with it", func(t *testing.T) {
// The point of setting the variable at all: this is what every
// library in the process reads, SQLite's driver included.
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.TempDir(); got != filepath.Join(base, tempDirName) {
t.Errorf("os.TempDir() = %q, want the directory we made", got)
}
})
t.Run("an unwritable directory is an error, not a silent success", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write anywhere, so there is nothing to refuse")
}
base := t.TempDir()
// MkdirAll on an existing directory succeeds whatever its
// mode, so without the write probe this case would set TMPDIR
// to a directory nothing can use -- which is the bug again,
// one directory over.
if err := os.Mkdir(filepath.Join(base, tempDirName), 0o500); err != nil {
t.Fatalf("prepare the unwritable directory: %v", err)
}
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err == nil {
t.Fatal("UseTempDir accepted a directory it cannot write to")
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s was set to %q despite the failure", envTempDir, got)
}
})
}
+55 -25
View File
@@ -4,28 +4,18 @@ includes:
common: ../Taskfile.yml
vars:
# APP_ID is an *assertion*, not a setting, and it has no default.
# The *installed* package name, which every adb-driven task below uses
# to uninstall, launch and filter. It must agree with `applicationId`
# in app/build.gradle, and nothing enforces that.
#
# It used to be the id every adb-driven task below uninstalled,
# launched and filtered, defaulting to "app.yellowjacket". It could
# never have been a setting: `wails3 task` builds its var set from CLI
# KEY=VALUE arguments and the Taskfile tree only -- nothing reads
# build/config.yml, contrary to ANDROID.md, checked with --dry -- and
# even when set it fed only the adb commands, never Gradle. So the
# identity was declared twice, here and as `applicationId` in
# app/build.gradle, with nothing enforcing that they agree.
#
# They did not agree. The debug buildType carries
# `applicationIdSuffix ".dev"`, so the tasks that assemble a debug APK
# addressed the *release* id -- on a device, the user's installed app
# and their library (#159).
#
# The id is now read back from the built APK by scripts/android-
# pkgid.sh, so the thing installed and the thing launched agree by
# construction. Passing APP_ID= says "this build had better declare
# that id", and the deploy refuses before touching anything if it does
# not -- which is the check that would have caught #159 statically.
APP_ID: '{{.APP_ID | default ""}}'
# ANDROID.md says to set this in build/config.yml. That does not work
# in beta.8, checked both ways: `wails3 task` builds its var set from
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads
# config.yml -- and even when set it feeds only these adb commands,
# never Gradle. So the identity is declared twice, here and in
# build.gradle, and a change to one alone means the official run and
# deploy tasks address a package that is not installed.
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}'
MIN_SDK: '21'
TARGET_SDK: '35'
# The emulator runs the host architecture; physical devices are arm64
@@ -382,7 +372,9 @@ tasks:
ARCH: '{{.ARCH | default .HOST_ARCH}}'
cmds:
- task: ensure-emulator
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
run:
summary: Build, install and launch a debug build in the Android Emulator
@@ -391,7 +383,9 @@ tasks:
- task: build
cmds:
- task: assemble:apk
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
device:list:
summary: Lists connected Android devices and emulators (serials)
@@ -406,7 +400,25 @@ tasks:
ARCH: arm64
cmds:
- task: assemble:apk
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- |
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -418,7 +430,25 @@ tasks:
vars:
ARCH: arm64
cmds:
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- |
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -891,41 +891,13 @@ public class MainActivity extends AppCompatActivity {
}
}
/**
* The activity going away is not the app shutting down.
*
* <p>The scaffold called {@code bridge.shutdown()} here, which is
* the natural reading of onDestroy and is wrong for this app twice
* over. Android destroys and recreates an activity for a
* configuration change the manifest does not declare, under memory
* pressure, and on every background if the user has "Don't keep
* activities" on -- all **without restarting the process**. And
* when the user really does leave, this app's reason for existing
* in the background is that a song is playing, which is what the
* {@code mediaPlayback} foreground service is holding the process
* alive for. Either way, tearing the Go side down here would stop
* the music.
*
* <p>It was harmless only by accident: {@code nativeShutdown} calls
* {@code App.Quit()}, whose Android {@code destroy()} is an empty
* method, and {@code Run()}'s deferred {@code shutdownServices()}
* can never fire because Android's {@code platformRun} is
* {@code select{}} and does not return. So no {@code
* ServiceShutdown} has ever run on Android, and removing this call
* changes nothing today -- it stops the day someone implements
* {@code destroy()} from silently killing playback on a rotation.
*
* <p>There is no callback for "the process is going away"; Android
* simply kills it. Durability on this platform is the persist
* writers, which submit on every mutation rather than at exit.
*
* <p>See #52, and CLAUDE.md, "An activity is a view onto the
* process".
*/
@Override
protected void onDestroy() {
super.onDestroy();
unregisterSystemEventReceivers();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) {
webView.destroy();
}
@@ -129,24 +129,7 @@ public class WailsBridge {
}
/**
* Initialize the native Go library.
*
* <p><b>{@code initialized} is deliberately per-instance, and making
* it {@code static} is the trap this comment exists for.</b> A
* recreated activity builds a new bridge and calls this again, in a
* process where the native library is already loaded and Go's
* {@code main()} is already running -- so "initialise once per
* process" looks like exactly the right rule. It is not, because
* {@code nativeInit} does <i>two</i> things: it runs
* {@code go mainFunc()}, and it stores the global JNI reference to
* <i>this</i> bridge. Skip it and Go keeps executing JavaScript
* against the destroyed activity's WebView: the app opens, renders,
* and never receives another backend event.
*
* <p>So this is called every time, and the half that must not repeat
* is latched on the Go side instead, at the top of {@code main()} --
* which is also where the damage was ({@code os.Exit(1)}), and the
* only place that can see it. See #52.
* Initialize the native Go library
*/
public void initialize() {
if (initialized) {
+24 -58
View File
@@ -1,12 +1,6 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* How far the scroll test scrolls. One constant, because the guard and
* the assertion have to agree about it they did not, which is #133.
*/
const SCROLL_TARGET = 80;
/**
* Plan 007 phase 5: expanding an album shows its tracks.
*
@@ -110,30 +104,20 @@ test.describe('the album dropdown', () => {
await app.setViewportSize({ width: 900, height: 600 });
try {
// The container has to be a scroller at all, which is the thing
// the defect behind this spec broke and is a property rather
// than a moment.
await expect
.poll(() => scrollRange(app))
.toMatchObject({ overflowY: 'auto' });
await expect.poll(() => scrollRange(app)).toMatchObject({
scrollable: true,
overflowY: 'auto',
});
// **Scrolling it and reading it back are one round trip** (#151).
//
// #133 made the guard ask for the range this needs rather than
// for "scrollable at all", which was necessary and is not
// sufficient: a guard and the write it guards are separate
// `evaluate` calls, so the grid can satisfy the range and settle
// out of it before the write lands. It still does — observed as
// `Expected 80, Received 10` in the second of three consecutive
// full-suite runs, with the spec green alone on the same app
// straight afterwards.
//
// Polling harder cannot close a window between two moments; only
// removing the window can. So the probe sets `scrollTop` and
// returns what it reads back, in one page-side call, and the
// poll retries *that* — which also means the assertion is about
// what the grid did rather than about what it was ready to do.
await expect.poll(() => scrollTo(app, SCROLL_TARGET)).toBe(SCROLL_TARGET);
await app.evaluate(() => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
if (sc) sc.scrollTop = 80;
});
expect(await scrollTop(app)).toBe(80);
// And the dropdown it opens is on screen, wherever the manager
// decides that leaves the scroll. It is *not* "the position is
@@ -264,45 +248,27 @@ async function closeDropdown(app: Page): Promise<void> {
});
}
/** Whether the grid is a scroller at all, which is what the bug broke. */
/** Whether the grid can scroll at all, which decides if a probe can move. */
async function scrollRange(app: Page) {
return app.evaluate((target) => {
return app.evaluate(() => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
return {
// Reported for the failure message rather than waited on: `room`
// was the guard #133 strengthened, and #151 is that a guard in
// its own round trip cannot speak for the write in the next one.
// `scrollTo` below is the assertion now; this says *why* it did
// not reach the target when it does not.
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
scrollable: !!sc && sc.scrollHeight > sc.clientHeight + 40,
overflowY: sc ? getComputedStyle(sc).overflowY : '',
};
}, SCROLL_TARGET);
});
}
/**
* Scroll the grid and report where it actually landed, in one call.
*
* The whole point is that the set and the read share a moment: a
* `scrollTop` write is clamped to the range *at the instant it lands*,
* so reading it back in a second round trip asks a container that may
* have re-laid out in between.
*/
async function scrollTo(app: Page, target: number): Promise<number> {
return app.evaluate((to) => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
if (!sc) return -1;
sc.scrollTop = to;
return sc.scrollTop;
}, target);
async function scrollTop(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container')?.scrollTop ?? -1,
);
}
/** Whether the open dropdown is inside the scroll container's viewport. */
-244
View File
@@ -15,49 +15,12 @@ import { test, expect } from '../support/fixtures.js';
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*
* **And `data-active-view` is not the behaviour.** Every assertion here
* used to be that attribute, which the shell sets on every path
* including `_isBack` so this file was green throughout #72, in
* which both navs highlighted the view the user had just *left*. The
* shell's own bookkeeping was the one thing that was already right;
* what a person sees is `aria-current`, and that is asserted below as
* well. This is the same trap `layout-overflow.spec.ts` set for #69: a
* spec named for the behaviour, measuring the plumbing.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/** A common phone, where the bottom bar is the primary navigation. */
const PHONE = { width: 390, height: 844 };
/**
* The nav item for a destination, in whichever navigation is on screen.
*
* Both navs carry a button named `Albums`, and only one of them is ever
* in the accessibility tree the other is `display: none` so the
* role query resolves to the one the user can see at this viewport.
* That is the point: the highlight has to be right in both, and #72 was
* two different-looking symptoms of one cause.
*/
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/**
* `aria-current="page"` is the accessible fact and the assertion worth
* making; `.active` is a class and could be restyled without breaking
* anything real.
*/
async function expectHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page');
}
async function expectNotHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false');
}
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
@@ -79,114 +42,6 @@ async function openAnArtist(app: Page): Promise<void> {
);
}
/**
* The global back/forward control (#6).
*
* It is desktop chrome hidden below 900px, where the sidebar has
* already given up its labels so these set a desktop viewport
* explicitly rather than trusting the runner's default.
*/
const DESKTOP = { width: 1280, height: 800 };
const backButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Back' });
const forwardButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Forward' });
test.describe('global back and forward', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('offers nothing at launch, in either direction', async ({ app }) => {
// The launch entry is *replaced*, not pushed, so there is nothing
// of ours behind it — and a Back button that is live at the root
// is a press that does nothing on desktop and, on Android, the
// press that should have exited the app (#142). This assertion is
// what pins that: it failed before the launch navigation stopped
// recording two entries.
await expect(backButton(app)).toBeDisabled();
await expect(forwardButton(app)).toBeDisabled();
});
test('walks the history in both directions, and says which are available', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeDisabled();
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
// Standing in the middle of the list: both directions live, which
// is the state a single depth counter cannot express.
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeEnabled();
await forwardButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await expect(forwardButton(app)).toBeDisabled();
});
test('reaches the detail view a tab click left behind', async ({ app }) => {
// The report, exactly: the album is one entry away the whole time,
// and before this control the only way back to it was a button
// that had gone off screen with the view it belonged to.
await app.getByTestId('nav-artists').click();
await openAnArtist(app);
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
});
test('drops the forward list when the user navigates from the middle', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await app.getByTestId('nav-tracks').click();
await backButton(app).click();
await expect(forwardButton(app)).toBeEnabled();
// A browser truncates here, and so does this: what was ahead is no
// longer reachable, and a Forward button still offering it would
// be pointing at an entry that has been overwritten.
await app.getByTestId('nav-genres').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres');
await expect(forwardButton(app)).toBeDisabled();
await expect(backButton(app)).toBeEnabled();
});
test('is absent below the desktop band, where nothing needs it', async ({
app,
}) => {
// Alt+Left/Right survive at every width, the detail views keep
// their own back buttons and the phone has the platform's gesture
// — so this is a control standing down, not an action becoming
// unreachable. It is hidden at 899 because the top bar is what
// runs out of room first below 900 (#143).
await app.setViewportSize({ width: 899, height: 600 });
await expect(app.locator('nav-history')).toBeHidden();
await app.setViewportSize({ width: 390, height: 844 });
await expect(app.locator('nav-history')).toBeHidden();
});
});
test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({
app,
@@ -216,105 +71,6 @@ test.describe('the back gesture', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('leaves the nav highlighting the view it landed on, not the one it left', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expectHighlighted(app, 'Albums');
await app.getByTestId('nav-tracks').click();
await expectHighlighted(app, 'Tracks');
await app.goBack();
// #72, and the half of it the report did not describe: this is
// desktop, and before the shell published the active view *both*
// navs stayed on Tracks. An absent highlight reads as a glitch; a
// confident wrong one is worse, and any back across two primary
// views produced it.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
await expectNotHighlighted(app, 'Tracks');
});
test('keeps the parent destination lit while a detail view is open', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expectHighlighted(app, 'Artists');
await openAnArtist(app);
// A detail view is not a destination in either nav, and the user is
// still inside Artists. `app-sidebar` did this by accident -- it
// guarded on its own item list, so an unmatched name left the
// highlight alone -- and that accident is why the sidebar looked
// right on a detail view while the tab bar lit nothing. This test
// therefore passed before the fix and is here to keep the rule from
// being lost while the others are made to pass; the *tab bar's*
// half of it is the phone test below, which did not.
await expectHighlighted(app, 'Artists');
await app.goBack();
await expectHighlighted(app, 'Artists');
});
test('the tab bar survives the same journey on a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
// The reported shape: Albums, open an album, press back. The tab
// bar had a highlight, then no highlight at all, and never got it
// back — `bottom-nav` took the detail view's name, matched it
// against no tab, and lit nothing.
await navItem(app, 'Albums').click();
await expectHighlighted(app, 'Albums');
await app.locator('cover-grid').getByText('Glass Harbour').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expectHighlighted(app, 'Albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
});
test('the drawer sidebar opens on the page you are standing on', async ({
app,
}) => {
await app.setViewportSize(PHONE);
await navItem(app, 'Tracks').click();
await expectHighlighted(app, 'Tracks');
// A third symptom of the same cause, found while measuring #72 and
// not in the report: `bottom-nav` mounts its `<app-sidebar>` when
// the drawer opens, so that copy had heard no `navigate` at all and
// showed its own default — Home, from any page in the app. An event
// has no answer for a listener that was not there; a store does.
await navItem(app, 'More').click();
// The element carrying the testid is the `wa-drawer` host, which
// always reports hidden -- what is visible is the `<dialog>` in its
// shadow root -- so the drawer being open is asserted of the
// sidebar it holds rather than of itself.
const drawer = app.getByTestId('nav-drawer');
await expect(drawer.locator('app-sidebar')).toBeVisible();
await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute(
'aria-current',
'page',
);
await expect(drawer.getByTestId('nav-home')).toHaveAttribute(
'aria-current',
'false',
);
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
-147
View File
@@ -1,147 +0,0 @@
import { test, expect, callBinding, NO_QUEUE_SOURCE } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The bottom bar's two promises (#23, #42): the transport is centred in
* the window, and the volume is a slider rather than a popup.
*
* **"Centred" is measured against the window, not against the space
* left over**, which is the whole of #23. The bar was
* `320px 1fr auto`, so the transport sat in the middle of what the
* metadata and the queue button did not use its centre was ~140px
* right of the window's at every size, which reads as an alignment
* mistake rather than as a layout choice.
*
* The mechanism is that the outer two columns are the same width, so
* this asserts the *outcome* (centre lines up) rather than the CSS. A
* spec that checked `grid-template-columns` would pass on any build
* that kept the declaration and broke the result.
*/
/** Where the transport sits, against where the window's centre is. */
const geometry = (app: Page) =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('.bottom-bar')!;
const player = document.querySelector<HTMLElement>('audio-player')!;
const b = bar.getBoundingClientRect();
const p = player.getBoundingClientRect();
const seek = player.shadowRoot
?.querySelector('seek-bar')
?.shadowRoot?.querySelector('wa-slider');
return {
offset: Math.round(p.left + p.width / 2 - (b.left + b.width / 2)),
barHeight: Math.round(b.height),
seekWidth: seek ? Math.round(seek.getBoundingClientRect().width) : -1,
};
});
/** Something has to be playing before the transport draws a seek bar. */
async function play(app: Page): Promise<void> {
const paths = await app.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
return tracks.slice(0, 3).map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
await callBinding(app, 'queue.Queue.Play');
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
}
test.describe('the bottom bar', () => {
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Four widths, because a centring bug is a function of width: the old
* layout was off by half the difference between the two outer
* columns, so it was wrong by a different amount at each one and
* exactly right at none.
*/
for (const width of [800, 900, 1100, 1440]) {
test(`centres the transport in the window at ${width}px`, async ({
app,
}) => {
await app.setViewportSize({ width, height: 700 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.offset)).toBe(0);
});
}
/**
* The seek bar is what the centring is *paid for* with, so it is
* asserted rather than assumed.
*
* Reserving the metadata's full width on both sides centres the
* transport perfectly and squeezes the control you drag: measured
* during this work at **61px of track at 800px**, against 257 before
* the change. The side columns are capped at a quarter of the bar for
* that reason, and this is the number that says so 246 at 800px,
* which is parity with the uncentred layout.
*/
test('does not pay for the centring with the seek bar', async ({ app }) => {
await app.setViewportSize({ width: 800, height: 700 });
await play(app);
await expect
.poll(() => geometry(app).then((g) => g.seekWidth))
.toBeGreaterThan(200);
});
/**
* #42: the slider is simply there. Three gestures click open, drag,
* click closed is what a bottom bar has room not to ask for.
*/
test('shows the volume slider without a click', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
const volume = app.locator('.bottom-bar volume-control');
await expect(volume).toBeVisible();
await expect(volume.locator('wa-slider')).toBeVisible();
});
/**
* And the inline icon is the mute toggle, because with the slider
* beside it there is nothing left to disclose. The name follows the
* action rather than the state for the same reason.
*/
test('names the inline icon after what it does', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(
app.locator('.bottom-bar volume-control').getByRole('button', {
name: 'Mute',
}),
).toBeVisible();
});
/**
* The bar is a fixed 4em row and the transport sits in it. A slider
* with a label grows `#slider` by 8px unless `wa-slider-label.css`
* suppresses it, which moved the whole bar the last time so the
* height is pinned here rather than left to a screenshot.
*/
test('stays 4em tall', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.barHeight)).toBe(64);
});
});
+9 -4
View File
@@ -29,13 +29,18 @@ test.describe('a control says what it controls', () => {
});
test('the volume slider is announced as Volume', async ({ app }) => {
// No disclosure to open first, and no state to put back afterwards:
// #42 made the slider inline, so it is simply there. The assertion
// is unchanged — the *name* is the subject here, and the route to
// the control got shorter rather than different.
// The popup renders no slider at all while closed, the same way the
// queue panel renders no list — so this has to open it first.
await app.getByRole('button', { name: /volume/i }).click();
await expect(
app.getByRole('slider', { name: 'Volume' }),
).toBeVisible();
// Leave the transport as it was found: the specs share one page in
// file order, and an open popup covers the buttons beneath it.
await app.keyboard.press('Escape');
await app.locator('body').click({ position: { x: 5, y: 5 } });
});
test('naming the slider did not move the transport', async ({ app }) => {
+1 -8
View File
@@ -36,16 +36,9 @@ test.describe('a failed binding says so', () => {
// Libraries is the one section that starts expanded (H-22), so ask
// the disclosure what state it is in rather than assuming one — a
// blind click used to expand it and now collapses it.
//
// By role and name, not by `.header`: since #27 the section also
// contains a `job-panel`, and an open `job-details-drawer` inside
// it carries the same class. That only bites once a job exists,
// which is why it showed up on the *second* engine of a CI run and
// not the first.
const disclosure = page
.locator('config-section[heading="Libraries"]')
.getByRole('button', { name: 'Libraries' })
.first();
.locator('.header');
if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
await disclosure.click();
-318
View File
@@ -1,318 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* #69: the Playlists header's buttons could not be reached.
*
* Three text buttons Import (91px), New Playlist (122px), New Smart
* Playlist (162px), 390px in total inside a header that gets 700px at
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
* phone width the Android report was the plain version of it: you
* cannot scroll to reach them, and scrolling is not how page controls
* should be exposed anyway.
*
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
* this file exists rather than a case being added there. That spec
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
* component is invisible to it. So the measurement here is per-button
* and per-header, against the widths the app promises.
*
* Plan 018's size matrix is the promise being kept: **no action is ever
* unreachable at any supported size.** These are its three bands.
*/
const VIEWPORTS = [
// Desktop's worst case, and not the enforced minimum: the sidebar
// collapses to icons *below* 900, so the content area is 843px at 899
// and 700px at 900. Testing "the minimum" and stopping misses it.
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
{ name: '390×780 (phone)', width: 390, height: 780 },
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
];
/** Every action the Playlists header can offer, in declared order. */
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
/**
* What the header is actually rendering, measured rather than inferred.
*
* A shadow query is the wrong tool for *asserting* that is what
* `getByRole` below is for but it is the right one for a measurement,
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
const header = root.querySelector<HTMLElement>('.page-header')!;
const box = header.getBoundingClientRect();
const title = root.querySelector<HTMLElement>('h1')!;
const clipped = [
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
]
.filter((b) => !b.hidden)
.filter((b) => {
const r = b.getBoundingClientRect();
return r.right > box.right + 1 || r.left < box.left - 1;
})
.map((b) => b.dataset['actionId'] ?? 'more');
return {
overflow: header.scrollWidth - header.clientWidth,
clipped,
titleTruncated: title.scrollWidth > title.clientWidth + 1,
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? ''),
menu: [
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled: the fit is decided by a ResizeObserver, so it settles a
// frame after the resize rather than with it.
await expect
.poll(async () => (await headerFit(app))?.clipped)
.toEqual([]);
const fit = (await headerFit(app))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for all three. This is
// the assertion the issue asks for: not "it fits" but "nothing
// was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
});
}
/**
* The title gives way before an action does.
*
* Once the heading can ellipsis it absorbs the pressure, and
* `scrollWidth` then reports a header that fits perfectly while the
* heading reads "Playlis…" this issue's own failure mode moved from
* the button to the title, and invisible to exactly the measurement
* that missed it the first time. At the desktop sizes there is always
* an action to collapse instead.
*/
test('does not truncate the heading to keep a button', async ({ app }) => {
for (const vp of VIEWPORTS.slice(0, 2)) {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app))?.titleTruncated)
.toBe(false);
}
});
/**
* Asserted through the accessibility tree, never a shadow query. An
* overflow menu is exactly the shape that grows a nameless control,
* and this repo has shipped one four times most recently the
* queue's own close button.
*/
test('the overflow is a named control that opens a named menu', async ({
app,
}) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await expect(more).toHaveAttribute('aria-expanded', 'false');
await more.click();
await expect(more).toHaveAttribute('aria-expanded', 'true');
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// Collapsed at 900×600: Import (lowest priority) and New Smart
// Playlist. New Playlist stays a button because it is the drop
// target, and a closed menu cannot be one.
await expect(
menu.getByRole('menuitem', { name: 'Import' }),
).toBeVisible();
await expect(
app.getByRole('button', { name: 'New Playlist', exact: true }),
).toBeVisible();
});
/**
* The phone case is the original report. Every action is in the menu
* at 390px, and the menu is reachable by name which is the whole of
* "these need to be reachable in a sensible way".
*/
test('offers every action from the menu on a phone', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
for (const label of ACTIONS) {
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
}
});
/**
* Escape closes it and focus goes back to the trigger `MenuKeyboard`
* is shared with every other menu in the app precisely so this is not
* a second keyboard model, and this is what proves it was wired up
* rather than merely imported.
*/
test('takes the keyboard, and gives it back', async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// The first item takes focus on open. `wa-dropdown-item` sets its
// own role in its own first update, so this is polled rather than
// read: a query at the host's updateComplete finds nothing, which
// reads exactly like a menu that refused to take focus.
await expect
.poll(async () =>
app.evaluate(() => {
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
// on the *host* whose shadow root has no active element.
// Descending unconditionally lands inside the focused
// `wa-dropdown-item`'s own shadow root, where nothing is
// focused — which reads exactly like a menu that refused the
// keyboard, on a build where it did not.
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el?.textContent?.trim() ?? null;
}),
)
.toBe('Import');
await app.keyboard.press('Escape');
await expect(more).toHaveAttribute('aria-expanded', 'false');
await expect(more).toBeFocused();
});
/**
* New Playlist is a drop target, and declaring it as data must not
* take that away which is why a `PageAction` carries the drop
* handlers rather than the header owning a notion of dropping.
*
* Nothing covered this before, in either tier, and it is the one
* behaviour the migration could plausibly have destroyed silently:
* dragging still *looks* fine against a button that no longer
* accepts anything.
*/
test('New Playlist still accepts a dropped track', async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
const button = app.getByRole('button', {
name: 'New Playlist',
exact: true,
});
await expect(button).toBeVisible();
const result = await app.evaluate(async () => {
const view = document.querySelector(
'[data-testid="main-content"] playlist-view',
)!;
const target = view.shadowRoot!
.querySelector('page-header')!
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
const data = new DataTransfer();
data.setData(
'application/x-yj-tracks',
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
);
const fire = (type: string) =>
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: data,
}),
);
fire('dragover');
await new Promise((r) => setTimeout(r, 50));
// The affordance is the host's state reaching the header's
// button, which is the half a plain handler call would not prove.
const highlighted = target.classList.contains('drag-over');
fire('drop');
await new Promise((r) => setTimeout(r, 200));
return {
highlighted,
opened: view.shadowRoot!.querySelector('.create-form') !== null,
};
});
expect(result).toEqual({ highlighted: true, opened: true });
// Leave the view as it was found.
await app.keyboard.press('Escape');
});
/**
* An action given back when the window widens again. The collapsed
* set is a function of the current width and not of how it got there
* a rule that only ever *added* to it would never widen.
*/
test('gives the buttons back when the window grows', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
await app.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(async () => (await headerFit(app))?.buttons)
.toEqual(ACTIONS);
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
-124
View File
@@ -1,124 +0,0 @@
import { test, expect, waitForEvent } from '../support/fixtures.js';
/**
* The Jobs tab folded into the places the work is started (#27).
*
* The assertion worth making is not that the tab is gone that is one
* line of a table but that **nothing became unreachable when it
* went**. Scanning is the case that mattered: the per-library controls
* lived only on that page, and the tab's own comment says they had been
* moved there out of Settings in the first place.
*
* `#24` wrote down one sentence covering all three size bands: *no
* action is ever unreachable at any supported size*. Deleting a
* destination is exactly the change that can quietly break it.
*/
type Page = import('@playwright/test').Page;
const section = (page: Page, heading: string) =>
page.locator(`config-page config-section[heading="${heading}"]`);
async function openSettings(page: Page, heading: string): Promise<void> {
await page.getByTestId('nav-settings').click();
// The section's own disclosure, by role rather than by `.header`:
// an open Libraries section also contains `job-details-drawer`,
// whose own header matches that class and makes it ambiguous.
const header = section(page, heading)
.getByRole('button', { name: heading })
.first();
await expect(header).toBeVisible();
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click();
}
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
test.describe('background jobs live where the work is started', () => {
test('the Jobs destination is gone', async ({ app }) => {
await expect(app.getByTestId('nav-jobs')).toHaveCount(0);
// And it is not offered as a launch page either, which is the copy
// of the destination list that is easiest to forget.
await openSettings(app, 'General');
const options = await section(app, 'General')
.locator('select')
.first()
.locator('option')
.allTextContents();
expect(options).not.toContain('Jobs');
});
/**
* Scanning is startable from Settings Libraries, and the job that
* results is visible there with its controls. One assertion covers
* both halves, because a Scan All that started nothing would leave
* the panel empty and read exactly like a panel that does not work.
*/
test('a scan is started and watched in Settings', async ({ app }) => {
await openSettings(app, 'Libraries');
const libraries = section(app, 'Libraries');
await libraries.getByRole('button', { name: 'Scan All' }).click();
await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 });
const panel = libraries.locator('job-panel');
await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 });
// The generic affordances are the point of the panel: the tier
// list and the progress rings the other surfaces already had
// cannot open a log.
await expect(
panel.getByRole('button', { name: /^Details/ }),
).toBeVisible();
});
/** A finished job dismisses from where it is shown. */
test('a finished scan can be dismissed in place', async ({ app }) => {
await openSettings(app, 'Libraries');
const panel = section(app, 'Libraries').locator('job-panel');
const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first();
await expect(dismiss).toBeVisible({ timeout: 10_000 });
await dismiss.click();
await expect(panel.locator('job-row')).toHaveCount(0);
});
/**
* Full rescan is destructive and asks first. It is asserted at the
* dialog rather than through it running one against the seeded app
* would delete the library the rest of the suite reads.
*/
test('Full Rescan asks before it does anything', async ({ app }) => {
await openSettings(app, 'Libraries');
await section(app, 'Libraries')
.getByRole('button', { name: 'Full Rescan' })
.click();
const dialog = app.getByRole('dialog', { name: 'Full rescan' });
await expect(dialog).toBeVisible();
// The message is read off the *host*, not the dialog: a wa-dialog
// keeps its slotted content in the host's shadow root, so
// `toContainText` on the dialog itself sees only Web Awesome's
// chrome.
await expect(app.locator('confirm-dialog')).toContainText(
'deletes all library data',
);
await app.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
});
-204
View File
@@ -1,204 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* #62. On a phone, background work is shown in the notification band
* and the header indicator stands down.
*
* The report was that the indicator's popover "is obscured by other UI,
* so it cannot be read while jobs run". Worth saying plainly: **that
* symptom did not reproduce in this tier.** Measured at the device's
* own 424x439 viewport, the popover was neither clipped nor covered
* `elementFromPoint` at its centre returned the indicator at every
* width tried. So this is not a fix for a stacking bug, and a spec
* asserting one would be a spec asserting something that was never
* true here.
*
* What is true regardless, and is what these assert:
*
* - a popover is a **disclosure**, and it is anchored to a bar 3.25em
* tall on a screen 439px tall. Background work is the one thing a
* phone should not make you open something to see.
* - #57 deletes that bar and is *blocked on this issue*, because the
* indicator needs somewhere else to live first. Somewhere else is
* the band, and the test that matters for #57 is that the bar no
* longer holds the indicator at all.
*
* This is the media-query tier by necessity: a query inside a shadow
* root is answered by the viewport, and `notification-host` decides
* whether the panel *exists* from `matchMedia`. The component tier
* cannot set either.
*/
type Page = import('@playwright/test').Page;
const JOBS = [
{
id: 'phone:scan',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music',
current: 40,
total: 100,
caps: { pausable: true, cancellable: true },
},
{
id: 'phone:idx',
kind: 'index-build',
state: 'running',
title: 'Building the search index',
current: 2,
total: 9,
caps: { pausable: true, cancellable: true },
},
];
/** The panel the band renders. Playwright's CSS engine pierces open
* shadow roots, which is what keeps this one line. */
const bandPanel = (page: Page) => page.locator('job-band').locator('job-panel');
const PHONE = { width: 424, height: 439 };
const DESKTOP = { width: 1100, height: 800 };
test.describe('background jobs on a phone', () => {
test('are shown in the band, without opening anything', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
// Both jobs, drawn by real `job-row`s -- asking the rows what they
// hold rather than reading the panel's text, which would pass
// whether or not a row rendered. Playwright's CSS engine pierces
// open shadow roots, which is what makes this one line;
// `querySelectorAll` does not, and stops at `job-panel`.
await expect(bandPanel(app).locator('job-row')).toHaveCount(2);
await expect(
bandPanel(app).locator('job-row').first(),
).toContainText('Scanning Music');
});
/**
* The #57 assertion. Not "the indicator is invisible" that could be
* true because the bar overflowed but that the shell's own rule
* puts it away at this width.
*/
test('leave the top bar, which is what #57 is waiting for', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
await expect(app.locator('job-indicator')).toBeHidden();
});
/**
* The property the first attempt at this got wrong, so it is the one
* worth pinning: the band is **in the layout**, not over it.
*
* A fixed band reads fine in a screenshot and is unusable -- at
* 424x439 a compact panel is ~200px of a 439px screen and it covers
* what is under it. Four specs failed on that version, two
* phone-shell journeys and the header's action menu, because the
* panel was intercepting the taps. So: nothing of the app is
* underneath it, and the main panel starts below it.
*/
test('push the content down rather than covering it', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
const before = await app
.getByTestId('main-content')
.evaluate((el) => el.getBoundingClientRect().top);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const after = await app.evaluate(() => {
const band = document.querySelector('job-band') as HTMLElement;
const main = document.querySelector(
'[data-testid="main-content"]',
) as HTMLElement;
const b = band.getBoundingClientRect();
const m = main.getBoundingClientRect();
// What the browser reports at the band's own centre. If this is
// anything but the band, the band is sitting on top of it.
const hit = document.elementFromPoint(
Math.round(b.x + b.width / 2),
Math.round(b.y + b.height / 2),
);
return {
mainTop: m.top,
bandBottom: b.bottom,
withinViewport: b.bottom <= window.innerHeight + 0.5,
hit: hit?.tagName.toLowerCase() ?? null,
};
});
expect({
pushed: after.mainTop > before,
mainClearsBand: after.mainTop >= after.bandBottom - 0.5,
withinViewport: after.withinViewport,
hit: after.hit,
}).toEqual({
pushed: true,
mainClearsBand: true,
withinViewport: true,
hit: 'job-band',
});
});
/**
* A running job repaints several times a second. The stack it sits
* beside is `role="status" aria-live="polite"`, and a progress bar
* inside a live region is a screen reader reading a number out over
* and over so the two are siblings in the band rather than one
* list, and this is what says so.
*/
test('are not inside the live region they sit beside', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const insideLiveRegion = await app.evaluate(() => {
const band = document.querySelector('job-band');
// Neither the band itself nor anything it is nested in may be a
// live region -- `closest` answers both at once.
return !!band?.closest('[aria-live]') || band?.hasAttribute('aria-live');
});
expect(insideLiveRegion).toBe(false);
});
/**
* `bottom-nav` rendering its duplicate `<app-sidebar>` unconditionally
* broke 30 specs with "resolved to 2 elements" on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so
* a fifth that answers for *every* kind is the same trap which is
* why the band decides from `matchMedia` whether the element exists
* rather than hiding it with CSS.
*/
test('do not leave a second panel behind on a desktop', async ({
app,
testctl,
}) => {
await app.setViewportSize(DESKTOP);
await testctl.emit('JobsChanged', JOBS);
await expect(app.locator('job-indicator')).toBeVisible();
await expect(bandPanel(app)).toHaveCount(0);
});
});
+24 -67
View File
@@ -26,21 +26,7 @@ const MIN_VIEWPORT = { width: 800, height: 600 };
const VIEWPORTS = [
{ name: '1440×900', width: 1440, height: 900 },
{ name: '1024×768', width: 1024, height: 768 },
// Not the minimum, and that is the point (#24). The sidebar collapses
// to icons *below* 900, so the main panel is 843px at 899 and 700px
// at 900 — the narrowest content area any desktop width produces is
// here, not at the enforced floor. A list that stopped at the minimum
// was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
];
/**
@@ -101,10 +87,9 @@ test.describe('the app fits in its own window', () => {
)
.toBe(true);
// Settings is the one that was unreachable: it is last in the nav,
// and the pane used to clip rather than scroll. (Jobs was the other
// half of this until #27 folded it into Settings.)
for (const view of ['explore', 'settings'] as const) {
// Settings and Jobs are the two that were unreachable: they are
// last in the nav, and the pane used to clip rather than scroll.
for (const view of ['jobs', 'settings'] as const) {
const item = app.getByTestId(`nav-${view}`);
await item.scrollIntoViewIfNeeded();
@@ -132,45 +117,27 @@ test.describe('the app fits in its own window', () => {
// be dragged here, but a scaled display or a large system font can
// still land the layout in it, and clipping the nav with no scroll
// is the failure that made Settings unreachable.
//
// The scroll and the measurement share one `evaluate` — #151's
// rule, which this already had — and the whole probe is polled,
// which it did not: a viewport change settles asynchronously, so a
// single attempt reads whatever the sidebar happened to be doing.
// The probe is safe to repeat because scrolling to the bottom twice
// is scrolling to the bottom.
await expect
.poll(() =>
app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
const reachable = await app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
if (!settings) return null;
if (!settings) return null;
el.scrollTop = el.scrollHeight;
el.scrollTop = el.scrollHeight;
const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect();
const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect();
return (
item.bottom <= Math.ceil(pane.bottom) &&
item.top >= Math.floor(pane.top)
);
}),
)
.toBe(true);
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top);
});
expect(reachable).toBe(true);
});
});
test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's
@@ -272,26 +239,16 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled, for the reason the track-row test above is: since #143
// the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
const excess = await app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth;
}),
)
.toBe(0);
return de.scrollWidth - de.clientWidth;
});
// The other half: at every size this app promises, the fix costs
// nothing. A scrollbar that is always there is a worse answer
// than the clipping it replaced.
expect(excess).toBe(0);
});
}
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks the app's own listeners, on the app's own DOM, from the
* events a touch would produce and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
const VIEWS = [
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
'explore', 'downloads', 'autotag', 'settings',
'explore', 'downloads', 'jobs', 'settings',
];
type IconState = { name: string; hasSvg: boolean };
+4 -6
View File
@@ -1,4 +1,4 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page
@@ -21,6 +21,7 @@ const VIEWS: [string, string, boolean][] = [
['tracks', 'Tracks', true],
['explore', 'Explore', false],
['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
];
/** The header lives in the view's shadow root, inside its own. */
@@ -52,16 +53,13 @@ const TAGS: Record<string, string> = {
tracks: 'track-list',
explore: 'explore-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
};
test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => {
// By event rather than by nav item: a destination is not
// guaranteed to have one any more (#25 — Downloads is absent
// without a download client), and every one of these is still a
// primary view with a header, which is what this spec is about.
for (const [view, heading, hasCount] of VIEWS) {
await navigateTo(app, view);
await app.getByTestId(`nav-${view}`).click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,
-209
View File
@@ -1,209 +0,0 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The album page on a phone (#66).
*
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
* that spec asserts the *shell* needs no sideways scrolling, and the
* shell was correct throughout `body.scrollWidth === clientWidth`
* while `explore-album-details` itself measured 443 inside a 424px box
* and clipped two of the album's three primary actions with its own
* `overflow: hidden`. So the measurement here is **per control against
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
* needed for the same reason.
*
* The other half is the scroll: the page was a fixed header over a
* scrolling tracklist, so at the reference device's 424x439 the header
* owned 253 of the panel's 318px and the list scrolled in the 64 that
* were left. It is one scroll container below 600px, which is a
* property of the *host* rather than of `.content`.
*
* The engine is the caveat this tier cannot close: the reference device
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
* and a scroll container are nowhere near that engine's documented gaps
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
* at that size in Chromium" is not evidence about the phone.
*/
/** The phone this was measured on, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
const details = (page: Page) => page.locator('explore-album-details');
/** The page's own boxes, read from inside its shadow root. */
const geometry = (page: Page) =>
page.evaluate(() => {
const host = document.querySelector('explore-album-details');
const sr = host?.shadowRoot;
if (!host || !sr) return null;
const box = (sel: string) => {
const el = sr.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { width: Math.round(r.width), right: Math.round(r.right) };
};
const content = sr.querySelector('.content');
return {
hostWidth: host.clientWidth,
hostScrollWidth: host.scrollWidth,
// The host is the scroller below 600px, so the page is taller
// than its box rather than the tracklist being a window inside it.
hostScrolls: host.scrollHeight > host.clientHeight,
contentScrolls: content
? content.scrollHeight > content.clientHeight
: null,
header: box('.album-header'),
play: box('[data-testid="album-play"]'),
shuffle: box('[data-testid="album-shuffle"]'),
queue: box('[data-testid="album-queue"]'),
title: (() => {
const el = sr.querySelector('.album-title-text');
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
})(),
};
});
test.describe('the album page on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await openFirstAlbum(app);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
});
test('keeps every action inside its own box', async ({ app }) => {
const geo = await geometry(app);
expect(geo).not.toBeNull();
// "Shuffle album" ended at x=443 in a 424px component and could not
// be reached by any gesture; "Add to queue" at 440.
for (const action of ['play', 'shuffle', 'queue'] as const) {
expect(
geo?.[action],
`${action} is rendered`,
).not.toBeNull();
expect(
geo?.[action]?.right ?? 0,
`${action} ends inside the page`,
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
}
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
expect(geo?.header?.width).toBe(geo?.hostWidth);
});
test('gives the title the row rather than one glyph of it', async ({
app,
}) => {
// `.album-info` was squeezed to 112px beside the art, so an album
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
// and was shrinking as asked — the row had to stack.
expect(await geometry(app).then((g) => g?.title)).toBe(true);
});
test('scrolls as one page, with the header scrolling away', async ({
app,
}) => {
const before = await geometry(app);
expect(before?.hostScrolls).toBe(true);
expect(before?.contentScrolls).toBe(false);
const headerTop = () =>
app.evaluate(
() =>
document
.querySelector('explore-album-details')
?.shadowRoot?.querySelector('.album-header')
?.getBoundingClientRect().top ?? 0,
);
expect(await headerTop()).toBeGreaterThanOrEqual(0);
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
// programmatic scrolling, so a probe that assigns it passes on the
// build this exists to fail.
await details(app).hover();
await app.mouse.wheel(0, 250);
await expect.poll(headerTop).toBeLessThan(-100);
});
test('is the desktop arrangement again above the breakpoint', async ({
app,
}) => {
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: one component with two
// arrangements, not a phone-only copy.
await expect
.poll(async () => (await geometry(app))?.hostScrolls)
.toBe(false);
const arrangement = await app.evaluate(() => {
const sr = document.querySelector('explore-album-details')?.shadowRoot;
const header = sr?.querySelector('.album-header');
const content = sr?.querySelector('.content');
return {
direction: header ? getComputedStyle(header).flexDirection : null,
contentOverflow: content ? getComputedStyle(content).overflowY : null,
};
});
expect(arrangement.direction).toBe('row');
expect(arrangement.contentOverflow).toBe('auto');
});
});
/** Albums → the second card, which navigates to the album page. */
async function openFirstAlbum(app: Page): Promise<void> {
// Below 600px the sidebar is gone; the tab bar is the navigation.
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
// Dispatched rather than clicked: the card lives in a virtualizer
// inside a shadow root, and Enter expands the dropdown instead.
await app.evaluate(() => {
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card')[1]
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expect(
details(app).locator('[data-testid="album-play"]'),
).toBeVisible();
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
-283
View File
@@ -1,283 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* Now Playing on a short screen (#51).
*
* #51 asks for a layout that "survives" ~424x439 with the controls
* never scrolling off. #172 measured why it did not the stacked
* layout's budget is fixed, so the art gets whatever is left, and that
* was 39px before #64 and 53px after it.
*
* **Two separate claims are asserted here, and only one of them is
* about the phone.**
*
* The first is that the art is *square*. It was not: `aspect-ratio` is
* specified not to re-derive the width when `max-height` clamps the
* height, so the art was drawn as a letterbox band and `object-fit:
* cover` cropped the cover to it — 264x53 on the reference device. The
* leftover only exceeds the width above ~843px of viewport, so this
* was every height from ~500 to ~843 as well: most phones, and any
* short window. That is ordinary CSS rather than a Chrome 113 quirk,
* so this tier can see it, and the heights below are chosen to cover
* the range rather than the one device.
*
* The second is the reflow: below 500px the art and the names sit side
* by side, which is what takes the art from 53px to 143px. That is
* asserted as a *relation between boxes* the art beside the names,
* not above them because the pixel count is a consequence of the
* arrangement and would pin this file to one device's chrome.
*
* **What this tier cannot see** is the device's engine: CI's Chromium
* and WebKit are current, and #60's clipping showed what that costs.
* Nothing here depends on Chrome 113 behaviour the sizing rules were
* checked against the device itself, at column heights of 288, 300,
* 451, 600 and 800, and the numbers are on #51.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
/**
* A tall phone, above the reflow's 500px. Roughly a Pixel 7, which is
* #51's other named device and was not attached so what is checked
* here is the layout it *should* get, not that device.
*/
const TALL_PHONE = { width: 412, height: 869 };
/** Inside the crop's old range and above the reflow: a short window. */
const SHORT_WINDOW = { width: 390, height: 700 };
/**
* The height the layout reflows at. Written down once here because the
* specs have to know which arrangement to *wait* for, not only which
* to assert.
*/
const REFLOW_AT = 500;
/** Put a track in the player, so the view has art and names to lay out. */
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,
);
});
}
/** Open the full-screen view and wait for the shell to say so. */
async function openNowPlaying(page: Page): Promise<void> {
await page.evaluate(() => {
document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
}),
);
});
await expect(page.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
// The attribute is the shell's bookkeeping and lands before the view
// has a track, so measuring on it alone races the first layout --
// which showed up as a 60x5 art on the first spec of a cold run.
//
// Waiting for a non-zero box is not enough on its own either: a
// previous test leaves the *other* arrangement on screen, and a
// stale column satisfies "has a size" perfectly. So the wait is for
// the arrangement this viewport should have, which is the thing
// every assertion below depends on. Found by this file passing one
// test at a time and failing in file order.
const wantRow = (page.viewportSize()?.height ?? 0) <= REFLOW_AT;
await page.waitForFunction(
(row: boolean) => {
const v = document.querySelector('now-playing-view');
const stack = v?.shadowRoot?.querySelector('.stack');
const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder');
const t = v?.shadowRoot?.querySelector('.transport');
if (!el || !t) return false;
// A build with no `.stack` at all is the one before this change,
// and the squareness assertions are still meaningful against it
// -- so this waits for the arrangement only where there is one to
// wait for. Otherwise reverting the component to check that these
// tests bite produces eight timeouts instead of the measurements
// that make the case.
if (stack) {
const dir = getComputedStyle(stack).flexDirection;
if (dir !== (row ? 'row' : 'column')) return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && t.getBoundingClientRect().height > 0;
},
wantRow,
);
}
/**
* The boxes this file reasons about, read in one evaluate.
*
* It reaches into the view's shadow root rather than using locators
* because the question is geometric where these boxes are *relative
* to each other* and a testid per edge would be four locators and
* four round trips to say one thing.
*/
async function boxes(page: Page) {
return page.evaluate(() => {
const v = document.querySelector('now-playing-view');
if (!v || !v.shadowRoot) return null;
const rect = (sel: string) => {
const el = v.shadowRoot!.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height,
};
};
return {
// Whichever of the two the track has; both carry the sizing.
art: rect('.art img') ?? rect('.art .placeholder'),
artBox: rect('.art'),
stack: rect('.stack'),
meta: rect('.meta'),
transport: rect('.transport'),
scrollHeight: v.scrollHeight,
clientHeight: v.clientHeight,
};
});
}
test.describe('Now Playing survives a short screen', () => {
test.beforeEach(async ({ app }) => {
await stageATrack(app);
});
/**
* The crop, at four heights spanning the range it covered. This is
* the assertion that fails on the build before this change: at
* 424x439 the art measured 264x53.
*/
for (const vp of [DEVICE, SHORT_WINDOW, TALL_PHONE, { width: 900, height: 500 }]) {
test(`draws the art square at ${vp.width}x${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await openNowPlaying(app);
const b = await boxes(app);
expect(b, 'now-playing-view did not mount').not.toBeNull();
expect(b!.art, 'neither art nor placeholder rendered').not.toBeNull();
const { width, height } = b!.art!;
expect(width, 'the art has no width').toBeGreaterThan(0);
// One pixel of slack for sub-pixel layout, and no more: the
// defect this guards was a 5:1 band.
expect(
Math.abs(width - height),
`art is ${Math.round(width)}x${Math.round(height)}, not square`,
).toBeLessThanOrEqual(1);
});
}
/**
* The promise #51 states and plan 018's matrix repeats. A floor on
* the art with the block scrolling was the other option on #172 and
* this is why it was not taken.
*/
test('never scrolls the transport off the bottom', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.transport!.bottom).toBeLessThanOrEqual(DEVICE.height);
expect(
b!.scrollHeight,
'the view scrolls, so the transport can be moved off screen',
).toBeLessThanOrEqual(b!.clientHeight + 1);
});
/**
* The reflow itself, as a relation rather than a measurement: below
* 500px the names are *beside* the art, above it they are below.
*/
test('puts the names beside the art below 500px', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.left,
'the names are not to the right of the art',
).toBeGreaterThanOrEqual(b!.artBox!.right - 1);
});
test('keeps the names below the art on a tall phone', async ({ app }) => {
await app.setViewportSize(TALL_PHONE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.top,
'the names are not below the art',
).toBeGreaterThanOrEqual(b!.artBox!.bottom - 1);
});
/**
* What the reflow actually does, stated as a mechanism rather than
* as a number: in a row the art is bounded by the row's *height*,
* so it fills it where in a column it is the leftover after the
* names, which is what made it 53px.
*
* **The pixel count is deliberately not asserted here.** Two drafts
* tried. The first compared the art against the column's leftover
* computed from the boxes on screen and passed on the broken build,
* because the subtraction goes negative when the names are taller
* than the art precisely the defect. The second put a floor of
* 100px on it, passed locally at 114 and **failed in CI at 64**: this
* app is long-lived, so a job staged by an earlier spec is still on
* screen, and the volume control renders here where it does not on
* Android. Both are chrome above and below this view, and both move
* the leftover. A test that asserts how much room CI happened to
* have is a test about the runner.
*
* The device numbers 53px to 143px are on #51, measured there,
* which is the only tier that can honestly produce them.
*/
test('fills the row with the art rather than the leftover', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.stack, 'there is no row to fill').not.toBeNull();
expect(
Math.abs(b!.artBox!.height - b!.stack!.height),
'the art does not fill the row, so it is still a leftover',
).toBeLessThanOrEqual(1);
});
});
-136
View File
@@ -1,136 +0,0 @@
import {
test,
expect,
callBinding,
resetEvents,
waitForEvent,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The phone's progress line (#58).
*
* The component tier already pins what the line *says* that it
* renders the backend's reported position and never a count of its own.
* What only a real shell can answer is **where it is**: the issue asks
* for a line on the border between the mini player and the tab bar, and
* "on the border" is two adjacencies in a grid that no component-level
* render has around it.
*
* It also asserts the line is not there on a desktop, which is the
* other half of the same fact: above 600px there is no tab bar for it
* to sit on the border of, and the bar carries a real seek bar.
*/
type Rect = { x: number; y: number; width: number; height: number };
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const DESKTOP = { width: 1280, height: 800 };
async function rectOf(app: Page, selector: string): Promise<Rect | null> {
return app.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}, selector);
}
/**
* Put the 90-second fixture on and wait for the first position report.
*
* The long track rather than any track: every other fixture is 2-6
* seconds, which is shorter than the time this spec takes to measure
* three rectangles.
*/
async function play(app: Page): Promise<void> {
const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>(
app,
'library.Library.GetTracks',
[0],
);
// `TrackName`, not `Title`: that is what the library model calls it.
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
expect(long, `no fixture track named ${LONG_TRACK}`).toBeTruthy();
await callBinding(app, 'queue.Queue.Clear');
await resetEvents(app);
await callBinding(app, 'queue.Queue.SetQueue', [
[long!.FilePath],
0,
false,
NO_QUEUE_SOURCE,
]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
await waitForEvent(app, 'PlaybackPositionChanged', { timeoutMs: 15_000 });
}
test.describe('the progress line sits on the border between the bars', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await play(app);
});
/*
* Every test here starts a LONG_TRACK and the suite is workers: 1,
* fullyParallel: false against one long-lived app so without this
* the four phone-* specs that follow alphabetically inherit a playing
* queue. phone-transport.spec.ts records where that lesson came from:
* the fault first showed up as a flake in a spec about something else.
*/
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize(DESKTOP);
});
test('spans the width, between the mini player and the tab bar', async ({
app,
}) => {
const line = await rectOf(app, 'player-progress-line');
const bar = await rectOf(app, '.bottom-bar');
const nav = await rectOf(app, 'bottom-nav');
expect(line, 'no progress line on the phone').not.toBeNull();
expect(bar).not.toBeNull();
expect(nav).not.toBeNull();
// A border, not a band: 2px, the full width, and touching both.
expect(line!.height).toBeCloseTo(2, 0);
expect(line!.width).toBeCloseTo(bar!.width, 0);
expect(line!.y).toBeCloseTo(bar!.y + bar!.height, 0);
expect(nav!.y).toBeCloseTo(line!.y + line!.height, 0);
});
/**
* It is 2px on the top edge of the tab bar, which is exactly where a
* thumb aiming at a tab lands. A line that sometimes seeks is worse
* than one that never does, so it must take no part in hit testing
* at all.
*/
test('takes no taps', async ({ app }) => {
const line = await rectOf(app, 'player-progress-line');
const hit = await app.evaluate(
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? '',
{ x: line!.x + line!.width / 2, y: line!.y + 1 },
);
expect(hit).not.toBe('PLAYER-PROGRESS-LINE');
});
test('is not there on a desktop', async ({ app }) => {
await app.setViewportSize(DESKTOP);
await expect(app.locator('player-progress-line')).toBeHidden();
});
});
-322
View File
@@ -1,322 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* #57. Below 600px the top bar is not in the layout, and search is a
* button that opens a modal on the pages where searching means
* anything.
*
* **This is the tier that can answer it, with one honest exception.**
* The shell's breakpoints are media queries, which the component tier
* cannot set so whether the bar is a grid row, and whether a header
* grows a search button, is a question for a real viewport. What this
* tier *cannot* answer is the reason the surface is a `wa-dialog`:
* #60 read out of the Web Awesome source that `wa-popup` falls back to
* `position: fixed` where there is no Popover API (Chrome 113, the
* reference device) and that `.main-panel`'s `contain: paint` clips a
* fixed descendant. Chromium and WebKit here both have the Popover API,
* so a popup is top-layered and correct, and **an assertion that the
* modal is not clipped would pass on the broken build.** The mechanism
* is asserted in `frontend/test/components/search-dialog.test.ts`
* instead, where "is there a native <dialog>" is a question a browser
* can answer without lying.
*
* **And it is measured per element.** `layout-overflow.spec.ts` asks
* whether the *shell* needs sideways scrolling and was green throughout
* the defect it is named for; the win this issue is for is vertical and
* belongs to one element, so it is that element's box that is read.
*/
type Page = import('@playwright/test').Page;
/** The reference device's own viewport, and a common small phone. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
/**
* Where the top bar is, and how much of the screen it costs.
*
* `contentTop` is measured against the *jobs band* rather than against
* the window, because that band is a real grid row whenever work is in
* flight (#62) and the app under these specs is long-lived a job
* staged by another file is still in the store. Measuring against zero
* makes this assertion say "and no background job is running", which is
* not what it is for and is not something it can arrange.
*/
const barBox = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band');
const cs = getComputedStyle(bar);
return {
position: cs.position,
height: Math.round(bar.getBoundingClientRect().height),
/** Where the content starts, and where the row above it ends. */
contentTop: Math.round(main.getBoundingClientRect().top),
aboveBottom: Math.round(band?.getBoundingClientRect().bottom ?? 0),
};
});
test.describe('the phone has no top bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* The vertical win, measured rather than asserted by the absence of
* an element: `display: none` on the header would satisfy "the bar is
* hidden" while leaving a 3.25em grid row exactly where it was.
*/
test('gives the row back to the content', async ({ app }) => {
const box = await barBox(app);
// Out of flow, so it takes no row — and 1px rather than 0, because
// it still carries the document's h1.
expect(box.position).toBe('absolute');
expect(box.height).toBeLessThanOrEqual(1);
// The content starts where the row above it ends, and there is no
// row above it but the jobs band. On `main` at the time of writing
// the content started 52px down from that point.
expect(box.contentTop).toBe(box.aboveBottom);
});
/**
* The wordmark yields its width and not its existence, which is the
* rule `top-bar-fit.ts` already lives by one band up: with the bar
* gone, `display: none` would take this document from one top-level
* heading to none on every page whose own header has no h1
* Settings has no `page-header` at all.
*/
test('still has a top-level heading', async ({ app }) => {
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And its four controls are gone from the tab order, not merely from
* sight. A visually-hidden container is still focusable, and tabbing
* into a search box nobody can see is worse than not having one.
*/
test('leaves nothing in the bar to tab into', async ({ app }) => {
for (const tag of [
'nav-history',
'library-filter',
'search-bar',
'job-indicator',
]) {
await expect(app.locator(`header.top-bar ${tag}`)).toBeHidden();
}
const focusable = await app.evaluate(
() =>
document
.querySelector('header.top-bar')!
.querySelectorAll('input, select, button, a[href]').length,
);
// Nothing in the bar is *rendered*, so nothing in it can be
// focused; the controls are display:none, which takes their own
// shadow content with them.
expect(focusable).toBe(0);
});
});
test.describe('search on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is a button in the view that can be searched', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
// Scoped to the view: every cached primary view holds a
// `page-header`, and an unscoped testid is `bottom-nav`'s
// "resolved to 2 elements" trap again.
const trigger = app.locator('track-list page-header search-trigger button');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-label', 'Search tracks');
});
/**
* The whole journey, which is the thing the issue asks for: a button,
* a modal, and the results on the page behind it saying what they are
* showing.
*/
test('opens a modal, filters the page, and says so', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await app.locator('track-list page-header search-trigger button').click();
const dialog = app.getByTestId('search-dialog');
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
// so the element carrying the testid always reports hidden — what
// is visible is the native `<dialog>` inside it. That awkwardness
// is written down in CLAUDE.md and is why the assertion that this
// is really up is the role query below.
await expect(dialog).toBeAttached();
// Named, which `getByRole` can answer and the a11y snapshot cannot
// — the snapshot never prints a dialog's name, named or not. This
// is also the assertion that the dialog is genuinely showing.
await expect(
app.getByRole('dialog', { name: 'Search tracks' }),
).toBeVisible();
// Scoped: the header's own box is still in the document, hidden.
// This is the one moment there are two `search-input`s.
await dialog.getByTestId('search-input').fill('aurora');
// Enter hands the screen back, because the results are the page.
await app.keyboard.press('Enter');
await expect(dialog).not.toBeAttached();
// Polled: the box debounces by 150ms, so reading the page once
// straight after closing the dialog can capture the state before
// the term ever reached the store.
await expect
.poll(() =>
app.evaluate(
() =>
document
.querySelector('[data-testid="main-content"] track-list')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim() ?? '',
),
)
.toMatch(/matching.*aurora/);
// And the button says the search is on, in its name rather than
// only in its colour.
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveAttribute('aria-label', /aurora/);
// Leave the app as the next spec expects to find it.
await app.locator('track-list page-header search-trigger button').click();
await app.getByTestId('search-dialog').getByTestId('search-input').fill('');
await app.keyboard.press('Escape');
});
/**
* Two of the seven searchable views have no `page-header` they are
* detail views that filter on the term and say so in their own
* headers. A trigger placed only in `page-header` would leave them
* with a search they can show and no way to set it, which is #24's
* sentence broken in the band it was written for.
*/
test('reaches the playlist detail view too', async ({ app }) => {
await app.getByTestId('tab-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
// `.playlist-item`, which is what the list renders. Asserted to
// exist rather than skipped on: the seed has a playlist, and a
// spec that quietly skips when its selector stops matching is a
// spec that reports success for a renamed class.
const first = app.locator('playlist-view .playlist-item').first();
await expect(first).toBeVisible();
await first.dblclick();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlist-details',
);
await expect(
app.locator('playlist-details search-trigger button'),
).toBeVisible();
});
/**
* A button that cannot do anything is worse than none the rule
* `library-status-indicator` was rewritten on. Home has nothing of
* its own to search and is not in the store's map.
*/
test('offers no button where there is nothing to search', async ({ app }) => {
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'home',
);
await expect(
app.locator('home-view page-header search-trigger button'),
).toHaveCount(0);
});
test('offers no button on a desktop, where the header has a box', async ({
app,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveCount(0);
await expect(app.locator('header.top-bar search-bar')).toBeVisible();
});
});
/**
* #148, which #57 inherits: `library-filter` is the only control in the
* app that calls `setSelectedLibrary`, and the bar it lived in is gone
* on a phone. #143 refused to hide it as a fit step for exactly this
* reason, so dropping it here would have been the same trade.
*/
test.describe('the library filter has a home that is not the bar', () => {
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is in Settings, and is reachable from a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-more').click();
await app.getByTestId('nav-drawer').getByTestId('nav-settings').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'settings',
);
const filter = app.getByTestId('settings-library-filter');
await expect(filter).toBeVisible();
await expect(filter.locator('select')).toBeVisible();
});
test('and it is the same control at every width', async ({ app }) => {
// Not a phone-only copy: "where do I change which library I am
// browsing" having two answers by viewport is the fault, not the
// fix.
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-settings').click();
await expect(app.getByTestId('settings-library-filter')).toBeVisible();
await expect(app.locator('header.top-bar library-filter')).toBeVisible();
});
});
+4 -112
View File
@@ -1,4 +1,4 @@
import { test, expect, LONG_TRACK } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* The phone shell (plan 016 B2, phase 1).
@@ -130,25 +130,6 @@ 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.
@@ -157,78 +138,6 @@ test.describe('the shell on a phone', () => {
.toHaveAttribute('data-active-view', 'tracks');
});
/**
* The same journey with a track that has **no cover art** (#150).
*
* The test above starts the *first* row of the track list, so which
* track it plays is the order the scan inserted them in and the
* answer decided whether it passed. A track with artwork renders an
* `<img>`, which is no obstacle; one without renders a placeholder
* `wa-icon`, which took every click aimed at the button beneath it,
* because that button is absolutely positioned with `z-index: auto`
* and the art is a *later* sibling. They tied, and the later one won.
*
* So this picks a track *for* the property that broke it, which is
* the only way the assertion means anything: the version above passes
* on a broken build roughly two runs in three, which is exactly how
* it came to cost three CI cycles across two branches that could not
* have caused it.
*/
test('opens the full-screen now playing for a track with no art', async ({
app,
}) => {
// `LONG_TRACK` by name, and not "the first track with no
// CoverArt": the *library* model reports that field empty for
// every row in this fixture (31 of 31), so filtering on it selects
// nothing in particular and picked a 2-second track, which had
// finished before the assertions ran. The placeholder check below
// is what actually holds the property this test needs.
const started = await app.evaluate(async (longTitle) => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; TrackName: string }[];
const bare = tracks.find((t) => t.TrackName === longTitle);
if (!bare) return null;
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[[bare.FilePath], 0, false, { type: '', id: 0, label: '' }],
10_000,
);
await window.__yjEvents.call('queue.Queue.Play', [], 5_000);
return bare.TrackName;
}, LONG_TRACK);
expect(started).toBe(LONG_TRACK);
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
// **The placeholder is the whole point**, so it is asserted rather
// than assumed: this test is about the thing that renders when
// there is no artwork. If the fixture ever gives this album a
// cover, this fails and says so instead of passing while measuring
// the easy case.
//
// One selector rather than a chain from the host: Playwright's CSS
// engine pierces an open shadow root, and chaining from the host
// element does not reach into it.
await expect(
app.locator('now-playing .cover-placeholder'),
).toBeAttached();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
await app.getByTestId('npv-back').click();
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
@@ -245,26 +154,9 @@ test.describe('the shell on a phone', () => {
await expect(app.locator('now-playing')).toBeVisible();
// Volume is the hardware keys' job on a phone, and a 4px seek bar
// is not a thumb target -- both belong to the full-screen
// now-playing view.
//
// `.bottom-bar volume-control`, not `audio-player volume-control`:
// #42 moved the control out of that component and into the bar, and
// **the old locator would have kept passing** — `toBeHidden()` is
// satisfied by an element that does not exist, so this assertion
// would have gone on reporting success about nothing. Its partner
// below is what makes this one mean something.
await expect(app.locator('.bottom-bar volume-control')).toBeHidden();
// The element is there and hidden, rather than absent: the check
// above cannot tell those apart on its own.
await expect(app.locator('.bottom-bar volume-control')).toHaveCount(1);
// And the seek bar is still inside the transport, where it stands
// down by its own media query.
await expect(
app.locator('audio-player').locator('seek-bar'),
).toBeHidden();
// is not a thumb target -- both belong to a later phase's
// full-screen now-playing view.
await expect(app.locator('audio-player volume-control')).toBeHidden();
});
});
-315
View File
@@ -1,315 +0,0 @@
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);
});
});
-373
View File
@@ -1,373 +0,0 @@
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #55 the queue is a *place* while it covers the content, and a
* *control* while it sits beside it.
*
* #24 already made the pixels right: measured at the reference device's
* 424×439, the overlaid panel is 424×318, which is `.main-panel`'s rect
* exactly. What was missing was the navigation model, and the defect was
* measurable in one line opening the queue on Artists and pressing
* back moved the page *underneath* to Albums and left the queue up. A
* back press that changes something the user cannot see, and costs them
* their place, is the whole of "it does not flow".
*
* **These assert the entry, not the attribute.** The temptation is to
* check `#queue-button[aria-expanded]` and stop, which is the shell's
* own bookkeeping and was right throughout the bug: what has to be true
* is that *one* back press closes the queue and the *next* one
* navigates. Asserting only the first would pass on a build that
* orphans the entry, which is the defect moved one press later the
* same trap `back-navigation.spec.ts` documents about `data-active-view`
* and `layout-overflow.spec.ts` set for #69.
*
* **Three of these nine fail on the build before #55**, and the other
* six cannot, which is worth knowing before trusting them: "the entry
* is not orphaned" and "the column is not in the stack" are both
* vacuously true of a build that pushes no entry at all, and the
* containment assertion pins the mount that was *not* taken. They guard
* the next change rather than reproducing this one the three that
* reproduce it are the two back-press tests and the touch target.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport, not a resized desktop. */
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> {
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', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-albums').click();
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 openTheQueue(app);
await expectQueue(app, true);
await app.goBack();
await expectQueue(app, false);
// 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',
'now-playing',
);
});
test('costs exactly one entry, so the next press navigates', async ({
app,
}) => {
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();
// 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');
});
/**
* Every route out unwinds the entry, and they do it through the
* panel's own `open` attribute rather than each knowing about
* history which is why a fourth route added later gets this free.
*
* The failure this pins is silent: close by button, and if the entry
* is orphaned the app looks correct until the next back press does
* nothing at all. It is a guard rather than a reproduction a build
* with no entry to orphan passes it and it is paired with the two
* above, which do reproduce.
*/
for (const [name, dismiss] of [
[
'the close button',
async (app: Page) => {
await app.getByRole('button', { name: 'Close queue' }).click();
},
],
[
'Escape',
async (app: Page) => {
await app.keyboard.press('Escape');
},
],
] as Array<[string, (app: Page) => Promise<void>]>) {
test(`${name} leaves no entry behind`, async ({ app }) => {
await openTheQueue(app);
await expectQueue(app, true);
await dismiss(app);
await expectQueue(app, false);
await app.goBack();
// 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',
);
});
}
/**
* 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.
*/
/**
* With the panel spanning the whole width there is no scrim here at
* all (#171), so the close button is the only pointer route out of a
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
*/
test('offers a way out a thumb can hit', async ({ app }) => {
await openTheQueue(app);
const box = await app
.getByRole('button', { name: 'Close queue' })
.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
/**
* #171 and it draws no scrim, because there is nowhere to tap.
*
* `.panel-content` is `width: 100%` here, so the scrim sat entirely
* underneath it: measured at 424×439, host, panel and scrim all
* 424×318. #24's tap-outside-to-close cannot exist on a surface with
* no outside, and a `cursor: pointer` layer nobody can reach is a
* claim the component cannot keep.
*
* Asserted as absence rather than by clicking, for the reason the
* issue gives: a naive phone case clicks the scrim's centre and hits
* the panel, so it passes on the build this exists to fail. The scrim
* is still real between 600 and 899px, which `queue-overlay.spec.ts`
* asserts at 900×600 by clicking it.
*/
test('draws no scrim, because a screen has no outside to tap', async ({
app,
}) => {
await openTheQueue(app);
const scrim = await queue(app).evaluate(
(el) => el.shadowRoot!.querySelector('.scrim') !== null,
);
expect(scrim).toBe(false);
});
});
/**
* **The mechanism, because no tier here can see the consequence.**
*
* #55's Direction asked for a `DETAIL_LOADERS` mount, which would put
* the panel inside `.main-panel > *`. That box is paint-contained under
* a `.main-panel` that is too, and `contain: paint` makes an element a
* containing block for fixed descendants *and clips them* which is
* what a `wa-popup` falls back to on the reference device's Chrome 113,
* where the Popover API does not exist (#60, `.planning/NOTES.md`).
* `queue-panel` has a context menu, so that mount would have broken a
* working menu on the one device this issue is about.
*
* CI's Chromium and WebKit both *have* the Popover API, so the menu is
* top-layered and correct here either way: a spec asserting "the menu is
* not clipped" is green on the broken build. What a browser can answer
* honestly is where the element is, so that is what this asks.
*/
test('the panel stays out of the paint-contained region', async ({ app }) => {
await app.setViewportSize(DEVICE);
// Open, because that is the only state in which a menu can be opened
// 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 openTheQueue(app);
await expectQueue(app, true);
const ancestry = await app.evaluate(() => {
const chain: Array<{ tag: string; contain: string }> = [];
for (
let el = document.getElementById('queue-panel');
el && el !== document.documentElement;
el = el.parentElement
) {
chain.push({
tag: el.tagName.toLowerCase(),
contain: getComputedStyle(el).contain,
});
}
return chain;
});
expect(ancestry.length).toBeGreaterThan(1);
expect(ancestry.some((a) => a.tag === 'main')).toBe(false);
for (const { tag, contain } of ancestry) {
expect(
`${tag}: ${contain}`,
'a paint-contained ancestor clips a fixed-positioned popup on Chrome 113',
).not.toMatch(/paint|content|strict/);
}
});
/**
* 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.
*
* This is the half a viewport breakpoint would get wrong: the mode is
* computed from the panel's own drag-resizable width, so the queue
* becomes a screen exactly when it stops being affordable as a column.
*/
test.describe('a docked queue is not in the back stack', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('survives a navigation, and back navigates the page', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await toggle(app).click();
await expectQueue(app, true);
await expect(queue(app)).not.toHaveAttribute('overlay', '');
await app.getByTestId('nav-artists').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
await expectQueue(app, true);
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectQueue(app, true);
});
});
-187
View File
@@ -1,187 +0,0 @@
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #24 the queue panel does not take the page's width away from it.
*
* The panel is `flex-shrink: 0` in the flow of `.content-area`, so an
* open queue used to be paid for by the main panel. Measured on
* Playlists before the fix:
*
* | viewport | main panel |
* |---|---|
* | 900×600 | 379px all three header actions clipped |
* | 390×780 | 69px |
* | 320×600 | **0px** |
*
* **900×600 is the worst desktop case, not the 800×600 minimum**, and
* that is the trap this file exists to keep closed: the sidebar
* collapses to icons *below* 900, so the main panel is 843px at 899 and
* 700px at 900. A spec that checks "the minimum" and stops has not
* checked the worst case which is what every viewport list in this
* suite did before this.
*
* These assert the *content's* width rather than the panel's mode
* wherever they can, because the mode is the mechanism and the width is
* the complaint.
*/
/** The bands from plan 018's size matrix, plus the pixel above the collapse. */
const BANDS = [
{ name: 'a wide desktop (1280×800)', width: 1280, height: 800, inline: true },
{ name: 'the default window (1100×720)', width: 1100, height: 720, inline: true },
{ name: 'a laptop (1024×768)', width: 1024, height: 768, inline: true },
{ name: 'the worst desktop width (900×600)', width: 900, height: 600, inline: false },
{ name: 'the enforced minimum (800×600)', width: 800, height: 600, inline: false },
{ name: 'a phone (390×780)', width: 390, height: 780, inline: false },
{ name: '400% zoom (320×600)', width: 320, height: 600, inline: false },
];
/**
* How much room the content has, and whether the shell needs scrolling
* to reach any of itself.
*/
const shellGeometry = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const main = document.querySelector('#main-content')!.getBoundingClientRect();
const panel = document.querySelector('#queue-panel')!;
return {
mainWidth: Math.round(main.width),
overlay: panel.hasAttribute('overlay'),
open: panel.hasAttribute('open'),
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
};
});
/**
* 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) {
test(`at ${band.name}`, async ({ app }) => {
await app.setViewportSize({ width: band.width, height: band.height });
await openQueue(app);
// The mode is settled by a ResizeObserver, so poll rather than
// read once: a single read races the resize and reports the
// previous viewport's answer.
await expect
.poll(async () => (await shellGeometry(app)).overlay)
.toBe(!band.inline);
const geo = await shellGeometry(app);
// The floor is the point of the whole issue. Inline, the queue is
// affordable and the content keeps the rest; as an overlay the
// content keeps *everything*, which is what makes 0px at 320
// impossible rather than merely unlikely.
expect(geo.mainWidth).toBeGreaterThanOrEqual(320);
if (!band.inline) {
expect(geo.mainWidth).toBeGreaterThanOrEqual(
Math.min(band.width, 320),
);
}
// And opening the queue must not make the shell overflow.
expect(geo.bodyScrollWidth).toBeLessThanOrEqual(geo.bodyClientWidth);
});
}
});
test.describe('an overlaid queue says it is over the content', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
});
test('draws a scrim and closes when it is clicked', async ({ app }) => {
await openQueue(app);
const panel = app.locator('#queue-panel');
await expect(panel).toHaveAttribute('overlay', '');
// The scrim is `aria-hidden` on purpose — it is a dismissal target,
// and the named routes out are the close button and Escape — so it
// is located structurally rather than by role.
await panel.evaluate((el) =>
el.shadowRoot!.querySelector<HTMLElement>('.scrim')!.click(),
);
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
/**
* `getByRole`, not a shadow-root query: this repo has shipped a
* nameless control three times, and a drawer with a scrim is exactly
* the shape that grows a fourth.
*/
test('offers a named close button', async ({ app }) => {
await openQueue(app);
const close = app.getByRole('button', { name: 'Close queue' });
await expect(close).toBeVisible();
await close.click();
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('closes on Escape and gives focus back to the toggle', async ({
app,
}) => {
const toggle = app.locator('#queue-button');
await toggle.focus();
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await app.keyboard.press('Escape');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(toggle).toBeFocused();
});
});
/**
* The inline panel is the mode that already worked, and the one every
* other queue spec is written against. It keeps its resize handle and
* gains none of the overlay's chrome.
*/
test.describe('a wide window keeps the queue beside the content', () => {
test('no scrim, no close button, and the content is narrower', async ({
app,
}) => {
await app.setViewportSize({ width: 1280, height: 800 });
const widthWithoutQueue = (await shellGeometry(app)).mainWidth;
await openQueue(app);
await expect(app.locator('#queue-panel')).not.toHaveAttribute(
'overlay',
'',
);
const geo = await shellGeometry(app);
expect(geo.mainWidth).toBeLessThan(widthWithoutQueue);
await expect(
app.getByRole('button', { name: 'Close queue' }),
).toHaveCount(0);
});
});
-299
View File
@@ -1,299 +0,0 @@
import {
test,
expect,
callBinding,
navigateTo,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The queue panel's mouse model (#43): single click selects, ctrl and
* shift extend, double click plays from that row.
*
* **All four already worked, and nothing pinned any of them** which is
* the whole reason the report could be made and could not be settled.
* `queue-reorder.spec.ts` covers the keyboard, `queue-overlay.spec.ts`
* covers the panel's mode, and the component tier has the reorder
* arithmetic; the pointer path had no coverage in either tier, so
* "selection is broken here" and "selection is fine here" were equally
* consistent with a green suite.
*
* Two things this spec is deliberately shaped around.
*
* **The clicks are real.** A `dispatchEvent(new MouseEvent('click'))`
* on a row exercises the delegated handler and *not* the question being
* asked, which is what the pointer lands on: the rows carry
* `explore-link` names that take their own clicks, and a synthetic
* event aimed at the row reports a selection the mouse would never have
* produced. Every click here goes through Playwright.
*
* **The playing assertions use the 90-second fixture.** Every other
* track is 26 seconds, so "double click plays row 3" read against a
* 2-second track reports whatever auto-advance moved on to measured
* during this work as row 3 double-clicked and row 4 playing, which
* reads exactly like an off-by-one in `PlayIndex` and is not one.
*/
/**
* How long a click may take to show up as a highlight.
*
* **A poll with the default 5s timeout cannot see this defect**, and
* that is the point of naming it. `queue-panel` repaints its rows two
* ways `onSelectionChanged()` calls `virtualizer.requestUpdate()`,
* and `.keyFunction` is a per-render arrow, which is itself a changed
* property the virtualizer reacts to. With **both** removed the
* highlight still arrives, on whatever unrelated render happens next:
* measured at 134ms, 3,866ms and 5,816ms for three clicks, against
* 5ms, 16ms and 17ms on a healthy build.
*
* A user cannot tell "four seconds late" from "broken", which is very
* close to what this issue reports. So the assertion is that the
* highlight is *prompt*, with a bound ~30x the measured healthy case
* and an order of magnitude under the degraded one.
*/
const HIGHLIGHT_MS = 500;
/** The queue's own answer, never the DOM's. */
async function playing(app: Page): Promise<{ index: number; title: string }> {
const state = await callBinding<{
currentIndex: number;
tracks: { title: string }[];
}>(app, 'queue.Queue.GetState');
return {
index: state.currentIndex,
title: state.tracks[state.currentIndex]?.title ?? '',
};
}
/** Which rows are selected, as the accessibility tree sees it. */
const selected = (app: Page) =>
app.evaluate(() =>
[
...document
.querySelector('queue-panel')!
.shadowRoot!.querySelectorAll('[data-index]'),
]
.filter((row) => row.getAttribute('aria-selected') === 'true')
.map((row) => Number((row as HTMLElement).dataset['index'])),
);
/**
* Six tracks with the long one in the middle, so a "play from here"
* assertion has something to land on that will still be playing when it
* is read back.
*/
async function queueSixAndOpen(app: Page): Promise<void> {
const paths = await app.evaluate(async (longTitle) => {
// `TrackName`, not `Title`: the library model names it after the
// tag, and the *queue* is what calls it `title`.
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; TrackName: string; Album: string }[];
const long = tracks.find((t) => t.TrackName === longTitle);
/**
* **Tracks that have an album**, which is a requirement of one of
* the tests and was previously left to luck (#156).
*
* `explore-link` routes a track name to its *album's* page, so a
* track with no album renders a name that navigates nowhere and
* the fixture library deliberately contains two (`01 Tone A`,
* `02 Tone B`). Which tracks arrive first is `audio_files.id`
* order, i.e. the order the **scan** inserted them, which depends
* on concurrency and directory traversal: locally the first eight
* all had albums and the spec passed twice over, and CI rebuilds
* its seed with a real scan and got a different eight.
*
* Asking for what the test needs is the fix. It is not a
* narrowing: every assertion here wants an ordinary track, and
* "the first five rows" was never a way to ask for one in a
* library whose whole purpose is edge cases.
*/
const rest = tracks
.filter((t) => t.TrackName !== longTitle && t.Album !== '')
.slice(0, 5);
// Index 3 is the long one: far enough down that a shift-extend has
// room either side of it.
return [
...rest.slice(0, 3).map((t) => t.FilePath),
long!.FilePath,
...rest.slice(3).map((t) => t.FilePath),
];
}, LONG_TRACK);
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
// A closed panel renders no list at all.
await app.locator('#queue-button').click();
await expect(app.locator('queue-panel .track-item').first()).toBeVisible();
await expect(app.locator('queue-panel .track-item')).toHaveCount(6);
}
/** The row at a data-index, not the nth child: see the note in the file. */
const row = (app: Page, index: number) =>
app.locator(`queue-panel .track-item[data-index="${index}"]`);
test.describe('selecting in the queue with a mouse', () => {
// The suite shares one backend in file order, and a queue and an open
// panel both outlive the page. `queue-reorder.spec.ts` sets the
// precedent and the reason: a spec that spends state fails the next
// one, in a list that reads like a regression in whatever you hold.
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* an empty queue is the state we were asking for */
});
const open = await app.locator('queue-panel[open]').count();
if (open > 0) await app.locator('#queue-button').click();
});
test('a single click selects that row and only that row', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
// And it *replaces* rather than accumulating, which is the half a
// test of one click cannot see.
await row(app, 4).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([4]);
});
test('ctrl adds a row and shift extends a range', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await row(app, 3).click({ modifiers: ['Control'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3]);
// From the last row touched, so 3→5, keeping the ctrl-picked 1.
await row(app, 5).click({ modifiers: ['Shift'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3, 4, 5]);
// A plain click collapses the whole thing back to one.
await row(app, 2).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([2]);
});
test('a double click plays from that row', async ({ app }) => {
await queueSixAndOpen(app);
// Row 3 is the 90-second track. Asked of the backend, because the
// panel's own highlight is a different claim.
await row(app, 3).dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
// Playing is not selecting: the double click clears the selection
// it made on the way through, or every play leaves a row looking
// picked out for an action the user did not ask for.
await expect.poll(() => selected(app)).toEqual([]);
});
/**
* The one collision the report is actually about.
*
* Every track, album and artist name in the app navigates
* (`utils/explore-link.ts`), and it does that by **stopping the
* click's propagation** in its own words, "the row must not also
* treat it as a selection". So a click that lands on the name text
* navigates and selects nothing, in the queue panel and in the track
* list alike.
*
* That is deliberate and it is pinned here rather than argued with,
* because the measurement says the queue is not the surface where it
* hurts: a horizontal hit-scan of a row at three heights makes the
* queue row **12%** link and the track list's row **21%** the panel
* the report calls broken is *less* covered by links than the list it
* calls correct. What is left is one deliberate exception, and a
* change to it should have to fail a test.
*/
test('a click on a name navigates instead, and that is the exception', async ({
app,
}) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
// `.track-title .explore-link`, not `.explore-link` first(): a row
// has two, and which one `first()` finds depends on whether the
// *title* is a link at all. It is not, for a track with no album —
// `explore-link` renders plain text where it cannot route — so the
// loose locator silently clicked the **artist** instead and the
// assertion below was about a different destination than the one
// being exercised (#156).
await row(app, 2).locator('.track-title .explore-link').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
// Row 2 did not join the selection — the link took the click.
await expect.poll(() => selected(app)).toEqual([1]);
await navigateTo(app, 'tracks');
});
/**
* And the other half of that bargain: the link holds its navigation
* for one double-click interval and drops it if a second click
* arrives, so double-clicking a *name* still plays the row rather
* than navigating away from it. That is what makes the exception
* above survivable, and it is the part most likely to break silently
* if the grace interval is ever removed.
*/
test('a double click on a name plays rather than navigating', async ({
app,
}) => {
await queueSixAndOpen(app);
// Read rather than assumed: which view the app lands on is the
// user's `DefaultPage`, so naming one here would be asserting on a
// config value in a test about a double click.
const before = await app
.getByTestId('main-content')
.getAttribute('data-active-view');
await row(app, 3).locator('.explore-link').first().dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
before!,
);
});
});
+6 -11
View File
@@ -1,4 +1,4 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app.
@@ -18,19 +18,16 @@ test.describe('Settings is reachable without a mouse', () => {
}) => {
await app.getByTestId('nav-settings').click();
// Per *section*, not per `.header`: a section holding a
// `job-panel` (#27) also contains `job-details-drawer`, whose own
// header carries that class and is not a disclosure.
const sections = app.locator('config-page config-section');
const headers = app.locator('config-page config-section .header');
await expect(sections.first()).toBeVisible();
await expect(headers.first()).toBeVisible();
const count = await sections.count();
const count = await headers.count();
expect(count).toBeGreaterThan(4);
for (let i = 0; i < count; i++) {
const header = sections.nth(i).locator('.header').first();
const header = headers.nth(i);
expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON');
expect(['true', 'false']).toContain(
@@ -59,9 +56,7 @@ test.describe('Settings is reachable without a mouse', () => {
test.describe("Downloads' tabs are tabs", () => {
test('arrow keys move the selection and swap the panel', async ({ app }) => {
// By event, not by nav item: with no download client configured
// there is no Downloads destination to click (#25).
await navigateTo(app, 'downloads');
await app.getByTestId('nav-downloads').click();
const view = app.locator('downloads-view');
const requests = view.getByRole('tab', { name: 'Requests' });
-269
View File
@@ -1,269 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* The top bar fits the window it is in (#143).
*
* **This is measured per child, not on the shell**, which is #69's
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
* the *document* needs no sideways scrolling, and clipping inside a
* component is invisible to it which is exactly why that spec was
* green throughout this defect. What a user sees is a control rendered
* past the edge of the bar it belongs to, so that is what is asserted.
*
* **And it is measured with a job running**, which is the half the
* original report missed. `job-indicator` is `hidden` while idle and up
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
* still and 862px during a scan 171 to 262px of overflow, arriving
* exactly when a user has reason to look at that bar. Nothing else in
* this suite has ever measured a layout with work in flight;
* `/__test/emit` stages it without staging the scan.
*/
type Page = import('@playwright/test').Page;
/**
* The widths this asks about.
*
* 600 is the bottom of the Compact band (#24) and where the defect
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
* find, at the width that just gained the sidebar's labels); 800 is the
* enforced minimum.
*
* **390 is kept, and what it asks changed with #57.** There is no bar
* to fit below 600px any more it is out of the grid and visually
* hidden so "nothing hangs out of it" is a claim about an element
* with no row, and would pass on a build that had merely broken the
* bar. Dropping the width would be dropping the one place this file
* can still say something true about a phone, so it asserts the
* *stronger* property instead, below: the bar is out of the layout
* altogether, which is the thing #57 wanted and the thing that makes
* fitting moot.
*/
const WIDTHS = [600, 800, 899, 900, 1440];
/** Where #57 leaves the bar, and where the desktop still has one. */
const PHONE_WIDTH = 390;
/**
* A scan whose title is as long as a real one gets. The label is capped
* at 12rem by the component, so this is the widest the indicator can
* be measuring with "Scanning" instead reports a bar that fits and a
* defect that is 100px smaller than it is.
*/
const LONG_JOB = {
id: 'top-bar-fit',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music from the external drive',
current: 40,
total: 100,
};
/**
* Every child's right edge against the bar's own content box.
*
* The content box, not `clientWidth`: the bar has a 2em right gutter,
* and a control sitting in the padding is already the failure it is
* simply one that `scrollWidth` under-reports, because `scrollWidth`
* counts the left padding and not the right.
*/
const overflowingChildren = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
const left = box.left + parseFloat(style.paddingLeft);
const right = box.right - parseFloat(style.paddingRight);
return [...bar.children]
.filter((child) => {
const cs = getComputedStyle(child);
// Out of flow is out of the question: a collapsed wordmark is
// `position: absolute` and 1px wide precisely so it costs the
// row nothing.
if (cs.display === 'none' || cs.position === 'absolute') return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
})
.map((child) => {
const r = child.getBoundingClientRect();
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
});
});
/** What the fit pass gave up, read back off the DOM it changed. */
const collapsed = (page: Page) =>
page.evaluate(() => ({
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
jobLabel: !!document.querySelector('job-indicator[compact]'),
}));
test.describe('the top bar fits the window', () => {
/**
* The phone's answer, which is not "it fits" (#57).
*
* The bar has no grid row below 600px, so measuring its children
* against its content box is measuring a 1px box that is already
* invisible a fit pass would collapse the wordmark every time and
* report success about nothing, which is why `measureTopBarFit`
* declines to run at all when the bar is out of flow. What is worth
* asserting here is that the fit pass has not quietly started
* *undoing* that: a rule that put the bar back in the layout would
* pass every assertion in this file and cost a 439px screen 12% of
* its height.
*/
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// Not merely hidden: `display: none` on the header would satisfy
// "invisible" and leave the 3.25em row exactly where it was. So
// the assertion is that the content starts where the row above it
// ends -- and with a job staged, the row above it is the jobs
// band, which is the whole reason this row could go.
await expect
.poll(() =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band')!;
return {
position: getComputedStyle(bar).position,
gap:
Math.round(main.getBoundingClientRect().top) -
Math.round(band.getBoundingClientRect().bottom),
};
}),
)
.toEqual({ position: 'absolute', gap: 0 });
// And the work is still visible, in the band that replaced the
// indicator (#62) — which is what made this row removable at all.
await expect(app.locator('job-indicator')).toBeHidden();
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
await app.setViewportSize({ width: 1440, height: 900 });
});
for (const width of WIDTHS) {
test(`no control sits outside the bar at ${width}px, idle`, async ({
app,
}) => {
await app.setViewportSize({ width, height: 600 });
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// The indicator has to actually be up, or this test passes by
// measuring the idle case under another name.
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
}
/**
* The other half of "measured, never breakpointed": a rule that
* collapses defensively at every narrow width fits just as well and
* is a worse app. 1440 is roomy at any job title; 899 was measured to
* fit with the longest one, because `nav-history` is not there yet.
*/
test('nothing is given up where there is room for it', async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await testctl.emit('JobsChanged', [LONG_JOB]);
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* And it gives them back. The pass starts from all-visible every
* time, so this is the property that a rule which only ever *added*
* to the collapsed set would fail the wordmark would be gone for
* the rest of the session after one narrow moment.
*/
test('the wordmark comes back when the window does', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: true,
jobLabel: true,
});
await app.setViewportSize({ width: 1440, height: 900 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* The wordmark yields its width and not its existence: `display:
* none` would take the document from one top-level heading to none.
*/
test('the collapsed wordmark is still the document heading', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And the indicator keeps saying what it is doing after its visible
* label goes the `sr-only` live region is what announces the state,
* which is the same argument the phone's own rule was written on.
*/
test('the job indicator still announces its state without its label', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
const spoken = await app
.locator('job-indicator')
.evaluate(
(el) =>
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
);
expect(spoken).toContain('Scanning Music from the external drive');
// Leave the app as the next spec expects to find it.
await app.setViewportSize({ width: 1440, height: 900 });
});
});
-311
View File
@@ -1,311 +0,0 @@
import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
* from plan 016 B2).
*
* The component tier proves the gestures in isolation, against markup
* it built itself. What it cannot prove is the half that made this one
* document listener instead of six: that the announced gesture reaches
* the handler a *real* component bound `track-list` delegates on the
* `lit-virtualizer` rather than binding per row and that the real
* menu opens from it, a path with its own history of opening and then
* refusing to work (see `menu-keyboard.spec.ts`).
*
* **Both halves of the reassignment are here, and the second is the
* one that matters.** #63 makes a hold on a *track row* mean selection
* mode; every other surface in the app keeps the context menu it has
* had, because an unclaimed `yj-long-press` still becomes a
* `contextmenu`. A spec that only checked the row would pass on a
* build that had silently broken the other thirteen menus.
*
* The pointer events are dispatched rather than performed: this
* project runs Desktop Chrome and Desktop Safari, neither of which has
* touch. So this is honest about what it checks the app's own
* listeners, on the app's own DOM, from the events a touch would
* produce and not about a real finger. The finger is the Android
* tier, and it found something this cannot see: Chrome 113's WebView
* fires its own `contextmenu` on a long press, which is why the module
* announces the gesture from a native event rather than standing down.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** A component's menu panel, or null while it is not rendered. */
const panel = (page: Page, host: string) =>
page.evaluate((tag) => {
const el = document
.querySelector(tag)
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
}, host);
/** How many tracks the selection bar says are selected, or null. */
const selectionCount = (page: Page) =>
page.evaluate(() => {
const bar = document
.querySelector('track-list')
?.shadowRoot?.querySelector('selection-bar');
return bar ? (bar as unknown as { count: number }).count : null;
});
/**
* Press an element, optionally dragging partway through the shape of
* a scroll that begins on a row, which must be neither gesture and
* optionally lifting, which is what makes it a tap rather than a hold.
*/
async function press(
page: Page,
selector: { host: string; inner: string },
opts: { driftY?: number; lift?: boolean } = {},
): Promise<void> {
await page.evaluate(
({ host, inner, drift, lift }) => {
const el = document
.querySelector(host)
?.shadowRoot?.querySelector(inner);
if (!el) throw new Error(`no ${inner} in ${host} to press`);
const box = el.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
if (lift) send('pointerup');
},
{
host: selector.host,
inner: selector.inner,
drift: opts.driftY ?? 0,
lift: opts.lift ?? false,
},
);
}
// `.track-row`, not `[role="row"]`: the column header is a row too, and
// it is the *first* one — a press on it is correctly ignored, which
// reads exactly like the gesture not working.
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
test.describe('a hold on a track row selects it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('raises the selection bar rather than the context menu', async ({
app,
}) => {
await expect.poll(() => selectionCount(app)).toBeNull();
await press(app, TRACK_ROW);
await expect
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
.toBe(1);
// The gesture is claimed, so the menu this hold used to open must
// not also be up -- on a phone that would be a sheet over the bar.
expect(await panel(app, 'track-list')).toBeNull();
});
test('is neither gesture when the press turns into a scroll', async ({
app,
}) => {
await press(app, TRACK_ROW, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await selectionCount(app)).toBeNull();
expect(await panel(app, 'track-list')).toBeNull();
});
});
test.describe('a hold anywhere else still opens the menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
// The property that let #63 reassign the hold without touching one
// of the fourteen context menus: unclaimed, it is what it was.
// Without this half, breaking all of them passes the suite.
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
await expect
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
});
});
/**
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
*
* The component tier has the rule this obeys one row is a position,
* several are a choice against a queue that is a fake. What is only
* true here is that the gesture reaches the *real* queue: `AddTracks`
* is a Go method, the queue is persisted, and "the row was added"
* is a question only the backend can answer.
*
* **It is Chromium-only, and that is a property of the browser rather
* than a gap.** The gesture runs on touch events, because Chrome 113's
* WebView cancels the pointer stream ~16px into any drag whatever
* `touch-action` says. Desktop WebKit implements no `TouchEvent`
* constructor at all touch events are a mobile-Safari surface so
* the events this needs cannot be built there. Skipping loudly is
* better than a spec that quietly asserts nothing on half the matrix,
* which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
* were each doing when they were green on a broken build.
*/
test.describe('a swipe right on a track row queues it', () => {
test.beforeEach(async ({ app, browserName }) => {
test.skip(
browserName !== 'chromium',
'desktop WebKit has no TouchEvent constructor to build the gesture from',
);
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Drag the first row sideways by a fraction of its own width and
* lift. `fraction` is against the row, because the commit threshold
* is a number of pixels here would be a second declaration of it,
* right on one viewport and wrong on the next.
*/
const swipeFirstRow = (page: Page, fraction: number) =>
page.evaluate((f) => {
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to swipe');
const box = row.getBoundingClientRect();
const y = box.top + box.height / 2;
const at = (x: number) =>
new Touch({
identifier: 1,
target: row,
clientX: box.left + x,
clientY: y,
});
const send = (type: string, points: Touch[]) =>
row.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: points,
changedTouches: points.length > 0 ? points : [at(0)],
}),
);
send('touchstart', [at(0)]);
for (const step of [0.25, 0.5, 0.75, 1]) {
send('touchmove', [at(box.width * f * step)]);
}
send('touchend', []);
}, fraction);
/** How many tracks the backend says are in the queue. */
const queueLength = async (page: Page) => {
const state = await callBinding<{ tracks: unknown[] }>(
page,
'queue.Queue.GetState',
);
return state.tracks?.length ?? 0;
};
test('adds exactly one track to the real queue', async ({ app }) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.6);
await expect.poll(() => queueLength(app)).toBe(before + 1);
// Queued, not played: a swipe is not a tap, and the difference is
// what is on screen afterwards.
expect(
await app.getByTestId('main-content').getAttribute('data-active-view'),
).toBe('tracks');
});
test('does nothing when the finger did not get far enough', async ({
app,
}) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.1);
await app.waitForTimeout(400);
expect(await queueLength(app)).toBe(before);
});
});
+2 -7
View File
@@ -4,7 +4,6 @@ import {
eventNames,
resetEvents,
waitForEvent,
navigateTo,
} from '../support/fixtures.js';
/**
@@ -40,9 +39,7 @@ test.describe('view lifecycle', () => {
test('a keypress on Settings does not reach the Autotag queue', async ({
app,
}) => {
// By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await app.getByTestId('nav-autotag').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
@@ -86,9 +83,7 @@ test.describe('view lifecycle', () => {
// The other half of the same bug (H-2): two document keydown handlers
// with no arbitration meant `s` on this page skipped the album *and*
// toggled shuffle. As a panel binding it can only mean one thing.
// By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await app.getByTestId('nav-autotag').click();
await expect
.poll(() => pendingCount(app))
.toMatch(/^Pending \(\d+\)$/);
-116
View File
@@ -1,116 +0,0 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
/**
* Which destinations the navigation offers (#25).
*
* Eleven sidebar entries is more than most libraries need, so they are
* individually toggleable from Settings, Autotag is off until asked for
* and Downloads is absent until there is a client to download with.
*
* **The assertions are about the navigation, not about the setting.**
* "The config was saved" is the plumbing, and the two most recent bugs
* in this area #69 and #72 both shipped green under specs that
* measured exactly that. What a person sees is whether the item is in
* the accessibility tree, and whether the view is still reachable when
* it is not.
*
* This runs against the seeded app, whose config is defaults and whose
* download client list is empty, so the initial state below is what a
* fresh install looks like.
*/
type Page = import('@playwright/test').Page;
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/** The Navigation section's checkbox for a destination. */
const viewToggle = (page: Page, label: string) =>
page.getByRole('checkbox', { name: `Show ${label} in the navigation` });
async function openNavigationSettings(page: Page): Promise<void> {
await page.getByTestId('nav-settings').click();
const section = page.locator(
'config-page config-section[heading="Navigation"] .header',
);
await expect(section).toBeVisible();
if ((await section.getAttribute('aria-expanded')) === 'false') {
await section.click();
}
await expect(section).toHaveAttribute('aria-expanded', 'true');
}
test.describe('configurable destinations', () => {
test('Autotag is off by default and Downloads needs a client', async ({
app,
}) => {
await expect(app.getByTestId('nav-home')).toBeVisible();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
await expect(app.getByTestId('nav-downloads')).toHaveCount(0);
});
/**
* Hiding takes the item away and nothing else. Detail views navigate
* into these and the launch page is one of them, so a destination
* with no nav item still has to open.
*/
test('a hidden destination is still reachable', async ({ app }) => {
await navigateTo(app, 'autotag');
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// And nothing is falsely lit while standing on it -- the same rule
// a detail view follows, with no special case for either.
await expect(navItem(app, 'Home')).toHaveAttribute('aria-current', 'false');
});
test('switching Autotag on adds it to the sidebar', async ({ app }) => {
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').check();
await expect(app.getByTestId('nav-autotag')).toBeVisible();
// Clicking it is the point of having it.
await app.getByTestId('nav-autotag').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// Put it back, or the next spec against this app sees a library
// this one changed.
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').uncheck();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
});
/**
* Settings has no toggle at all, rather than a toggle that refuses:
* a user who hides it cannot get back to unhide it. The backend
* refuses it too, because `config.toml` is hand-editable.
*/
test('Settings cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Settings')).toBeDisabled();
await expect(app.getByTestId('nav-settings')).toBeVisible();
});
/**
* The launch page is refused while it is the launch page, which is a
* state the user can leave by changing the launch page above it.
*/
test('the launch page cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Home')).toBeDisabled();
});
});
-73
View File
@@ -111,79 +111,6 @@ export async function bindingCalls(page: Page): Promise<string[]> {
return calls.map(nameOf);
}
/**
* Go to a view without going through the navigation.
*
* `navigate` is the event the shell listens for and every nav item, card
* and detail view dispatches, so this is the app's own mechanism rather
* than a test-only door. It exists because a destination is not
* guaranteed to have a nav item any more (#25): Autotag is hidden until
* the user asks for it and Downloads until a client exists, and a spec
* about what a *view* does should not also be asserting that the
* sidebar offers it.
*/
export async function navigateTo(page: Page, view: string): Promise<void> {
await page.evaluate(
(v) =>
void document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: v },
bubbles: true,
composed: true,
}),
),
view,
);
await page
.getByTestId('main-content')
.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) {}
@@ -69,14 +69,6 @@ export function GetPinDefaultPlaylist(): $CancellablePromise<boolean> {
return $Call.ByID(3818283301);
}
/**
* GetPopupVolume reports whether the bottom bar's volume control is a
* click-to-open popup rather than an inline slider (#42).
*/
export function GetPopupVolume(): $CancellablePromise<boolean> {
return $Call.ByID(2885777);
}
/**
* GetQueueFallback returns what plays, if anything, once the queue
* runs out.
@@ -120,16 +112,6 @@ export function GetTrackListColumns(): $CancellablePromise<tracklist$0.Column[]
return $Call.ByID(3426289065);
}
/**
* GetViewVisibility reports which primary views the sidebar should
* show, answered for every known view rather than only the ones the
* config mentions -- so the frontend filters on a value and never has
* to hold a second copy of the defaults.
*/
export function GetViewVisibility(): $CancellablePromise<{ [_ in string]?: boolean } | null> {
return $Call.ByID(2798108026);
}
/**
* Load reads and parses the config file from disk.
*/
@@ -215,18 +197,6 @@ export function SetPinDefaultPlaylist(pin: boolean): $CancellablePromise<void> {
return $Call.ByID(372446849, pin);
}
/**
* SetPopupVolume saves the volume control's presentation.
*
* Nothing to validate: both values are legal at every width, and the
* frontend additionally stands the inline slider down below the phone
* breakpoint whatever this says, because that is about room rather than
* about preference.
*/
export function SetPopupVolume(popup: boolean): $CancellablePromise<void> {
return $Call.ByID(1430308453, popup);
}
/**
* SetQueueFallback validates and saves a new queue-fallback mode.
*/
@@ -277,20 +247,6 @@ export function SetTrackListColumns(columns: tracklist$0.Column[] | null): $Canc
return $Call.ByID(4226159685, columns);
}
/**
* SetViewVisible shows or hides one primary view.
*
* Two refusals, both about a state the user cannot get out of from the
* UI they would be left with: Settings is never hideable, and the
* launch page is never hideable while it is the launch page (change it
* first). Hiding a view does not make it unreachable -- `navigate`
* still resolves it, which detail views depend on -- it only takes the
* nav item away.
*/
export function SetViewVisible(view: string, visible: boolean): $CancellablePromise<void> {
return $Call.ByID(1751982648, view, visible);
}
/**
* Validate returns errors if there is a breaking issue with the config.
*/
@@ -142,18 +142,6 @@ 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.
*/
+35 -288
View File
@@ -104,51 +104,6 @@ p {
flex: 0 1 320px;
}
/* What the bar gives up when it does not fit is decided by measuring
it (`services/top-bar-fit.ts`, #143). Two rules here are what make
that measurement mean anything.
**Nothing but the search box may shrink.** `scrollWidth` reports a
perfect fit while a child quietly truncates -- #69's trap, one
component over -- and the indicator's label is `text-overflow:
ellipsis`, so it would have absorbed the deficit and hidden it. The
search box is exempt because it shrinks between its 320px basis and
the 200px floor its own stylesheet sets, and a narrower input hides
nothing it was showing. */
.top-bar hgroup,
.top-bar library-filter,
.top-bar job-indicator {
flex-shrink: 0;
}
/* **The wordmark yields its width, not its existence.** It is the
app's top-level heading as well as its brand, and `display: none`
would take a document from one `h1` to none at exactly the widths
where the view's own header is the only thing left saying where you
are. This is `styles/sr-only.css.ts`'s recipe, written out because
that one is a `CSSResult` for shadow roots and this is the light
DOM. */
.top-bar hgroup.yj-collapsed {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* The bar is `justify-content: space-between`, which with four children
spreads them evenly and left back/forward floating in the middle of
nothing. Collecting the free space *after* this one puts the pair
beside the brand, where a browser keeps them, and leaves the
right-hand group exactly as it was. */
.top-bar nav-history {
margin-right: auto;
}
ul {
list-style-type: none;
}
@@ -178,23 +133,6 @@ ul {
.subtitle {
display: none;
}
/* Back/forward is Desktop-band chrome (#6), and 900 is the same
line the sidebar's labels and the subtitle are already given up
at -- below it the shell is narrow enough that the header is
what runs out of room first. Measured at 600, the bottom of the
Compact band: the bar is 611px inside a 600px viewport *before*
this component exists (filed separately), and 695px with it, so
keeping it here would be widening a violation of the promise
that nothing scrolls sideways at a supported size.
Nothing is unreachable as a result, which is the rule that
decides it: Alt+Left / Alt+Right are global and every width has
them, the detail views keep their own back buttons, and the
phone additionally has the platform's gesture. */
.top-bar nav-history {
display: none;
}
}
body div.sidebar {
@@ -211,39 +149,7 @@ body div.sidebar {
padding: 0.25em;
background-color: var(--yj-bg-elevated, #343a40);
display: grid;
/* Three columns whose outer two are the *same* width, which is what
centres the middle one (#23). It was `var(--now-playing-width) 1fr
auto`, so the transport's centre sat at `W/2 + 140px` in the
middle of the space left over, which is not the same thing and
reads as an alignment mistake at every window size.
The outer width is still `--now-playing-width`, so **the metadata
panel's drag handle keeps meaning something**: widening it takes
room from the transport on both sides at once, symmetrically. An
`1fr 1fr` pair would have centred the transport just as well and
silently made that handle a no-op.
**The cap is what stops that being a regression**, and it was
measured as one first. Reserving the full metadata width on both
sides costs the transport twice: at 800px the outer pair wanted
640 of 800, and the seek bar's track went from 257px to 61px
(and to 0 at 200% text) the control you drag, squeezed out to
centre the buttons above it. So the side tracks are the metadata
width *or a quarter of the bar*, whichever is smaller, which
leaves the drag handle meaningful everywhere it has room to be
and hands the difference to the transport where it does not.
`minmax(0, )` on the outer tracks and `min-content` on the middle
decide who yields when even that is not enough: the metadata and
the end group shrink (both truncate; neither loses an action), and
the transport keeps at least its buttons. Without the `min-content`
floor the middle collapses first, because a `1fr` track's minimum
is `auto` only until something else insists. */
--bar-side: min(var(--now-playing-width, 320px), 25%);
grid-template-columns:
minmax(0, var(--bar-side))
minmax(min-content, 1fr)
minmax(0, var(--bar-side));
grid-template-columns: var(--now-playing-width, 320px) 1fr auto;
align-items: center;
contain: layout style;
@@ -271,44 +177,23 @@ body div.sidebar {
text-wrap-mode: nowrap;
overflow: hidden;
/* `& p`, not `p`. **A nested rule that begins with a bare
element selector is silently dropped before Chrome 120**
(relaxed nesting), and the phone this app runs on renders
in Chrome 113 -- so this ellipsis, and the two rules
below, have never applied on the device. Nothing fails;
the text simply overflows there. The `&` form is valid in
both, which is why it is used for every element selector
in this file's nested blocks. */
& p {
p {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
& now-playing {
now-playing {
overflow: hidden;
}
& audio-player {
audio-player {
margin: 0.5em 1em;
min-width: 0;
}
/* The right-hand group, and the thing the left column is matched
against. It is one grid cell rather than two columns because the
centring rule above compares *columns*: volume and the queue
button in separate tracks would make the outer pair unequal by
whatever the volume happens to measure. */
.bar-end {
justify-self: end;
display: flex;
align-items: center;
gap: 0.25em;
min-width: 0;
}
#queue-button {
justify-self: end;
background: none;
border: none;
color: inherit;
@@ -346,14 +231,6 @@ body div.sidebar {
display: flex;
overflow: hidden;
contain: layout style;
/* The containing block for the queue panel's overlay mode (plan
018, #24), which spans this box rather than taking width from
the main panel beside it. `contain: layout` already establishes
one; this says so on purpose, so that removing the containment
for a paint reason does not silently reparent the overlay to the
viewport. */
position: relative;
}
.main-panel {
@@ -412,21 +289,10 @@ body div.sidebar {
=================================================================== */
@media (max-width: 599px) {
body {
/* **There is no top-bar row here (#57).** Every one of the five
things that bar held has somewhere else to be below 600px:
`nav-history` is the platform's own gesture (gone from 899
down), the job indicator is `<job-band>` (#62), the search
box is a modal opened from the view's own header
(`search-trigger`), the library filter is Settings ->
Libraries (#148), and the wordmark is below. That is 3.25em
of a 439 CSS px viewport -- the single biggest vertical win
available on the reference device, which is why #57 asks for
the row rather than for a smaller bar. */
grid-template:
"jobs-band" auto
"top-bar" 3.25em
"main-panel" 1fr
"bottom-bar" auto
"progress-line" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
@@ -443,55 +309,40 @@ body div.sidebar {
grid-area: bottom-nav;
}
/* The bar is out of the layout, and out of it the way the *wordmark*
already goes at desktop widths: visually hidden rather than
`display: none`, because that `h1` is the document's top-level
heading and this app would otherwise have none on the pages whose
own header is empty by design (`page-header` renders no `h1` when
`heading` is '', and Settings has no `page-header` at all).
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
Its four *controls* are `display: none` below, which is what
keeps them out of the tab order -- a visually-hidden container is
still focusable, and tabbing into a search box nobody can see is
worse than not having one.
This is `styles/sr-only.css.ts`'s recipe again, written out
because that one is a `CSSResult` for shadow roots and this is
the light DOM. `position: absolute` is also what tells
`services/top-bar-fit.ts` there is no row to fit into. */
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
.top-bar {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
gap: 0;
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
overflow: hidden;
}
.top-bar nav-history,
.top-bar library-filter,
.top-bar search-bar,
.top-bar job-indicator {
display: none;
}
/* `min-width: 0` is load-bearing wherever a box sits between the
viewport and content that must shrink. A grid item's implicit
minimum is `auto` -- its content -- so one child insisting on
580px makes the *body* 580px wide inside a 360px viewport, and
`overflow-x: hidden` then hides the right-hand third of the app
rather than fitting it. */
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
@@ -502,18 +353,17 @@ body div.sidebar {
expression of the same fact is a second thing to keep in step.
The view carries its own queue button, because this is where
that one lived. */
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar,
body:has(#main-content[data-active-view="now-playing"]) player-progress-line {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
/* The phone keeps the two-part bar it had: metadata, then the
transport and the queue button. There is no third column to
balance because the centring the desktop does is a luxury of
having room at 360px the metadata needs all of the space the
controls do not. */
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
@@ -522,107 +372,4 @@ body div.sidebar {
.bottom-bar audio-player {
margin: 0.25em;
}
/* Volume stands down here whatever the setting says, because this
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.
**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
600px anyway, but an in-flow grid child with no named area is
auto-placed into a row of the shell -- the same trap the skip link is
absolutely positioned to avoid. `player-progress-line` (#58) is the
same element in the same position for the same reason: below 600px it
has a named row, and above it there is no border for it to sit on --
the desktop bar carries a real, interactive seek bar. */
body job-band,
body player-progress-line {
display: none;
}
/* #62. The job indicator stands down on the phone, and its work is
shown in the notification band instead (notification-host).
Three reasons, and the first is the report: its popover is anchored
to the top bar, which is 3.25em here on a viewport 439 CSS px tall,
and it was reported as unreadable behind other UI. The second is
that a popover is a disclosure, and background work is the one thing
a phone should not make you disclose. The third is #57, which
deletes this bar entirely and is blocked on the indicator having
somewhere else to live -- this is that somewhere.
#57 has since done exactly that, so the indicator's own rule now
lives with the other three in the phone block above, where the bar
goes out of the layout in one statement rather than four. What stays
here is the band, and the argument for it. */
@media (max-width: 599px) {
/* The indicator's rows appear here, in the grid row above the content.
In flow rather than over it: a fixed band reads fine in a
screenshot and is unusable, because at 424x439 a compact panel
is ~200px of a 439px screen and it *covers* what is under it.
Measured, not assumed -- four e2e specs failed on that version,
two phone-shell journeys and the header's action menu, because
the panel was intercepting the taps. */
body job-band {
display: block;
grid-area: jobs-band;
background-color: var(--yj-bg-elevated, #343a40);
}
}
/* #58. How far through the song we are, in its own grid row between
the two bars -- so the line is *on* the border rather than inside
either of them, and in flow rather than over it. The row is `auto`
and the element renders nothing while no track is loaded, so it costs
no height at all until there is something to say.
**This block is below the `display: none` above and has to be**, for
the reason the band's rule is: a media query adds no specificity, so
`body player-progress-line { display: block }` written before that
rule loses to it at equal specificity and the line never appears at
any width. Nothing fails; it is simply not there. */
@media (max-width: 599px) {
body player-progress-line {
display: block;
grid-area: progress-line;
}
}
+7 -59
View File
@@ -14,37 +14,16 @@
user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a>
<!-- Below 600px this bar is not in the layout at all (#57): index.css
takes its grid row away and leaves the element visually hidden,
carrying nothing but the `h1` below. Every control in it has
somewhere else to be there -- `nav-history` is the platform's
own back gesture, `job-indicator` is `<job-band>`, `search-bar`
is `<search-dialog>` opened from the view's own header, and
`library-filter` is Settings -> Libraries (#148). -->
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
<!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p>
</hgroup>
<!-- Global back/forward (#6). Before the library filter so the
two navigation controls in this bar are adjacent, and after
the brand because that is where a window's chrome ends and
the app's begins. Hidden below 600px by index.css: the
phone has a system back, and this bar has no room. -->
<nav-history></nav-history>
<library-filter></library-filter>
<search-bar></search-bar>
<job-indicator></job-indicator>
</header>
<!-- The phone's view of background work (#62): below 600px the
indicator above stands down and its rows appear here instead,
in the layout rather than over it. `display: none` above that
width in index.css, which is also what keeps it out of the
desktop grid -- an in-flow child with no named area is
auto-placed into one of the shell's rows, which is the trap the
skip link is absolutely positioned to avoid. -->
<job-band></job-band>
<div class="sidebar">
<app-sidebar></app-sidebar>
</div>
@@ -55,42 +34,17 @@
</main>
<queue-panel id="queue-panel"></queue-panel>
</div>
<!-- Three columns, and the outer two are the same width, which is
what makes the middle one *centred* rather than merely in the
middle of what is left (#23). The transport used to sit in a
`320px 1fr auto` grid, so its centre was ~140px right of the
window's.
That is also why the volume moved out of `audio-player` and
into the bar (#42): the transport column has to contain the
transport and nothing else, or "centred" means centred with a
slider bolted to one side. It joins the queue button in
`.bar-end`, whose width is what the left column is matched
against. -->
<footer class="bottom-bar">
<now-playing></now-playing>
<audio-player></audio-player>
<div class="bar-end">
<volume-control></volume-control>
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
id="queue-button">
<!-- ICON_QUEUE in src/utils/icon-language.ts, written out
because this file has no module scope. It was `list`,
which is the Playlists destination's icon. -->
<wa-icon name="bars-staggered"></wa-icon>
</button>
</div>
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
id="queue-button">
<!-- ICON_QUEUE in src/utils/icon-language.ts, written out
because this file has no module scope. It was `list`,
which is the Playlists destination's icon. -->
<wa-icon name="bars-staggered"></wa-icon>
</button>
</footer>
<!-- How far through the song we are, on the border between the two
bars (#58). The shell's element rather than either bar's:
they are separate components stacked in this grid, so a line
on the border between them is a row of it, and neither one has
to reach into the other's box for two pixels. It renders
nothing above 600px and nothing with no track, is `aria-hidden`
(Now Playing's seek bar is what announces the position) and
takes no pointer events at all -- a thin line that sometimes
seeks is worse than one that never does. -->
<player-progress-line></player-progress-line>
<!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason
notification-host is: it is the only way to move around the
@@ -101,12 +55,6 @@
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
<!-- The phone's search surface (#57). A singleton here for the
reason shortcuts-overlay is one: one instance, one document
listener, and no `data-testid="search-input"` resolving to two
elements. It renders nothing while shut, so the header's box
is still the only one on a desktop. -->
<search-dialog></search-dialog>
</body>
</html>
+31 -228
View File
@@ -18,25 +18,12 @@
// track-list — index.html renders one, so it is the first paint.
// ---------------------------------------------------------------------------
import '@components/audio-player/audio-player.ts';
// In the bar rather than inside `audio-player` since #42, so the shell
// is what has to register it.
import '@components/audio-player/volume-control/volume-control.ts';
// The phone's progress line (#58), on the border between the mini
// player and the tab bar. In the shell for the same reason the volume
// is, and eager because it is part of the bottom bar's first paint.
import '@components/audio-player/progress-line/progress-line.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts';
// The phone's search surface (#57). Eager, because below 600px it is
// the *only* way to search and a modal that has to fetch a chunk before
// it can take a keystroke is late by exactly the interval it exists to
// remove. It renders nothing until asked.
import '@components/search-dialog/search-dialog.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
@@ -47,19 +34,12 @@ import '@components/confirm-dialog/confirm-dialog.ts';
// not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.ts';
// The phone's half of the same thing (#62). Eager because it is part
// of the shell's first paint below 600px, and because a band that has
// to fetch a chunk before it can say the app is busy is late by
// exactly the interval it exists to explain.
import '@components/jobs/job-band.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
import { historyStore } from '@store/history-store';
import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
@@ -70,9 +50,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installTouchGestures } from '@utils/touch-gestures';
import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit';
import { installLongPressContextMenu } from '@utils/long-press';
import {
hasTrackPayload,
getDragPayload,
@@ -87,21 +65,10 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// Every touch gesture in the app, installed once rather than per
// component (plan 019). Harmless on a desktop: it acts on
// `pointerType === 'touch'` only, per event, so a mouse on a
// touchscreen keeps click-selects / double-click-plays on the very
// same row. An unclaimed long press still becomes a `contextmenu`,
// which is what leaves all fourteen menus untouched by #63.
installTouchGestures();
// The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and
// its children are five separate elements; the shell is the only thing
// that can see all five at once.
const topBar = document.querySelector<HTMLElement>('header.top-bar');
if (topBar) installTopBarFit(topBar);
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// ---------------------------------------------------------------------------
// View caching navigation system
@@ -131,6 +98,7 @@ const VIEW_TAGS: Record<string, string> = {
explore: 'explore-view',
autotag: 'autotag-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
settings: 'config-page',
};
@@ -148,6 +116,7 @@ const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
explore: () => import('@components/explore-view/explore-view.ts'),
autotag: () => import('@components/autotag-view/autotag-view.ts'),
downloads: () => import('@components/downloads-view/downloads-view.ts'),
jobs: () => import('@components/jobs/jobs-view.ts'),
settings: () => import('@components/config-page/config-page.ts'),
};
@@ -245,132 +214,49 @@ document.addEventListener('navigate', (e: Event) => {
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** The navigation an entry stands for, and where it sits in this
* session's list. `undefined` on the entry that predates the app's own
* routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number };
/** The navigation an entry stands for. `undefined` on the entry that
* predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
// Back and forward are the *same* `popstate` event -- it carries no
// direction, and the History API exposes neither the current position
// nor a reachable depth. So the shell numbers its own entries: the
// index of the one showing, and the highest index reachable from here.
//
// The counter this replaced (`pushedEntries`, one number decremented on
// every pop) could not express forward at all: going forward looked
// exactly like going back again, so two presses of a Forward button
// would have claimed the app was at its root.
/** Index of the entry now showing. 0 is the launch entry, which is
* replaced rather than pushed -- so this is also how deep back can go
* while staying inside the app. */
let currentIndex = 0;
/** The highest index reachable from here: how far forward is left.
* A new navigation truncates the forward list, exactly as a browser
* does, so this is reset to the entry being pushed. */
let maxIndex = 0;
function publishDepth(): void {
historyStore.setDepth(currentIndex > 0, currentIndex < maxIndex);
}
/** How many entries this session has pushed beyond that first one --
* i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` and `_replace` are bookkeeping, not destination: keeping
// either in the entry would make a replayed navigation claim to be
// one.
const { _isBack: _ignored, _replace: replace, ...nav } = detail;
// Still launching: the configured landing page is not a navigation
// *away* from the eager one, it is the same arrival arriving late
// (#142). Pushing it left the app one entry deep before the user
// had touched anything, so the first back press replayed home over
// home -- invisible on desktop until #6 drew a Back button, and on
// Android the press that should have exited the app instead did
// nothing, because `canGoBack()` was true.
//
// Guarded on being at the root rather than on a flag, because
// `GetDefaultPage()` is a backend call and the user can navigate
// while it is in flight: past index 0 this is an ordinary
// navigation, or a slow answer would overwrite an entry they made.
if (historyStarted && replace && currentIndex === 0) {
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
maxIndex = 0;
publishDepth();
return;
}
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
currentIndex += 1;
// Navigating from the middle of the list drops what was ahead
// of it -- there is no longer a forward to go to.
maxIndex = currentIndex;
history.pushState({ yjNav: nav, yjIdx: currentIndex }, '');
history.pushState(state, '');
pushedEntries += 1;
} else {
currentIndex = 0;
maxIndex = 0;
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
history.replaceState(state, '');
historyStarted = true;
}
publishDepth();
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const state = e.state as NavState | null;
const nav = state?.yjNav;
const nav = (e.state as NavState | null)?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
// The entry says where it is, so this works in both directions and
// across a jump of more than one -- which a long-press on a
// browser's back button, and `history.go(-n)`, both produce.
// The fallback is for an entry pushed before this numbering
// existed; it can only be wrong about a control's disabled state,
// never about which view is restored.
currentIndex = state?.yjIdx ?? Math.max(0, currentIndex - 1);
publishDepth();
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
/**
* The queue, while it is a screen (#55).
*
* It is *not* in `VIEW_TAGS` and *not* in `DETAIL_LOADERS`: there is
* nothing to mount, because the panel is already in the document and,
* as an overlay, already occupies `.main-panel`'s rect exactly. What a
* navigation adds is the two things that make a screen a screen a
* history entry, so the platform's back gesture answers it, and a
* destination to leave, so navigating anywhere else takes it away.
*
* Keeping it out of both tables is what keeps its context menu working
* on the reference device: `.main-panel > *` is paint-contained and a
* `wa-popup` falls back to `position: fixed` on Chrome 113, which
* escapes overflow but not containment (#60). The panel stays in
* `.content-area`, which is not paint-contained, exactly as it is
* today.
*/
const QUEUE_VIEW = 'queue';
/** Close a queue that is being navigated away from. A *column* is not
* a place, so it survives a navigation the way the sidebar does. */
function dismissQueueScreen(): void {
const panel = queuePanelElement();
if (panel?.hasAttribute('overlay')) panel.removeAttribute('open');
}
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
@@ -382,25 +268,6 @@ async function handleNavigate(
if (!detail._isBack) recordNavigation(detail);
if (view === QUEUE_VIEW) {
// The shell says where the user is; `false` because the queue is
// not a primary view, so nothing in either nav lights while it
// is up -- the same rule a detail view gets, and the reason the
// tab the queue was opened from stays lit.
activeViewStore.setView(view, false);
queuePanelElement()?.setAttribute('open', '');
// Deliberately not `searchStore.setCurrentView` and not
// `dataset.activeView`: both describe what is *in the main
// panel*, and the queue covers that panel without replacing it.
// Overwriting either would disable the search box belonging to
// the page underneath and make every `data-active-view`
// selector in the suite disagree with the element it names.
return;
}
dismissQueueScreen();
// Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e
@@ -412,20 +279,6 @@ async function handleNavigate(
// attribute keeps e2e selectors semantic instead of structural.
mainContent.dataset.activeView = view;
// And publishing it as a *value* is what the nav components read.
// They used to learn the active view from the `navigate` event,
// which only the outbound path dispatches -- so a back-navigation
// left both of them highlighting the view it had just left (#72).
// Re-dispatching `navigate` here is not the fix: this file is a
// document listener for it, so that is an infinite loop, and
// "please go to X" is not the statement being made.
//
// `view in VIEW_TAGS` is the primary/detail split, and it is passed
// rather than re-derived because this table is where it is written
// down. A detail view therefore leaves the tab it was opened from
// lit, which is what the report asks for.
activeViewStore.setView(view, view in VIEW_TAGS);
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Remove any active detail view first
@@ -644,18 +497,7 @@ function schedule(fn: () => void): void {
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
if (currentIndex > 0) history.back();
});
// Forward: the other half of #6. The stack was always global -- every
// navigation is an entry and `popstate` restores any of them -- so what
// was missing is a way to ask for one, and a truthful answer to whether
// there is one to ask for. It is guarded for the same reason back is:
// `history.forward()` at the end of the list is silent, so a button
// that offers it when there is nothing there is a button that does
// nothing.
document.addEventListener('navigate-forward', () => {
if (currentIndex < maxIndex) history.forward();
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
@@ -665,17 +507,14 @@ GetDefaultPage()
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
// Part of launching, not a navigation away from the eager
// 'home' above: it replaces that entry rather than
// stacking on it (#142).
detail: { view: view || 'home', _replace: true },
detail: { view: view || 'home' },
}));
})
.catch(() => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'home', _replace: true },
detail: { view: 'home' },
}));
});
@@ -685,17 +524,13 @@ const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
if (queueButton && queuePanel) {
queueButton.addEventListener('click', () => {
if (queuePanel.hasAttribute('open')) {
// Closing goes through the panel either way; where the queue
// is a screen the observer below is what unwinds its history
// entry, so this button, Escape, the scrim and the close
// button all take the same route out.
const isOpen = queuePanel.hasAttribute('open');
if (isOpen) {
queuePanel.removeAttribute('open');
return;
} else {
queuePanel.setAttribute('open', '');
}
openQueue();
});
// The button says whether the panel is open, and it learns that
@@ -713,39 +548,7 @@ if (queueButton && queuePanel) {
);
};
/**
* Keep the back stack honest about a queue that closed itself.
*
* Where the queue is a screen its `open` attribute and the current
* history entry are two statements of one fact, and the panel can
* change its half on its own -- Escape, the scrim, the close button,
* and anything added later. Reconciling here rather than at each of
* those is the same reason this observer already exists for
* `aria-expanded`: the attribute is the one fact, and a state kept
* beside a click is right until something else changes it.
*
* Without this the entry is orphaned and the *next* back press is
* the one that closes the queue -- a press that appears to do
* nothing, which is the defect this issue is about, moved one press
* later.
*
* `history.back()` rather than a stack of our own, for the reason
* `navigate-back` does: two stacks is how a component's own way out
* and the phone's gesture come to disagree about what one press
* means.
*/
const reconcileQueueHistory = () => {
if (queuePanel.hasAttribute('open')) return;
const state = history.state as NavState | null;
if (state?.yjNav?.view === QUEUE_VIEW) history.back();
};
new MutationObserver(() => {
reflectQueueState();
reconcileQueueHistory();
}).observe(queuePanel, {
new MutationObserver(reflectQueueState).observe(queuePanel, {
attributes: true,
attributeFilter: ['open'],
});
+79 -5
View File
@@ -23,23 +23,97 @@
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) problems.push({ file, line, tag });
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
}
}
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env node
/**
* Fail on a nested rule whose selector starts with an element name.
*
* See `css-nesting.mjs` for what the phone does with one. No tier here
* can see it: the component tier, the e2e tier and `make ui-visual` all
* run a current Chromium, where the rule applies normally, so the only
* report is a screenshot of the device which is how the bottom bar's
* title came to have never truncated there.
*
* It covers `index.css` and the `css` literals in the components alike,
* because a shadow-root stylesheet is parsed by the same engine.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { findBareNestedRules } from './css-nesting.mjs';
const problems = [];
// Every stylesheet, not `index.css` by name: the hook that runs this
// fires on `frontend/**/*.{ts,css}`, so naming one file promises a
// coverage the sweep does not deliver -- a second stylesheet would be
// silently unswept while the hook still went green over it. There is
// only `index.css` today, which is exactly when this is free to fix.
const stylesheets = globSync('*.css', { cwd: process.cwd() });
if (stylesheets.length === 0) {
console.error('css-nesting-check: no stylesheet matched *.css');
process.exit(1);
}
for (const file of stylesheets) {
for (const { line, selector } of findBareNestedRules(
readFileSync(file, 'utf8'),
)) {
problems.push({ file, line, selector });
}
}
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
// A sweep over an empty glob passes, and this one is expected to find
// nothing, so "it found nothing" has to mean it looked.
if (sources.length === 0) {
console.error('css-nesting-check: no sources matched src/**/*.ts');
process.exit(1);
}
for (const file of sources) {
const src = readFileSync(file, 'utf8');
for (const literal of taggedLiterals(src, ['css'])) {
for (const { line, selector } of findBareNestedRules(literal.body)) {
problems.push({ file, line: literal.line + line - 1, selector });
}
}
}
if (problems.length > 0) {
for (const p of problems) {
console.error(
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
'with an element name — write it as "& ' +
`${p.selector.split('\n')[0]}"`,
);
}
console.error(
`\ncss-nesting-check: ${problems.length} problem(s). ` +
'Chrome 113 (the device) drops a nested rule that does not start ' +
'with a symbol; the leading & is valid in both syntaxes.',
);
process.exit(1);
}
console.log(
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
);
-103
View File
@@ -1,103 +0,0 @@
/**
* Finding the `css` tagged templates in a TypeScript source.
*
* Two checks read them the unterminated-comment one and the nesting
* one and a second scanner would be a second thing to keep in step
* with how a template literal actually ends.
*/
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
export function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/**
* Strip `${...}` substitutions, which may legitimately contain anything.
*
* Newlines inside them are kept, so a line number taken from the
* stripped text still names the right line of the file it came from.
*/
export function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
else if (text[i] === '\n') out += '\n';
}
return out;
}
/** The 1-based line number of `index` in `src`. */
export function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
/**
* Every tagged template literal in `src` whose tag is in `tags`.
*
* `body` has its substitutions stripped and `line` is the line its
* opening backtick sits on, so `line + (n - 1)` is the file line of the
* body's own line `n`.
*/
export function taggedLiterals(src, tags) {
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
const found = [];
let match;
while ((match = pattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
found.push({
tag: match[2],
body: stripSubstitutions(src.slice(open + 1, close)),
line: lineOf(src, open),
});
}
return found;
}
-119
View File
@@ -1,119 +0,0 @@
/**
* A nested rule whose selector starts with an element name is silently
* dropped on the phone.
*
* The device renders in Chrome 113, which predates relaxed CSS nesting
* (Chrome 120): before that a nested selector had to start with
* something that could not be read as the beginning of a declaration,
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
* would notice the inner rule simply does not exist, on the phone and
* only on the phone. Three were live in `index.css`, one of them the
* `text-overflow: ellipsis` on the bottom bar's title, which had
* therefore never truncated on the device.
*
* `& audio-player` is valid in both syntaxes, so no nested rule here
* has any reason to omit it.
*
* Two things the detection has to get right:
*
* - **A rule directly inside an at-rule is not nested.**
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
* rule and is fine and it is the majority of the matches a regex
* over the file would produce. What decides it is whether a *style*
* rule is somewhere above, not what the immediate parent is: inside
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
* nested, at-rule in between or not.
* - **A declaration is not a rule.** `background: url(…)` and any
* string or comment can hold a brace, so this tracks them rather than
* matching lines.
*/
/** Does this selector start with an identifier, rather than a symbol? */
function startsWithIdent(selector) {
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
}
/**
* Every nested style rule in `css` whose selector starts with an
* element name, as `{ line, selector }` with a 1-based line.
*/
export function findBareNestedRules(css) {
const found = [];
/** The blocks we are inside, innermost last: 'style' or 'at'. */
const stack = [];
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
let prelude = '';
let preludeLine = 1;
let line = 1;
const startPrelude = () => {
prelude = '';
preludeLine = line;
};
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (c === '\n') {
line++;
if (prelude.trim() === '') preludeLine = line;
prelude += c;
continue;
}
if (c === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2);
const comment = css.slice(i, end === -1 ? css.length : end + 2);
line += (comment.match(/\n/g) ?? []).length;
i += comment.length - 1;
if (prelude.trim() === '') preludeLine = line;
continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < css.length && css[j] !== c) {
if (css[j] === '\\') j++;
j++;
}
prelude += css.slice(i, j + 1);
i = j;
continue;
}
if (c === '{') {
const selector = prelude.trim();
const kind = selector.startsWith('@') ? 'at' : 'style';
if (
kind === 'style' &&
stack.includes('style') &&
startsWithIdent(selector)
) {
found.push({ line: preludeLine, selector });
}
stack.push(kind);
startPrelude();
continue;
}
if (c === '}') {
stack.pop();
startPrelude();
continue;
}
if (c === ';') {
startPrelude();
continue;
}
prelude += c;
}
return found;
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>

Before

Width:  |  Height:  |  Size: 532 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm168 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm224-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>

Before

Width:  |  Height:  |  Size: 443 B

@@ -14,7 +14,6 @@ import {
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
@customElement('artist-details')
export class ArtistDetails extends LitElement {
@@ -41,7 +40,7 @@ export class ArtistDetails extends LitElement {
/** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null = null;
static override styles = [designTokens, backButton, css`
static override styles = [designTokens, css`
:host {
display: flex;
flex-direction: column;
@@ -67,6 +66,31 @@ export class ArtistDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -26,15 +26,14 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dict, list } from '@utils/binding';
@@ -132,17 +131,17 @@ export class ArtistsView
private contextMenuArtistId: number | null = null;
@query('#context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup!: MenuSurface;
private playlistSubmenuPopup!: WaPopup;
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| MenuTarget
| WaPopup
| undefined {
return this.playlistSubmenuPopup;
}
@@ -1337,8 +1336,11 @@ export class ArtistsView
private renderContextMenu() {
return html`
<menu-surface
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1432,12 +1434,13 @@ export class ArtistsView
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<menu-surface
<wa-popup
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1465,7 +1468,7 @@ export class ArtistsView
</div>
`
: nothing}
</menu-surface>
</wa-popup>
`;
}
@@ -3,6 +3,7 @@ import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import './controls/player-controls';
import './seekbar/seek-bar';
import './volume-control/volume-control';
import '../notifications/inline-notice';
import { PlayerRegion } from '@store/player-store';
import { designTokens } from '../../styles/tokens.css';
@@ -29,7 +30,6 @@ export class AudioPlayer extends LitElement {
.player-main {
flex: 1;
min-width: 0;
}
/* The phone transport (plan 016 B2): the buttons, and nothing
@@ -37,17 +37,14 @@ export class AudioPlayer extends LitElement {
viewport, not by the host, so this is the component saying what
it drops at phone width rather than the shell reaching in.
Volume goes because the hardware keys own it on a phone --
Android routes them to the media stream, which is also why
mediacontrols' Android handler implements no volume callback.
The seek bar goes because a 4px-tall target dragged with a thumb
is not a seek control; seeking belongs to the full-screen
now-playing view.
Volume used to go from here too, and now goes from index.css
instead: #42 moved the control out of this component and into
the bar, so the shell is what can hide it. The reason is
unchanged -- the hardware keys own volume on a phone, which is
also why mediacontrols' Android handler implements no volume
callback. */
now-playing view, which is the next phase. */
@media (max-width: 599px) {
volume-control,
seek-bar {
display: none;
}
@@ -68,6 +65,7 @@ export class AudioPlayer extends LitElement {
<seek-bar></seek-bar>
</div>
</div>
<volume-control></volume-control>
</div>
`;
}
@@ -1,77 +1,22 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { LitElement, html, css } from 'lit';
import { customElement, 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;
@@ -92,7 +37,6 @@ export class PlayerControls extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
this.media?.removeEventListener('change', this.onMedia);
}
static override styles = [designTokens, css`
@@ -114,99 +58,6 @@ 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);
}
@@ -253,107 +104,46 @@ export class PlayerControls extends LitElement {
queueStore.cycleRepeat();
};
/** 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>
`;
}
override render() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
/** Repeat, whose label spells the mode out because one icon covers
* three states. */
private renderRepeat() {
const shuffleClass = this.shuffleMode ? 'active' : '';
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">
${this.slim ? nothing : this.renderShuffle()}
${this.renderPrimary()}
${this.slim ? nothing : this.renderRepeat()}
<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>
</div>
`;
}
@@ -1,204 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* How far through the song we are, on the border between the mini
* player and the tab bar (#58).
*
* The phone's bottom bar carries three controls and no seek bar plan
* 016 B2 took it out, because 4px of height is not a thumb target and the
* full-screen `now-playing-view` is where seeking belongs. What went
* with it is the one thing a mini player is expected to say without
* being opened: how far through the song it is. This is that, and
* only that.
*
* Four things about it are load-bearing.
*
* **It is the shell's element, not either bar's.** The mini player and
* `<bottom-nav>` are separate components stacked in the shell's grid,
* so a line on the border between them is a row of the grid either
* one drawing it means reaching into the other's box for two pixels.
*
* **It never counts.** The position is pushed at 1 Hz by the backend
* (`PlaybackPositionChanged`), and the interval here interpolates
* *between* those reports and is stopped and restarted by every one of
* them the seek bar's rule, for the reason the seek bar has it: a
* local clock drifted 30 s away from the backend across four keyboard
* seeks. The `trackChangeId` and `seq` guards come along for the same
* reason: the store is a singleton, so a report about the previous
* track must not be adopted, and the same second reported twice still
* has to reset the interpolation.
*
* **It is not a control and cannot become one.** `aria-hidden` on the
* host and `pointer-events: none` throughout: the real progress is
* announced by the seek bar on Now Playing, and a 2px strip on the top
* edge of the tab bar that sometimes seeks is worse than one that
* never does. It is also where a thumb aiming at a tab lands.
*
* **It renders nothing above 600px**, from `matchMedia` rather than a
* media query, because that decides whether the element *exists* and
* with it whether a 1 Hz interval runs for the life of every desktop
* session about a line nobody can see. `job-band`, `search-trigger`
* and `player-controls` are the same pattern for the same reason.
*/
/**
* The reporting cadence, matched. This is not the clock: it exists
* only so the line moves in the second between two reports, and its
* error is discarded by the next one rather than carried.
*/
const InterpolationIntervalMillis = 1000;
@customElement('player-progress-line')
export class PlayerProgressLine extends LitElement {
private player = new PlayerController(this);
/** Phone width. See the class comment: existence, not paint. */
@state() private phone = false;
/** Seconds into the track, from the last report plus interpolation. */
@state() private elapsed = 0;
private previousTrackChangeId = -1;
/** The sequence number of the last backend report applied. */
private previousPositionSeq = -1;
private timerID = -1;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
static override styles = [
designTokens,
css`
:host {
display: block;
/* Not a target, at any depth. */
pointer-events: none;
}
.track {
height: 2px;
background-color: var(--yj-bg-surface, #212529);
}
.fill {
height: 100%;
background-color: var(--yj-accent, #ffd43b);
/* scaleX off a full-width box rather than a width in
percent, so the moving thing is a transform and the
line costs no layout once a second. */
transform-origin: left center;
}
`,
];
private get trackLength(): number {
return this.player.currentTrack?.trackLength ?? 0;
}
override connectedCallback(): void {
super.connectedCallback();
// Decorative in full: the seek bar on Now Playing is what
// announces the position, and this says the same thing without
// a name, a value or a way to act on it.
this.setAttribute('aria-hidden', 'true');
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.stopInterpolating();
this.media?.removeEventListener('change', this.onMedia);
}
override updated(): void {
// A track change resets the line, and `trackChangeId` is what
// reveals one when the same file plays twice in a row.
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
if (currentChangeId !== this.previousTrackChangeId) {
this.previousTrackChangeId = currentChangeId;
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
this.stopInterpolating();
}
// The backend's own position wins over anything counted here,
// and a report for a track that is no longer loaded is stale by
// definition.
const position = this.player.position;
if (
position &&
position.trackChangeId === currentChangeId &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.elapsed = position.positionSeconds;
this.stopInterpolating();
}
// One owner for the interval, as in `seek-bar`: everything that
// wants it started or stopped says so by changing state that
// brings us back here.
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
this.startInterpolating();
} else {
this.stopInterpolating();
}
}
private stopInterpolating(): void {
if (this.timerID !== -1) {
clearInterval(this.timerID);
this.timerID = -1;
}
}
private startInterpolating(): void {
if (this.timerID !== -1) {
return;
}
this.timerID = window.setInterval(() => {
if (this.elapsed < this.trackLength) {
this.elapsed += 1;
}
}, InterpolationIntervalMillis);
}
override render() {
// Nothing playing is nothing to say, and the grid row is `auto`
// so an empty render costs no height at all -- `job-band`'s
// rule one row down.
if (!this.phone || this.player.currentTrack === null) return nothing;
const length = this.trackLength;
const fraction =
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
return html`
<div class="track" data-testid="progress-line">
<div class="fill" style="transform: scaleX(${fraction})"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'player-progress-line': PlayerProgressLine;
}
}
@@ -22,29 +22,23 @@ export class SeekBar extends LitElement {
@state()
private seekValue: number = 0;
/**
* Whether the user is dragging the thumb right now.
*
* It is `@state` rather than a plain field because `updated()` owns
* the interval and only reactive state brings `updated()` round. A
* bare `stopProgress()` in the input handler mutated nothing, so
* nothing re-rendered, so the tail of `updated()` that restarts the
* interval never ran and the only things that could restart it
* were a `change` event or the next backend report. Any `input`
* without a committed `change` therefore froze the interpolation:
* a drag cancelled outside the element, a pointer taken by a scroll,
* or a touch on the track treated as a scrub, which on a phone are
* ordinary gestures. While playing, the 1 Hz report papered over it
* within a second; with reports not arriving it was permanent.
*/
@state()
private dragging: boolean = false;
/** Whether the right-hand clock shows time remaining or total. */
@state()
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -68,57 +62,6 @@ export class SeekBar extends LitElement {
background: var(--yj-bg-base, black);
}
/* The phone's seek bar, and this block is last on purpose.
A media query adds no specificity, so this lived above the plain
"wa-slider" rule and lost to it at every width: the 12px track it
asks for had never once applied, and the bar measured 261x6 on
the device while the source said 12. That is index.css's rule
("the phone section is last on purpose") met inside a component's
own stylesheet, and nothing renders differently in any tier here
to say so.
The bottom bar's seek bar is display:none below this width (016
B2 phase 1), so the only instance a viewport media query can
reach is the full-screen now-playing view's -- which is exactly
the one a thumb uses. The desktop bar keeps its 6px, where a
mouse is precise and the thickness is right.
The painted track and the thing you can hit are allowed to
differ, and a slider is the clearest case where they should: 12px
is a progress bar you can see, and 44px is the app's touch floor
(#56). A 44px-*thick* bar would be wrong-looking and would cost
the album art the vertical space #51 spent an issue recovering.
Two things about how the target is built.
The padding goes on ::part(slider) rather than on the host,
because that inner div is what carries the gesture -- it has the
listener and the touch-action: none, and it is exactly the host's
size, so padding the host would grow a box that does not take the
press.
The padding is asymmetric and the margins cancel it, so the row
does not grow by the difference. Both halves are measured: the
seek row is 19px (its clocks, not the track, decide that) and the
play button's top edge is 8px below it, so the target takes the
space *above*, where .art is a non-interactive div. Growing the
row instead cost the art 25px of 143. Verified on the device at
424x439: hit area 44px, painted track 12px, row still 19px, art
still 143px, 8px of clearance left under the play button, a press
26px above the track seeks, and a hit test on the play button's
top edge still reaches the play button. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
wa-slider::part(slider) {
padding-block: 28px 4px;
margin-block: -28px -4px;
}
}
#seek-bar-container {
display: flex;
justify-content: space-between;
@@ -190,7 +133,6 @@ export class SeekBar extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.stopProgress();
this.endDrag();
}
override updated() {
@@ -212,33 +154,18 @@ export class SeekBar extends LitElement {
// A report for a track that is no longer loaded is stale by
// definition: the change id is the only thing that distinguishes
// it, since the same file can play twice in a row.
//
// A report arriving mid-drag is deliberately *not* applied: the
// thumb belongs to the finger on it, and adopting a report once a
// second pulls it back out from under them. The seq is left
// unrecorded too, so the first report after the drag still counts
// as fresh.
const position = this.player.position;
const forThisTrack =
position !== null && position.trackChangeId === currentChangeId;
if (
position &&
forThisTrack &&
!this.dragging &&
position.seq !== this.previousPositionSeq
) {
if (position && forThisTrack && position.seq !== this.previousPositionSeq) {
this.previousPositionSeq = position.seq;
this.seekValue = position.positionSeconds;
this.stopProgress();
}
// One owner for the interval, and this is it. Every other place
// that wants it started or stopped says so by changing state that
// brings us back here, so the timer cannot be left running by a
// path that forgot to stop it or stopped by a path that forgot to
// start it again.
if (this.isPlaying && this.hasTrack && !this.dragging) {
// Start/stop progress interval based on playback state
if (this.isPlaying && this.hasTrack) {
this.startProgress();
} else {
this.stopProgress();
@@ -283,48 +210,18 @@ export class SeekBar extends LitElement {
private handleChange(e: Event) {
const newSeekVal = (e.target as WaSlider).value;
this.endDrag();
this.setSeekValue(newSeekVal);
this.player.seek(newSeekVal);
}
/**
* The user is moving the thumb.
*
* This only records that fact; `updated()` decides what it means for
* the interval. `seekValue` follows the slider so the clocks track
* the thumb during the drag rather than jumping when it is released.
*/
private handleInput(e: Event) {
this.setSeekValue((e.target as WaSlider).value);
if (this.dragging) {
return;
if (this.isPlaying) {
this.startProgress();
}
this.dragging = true;
// A drag that never commits must not strand the flag, or this fix
// turns a stall of up to one second into a permanent one -- which
// is the failure it exists to remove. `change` is the ordinary
// end; these are the ones that are not, and they are on the
// document because the pointer is routinely released outside the
// element it started in. A drag's listeners belong to the drag,
// so they go on with it and come off with it.
document.addEventListener('pointerup', this.endDrag);
document.addEventListener('pointercancel', this.endDrag);
document.addEventListener('touchend', this.endDrag);
document.addEventListener('touchcancel', this.endDrag);
}
private endDrag = () => {
document.removeEventListener('pointerup', this.endDrag);
document.removeEventListener('pointercancel', this.endDrag);
document.removeEventListener('touchend', this.endDrag);
document.removeEventListener('touchcancel', this.endDrag);
this.dragging = false;
};
// Stops progress while user is dragging the thumb
private handleInput() {
this.stopProgress();
}
private setSeekValue(val: number) {
if (val < 0) val = 0;
@@ -1,10 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { LitElement, html, css } 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';
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
import { PlayerController } from '@store/controllers/player-controller';
import { volumeStyleStore } from '@store/volume-style-store';
import { designTokens } from '../../../styles/tokens.css';
import { waSliderLabel } from '../../../styles/wa-slider-label.css';
@@ -23,32 +22,6 @@ export class VolumeControl extends LitElement {
@state()
private showSlider = false;
/** Whether this is the click-to-open popup rather than a slider. */
@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.
// The store's volume only updates once the backend echoes VolumeChanged
// (which we debounce), so we track intent here for responsive UI and to let
@@ -63,13 +36,6 @@ 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;
@@ -120,31 +86,11 @@ export class VolumeControl extends LitElement {
--thumb-height: 16px;
}
.volume-popup wa-slider::part(track) {
wa-slider::part(track) {
background: var(--yj-text-primary, white);
height: 120px;
}
/* The inline slider (#42). It is the default now, so the width is
a real layout decision rather than a detail: 5em is wide enough
to aim at and narrow enough that the bottom bar's *outer*
columns stay equal without squeezing the transport which is
the arrangement #23 depends on.
flex-shrink: 0 for the reason the top bar's children have it
(#143): a control that quietly gets narrower under pressure
hides the fact that the bar has run out of room. This one stands
down at phone width instead, in index.css, where the shell can
see the viewport. */
.inline-slider {
width: 5em;
flex-shrink: 0;
}
.inline-slider::part(track) {
background: var(--yj-text-primary, white);
}
wa-slider::part(indicator) {
background: var(--yj-accent, yellow);
}
@@ -176,26 +122,8 @@ export class VolumeControl extends LitElement {
// LIFECYCLE
// ===================================================================
override connectedCallback() {
super.connectedCallback();
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();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeStyle?.();
document.removeEventListener('click', this.boundHandleOutsideClick);
clearTimeout(this.volumeDebounceTimer);
}
@@ -226,12 +154,10 @@ export class VolumeControl extends LitElement {
private handleOutsideClick(e: Event) {
const path = e.composedPath();
if (!path.includes(this)) this.closeSlider();
}
private closeSlider() {
this.showSlider = false;
document.removeEventListener('click', this.boundHandleOutsideClick);
if (!path.includes(this)) {
this.showSlider = false;
document.removeEventListener('click', this.boundHandleOutsideClick);
}
}
private handleInput(e: Event) {
@@ -263,67 +189,21 @@ 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:
// there is nothing left to disclose, and a button that opens a
// popup containing the slider already beside it would be a control
// whose only effect is to duplicate its neighbour.
const iconAction = this.popup
? this.toggleSlider
: () => this.player.toggleMute();
const iconLabel = this.popup
? muted
? 'Muted'
: `Volume ${this.currentVolume}%`
: muted
? 'Unmute'
: 'Mute';
return html`
<button
class=${muted ? 'muted' : ''}
title=${muted ? 'Muted — click for volume' : 'Volume'}
aria-label=${iconLabel}
aria-label=${muted ? 'Muted' : `Volume ${this.currentVolume}%`}
data-muted=${muted ? 'true' : 'false'}
@click="${iconAction}"
@click="${this.toggleSlider}"
@wheel="${this.handleWheel}"
>
<wa-icon name=${this.volumeIcon}></wa-icon>
</button>
${!this.popup
? html`
<wa-slider
class="inline-slider ${muted ? 'muted' : ''}"
label="Volume"
min="0"
max="100"
.value="${this.currentVolume}"
@input="${this.handleInput}"
@wheel="${this.handleWheel}"
></wa-slider>
`
: ''}
${this.popup && this.showSlider
${this.showSlider
? html`
<div
class="volume-popup ${muted ? 'muted' : ''}"
@@ -29,7 +29,6 @@ import { nameDialogsIn } from '../../utils/name-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@components/jobs/job-panel';
import { list } from '@utils/binding';
type PendingItem = autotagservice.PendingItem;
@@ -136,20 +135,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
padding: 0.75rem 1rem;
}
/* The header and the apply-job panel share the header row.
A wrapper rather than a third grid row, because the panel
is display:none while nothing is applying and a grid
row would still spend the container's gap on it -- the
idle case, which is nearly always. */
.header-area {
grid-area: header;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.header {
grid-area: header;
display: flex;
align-items: center;
gap: 0.75rem;
@@ -250,20 +237,13 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
/* Collapsible-section toggle used in the Pending header
transparent button that inherits the header's type.
187x**15** before this (#186), which was the smallest
control measured anywhere in the app until the column
arrows were counted. It is transparent and full-width
already, so the floor costs it a height and nothing
else. */
transparent button that inherits the header's type. */
.section-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
min-block-size: 44px;
padding: 0;
background: transparent;
border: 0;
@@ -281,8 +261,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* 32x18, and it has no background until hover -- so the
padding out to a square target is invisible (#186). */
.folders-menu-trigger {
background: transparent;
border: 0;
@@ -290,8 +268,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.4rem;
min-inline-size: 44px;
min-block-size: 44px;
border-radius: 3px;
cursor: pointer;
}
@@ -304,10 +280,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
.folders-refresh-trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
min-inline-size: 44px;
min-block-size: 44px;
}
.folders-refresh-trigger:disabled {
@@ -3234,20 +3207,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// sees a blank full-screen "Loading\u2026".
return html`
<div class="root">
<div class="header-area">
${this.renderHeader()}
<!--
Applying rewrites tags on disk, and until #27 the
only way to stop a run was the Jobs tab or the
header popover. The per-album ring says work is
happening; this is what can stop it, and what has
the log when it goes wrong.
-->
<job-panel
kinds="autotag-apply"
heading="Applying tags"
></job-panel>
</div>
${this.renderHeader()}
${this.renderFolderSidebar()}
${this.renderMain()}
</div>
@@ -7,8 +7,6 @@ import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -116,35 +114,8 @@ export class BottomNav extends LitElement {
}
`];
/**
* Which tab is lit, read from the shell rather than tracked here.
*
* This was a `@state()` field set from the `navigate` event, which
* only the outbound path dispatches -- so backing out of a detail
* view left the highlight wherever it had been (#72). It had no
* equivalent of `app-sidebar`'s `navItems.some(...)` guard either,
* so a detail view set it to a name matching no tab and *nothing*
* was lit; that asymmetry is why one nav looked broken and the
* other looked fine. The store answers both: a detail view leaves
* the tab it was opened from lit, in both components.
*/
private activeCtrl = new ActiveViewController(this);
/**
* The tab bar honours the sidebar's toggles (#25), and the reason is
* inside this component rather than a general rule about phones.
* `PHONE_COLUMN_IDS` is the precedent for "what a phone shows is a
* different question", and it would apply here too -- except that
* "More" opens the *same* `<app-sidebar>`, which filters. An
* unfiltered bar would therefore contradict its own drawer, one tap
* apart, and a destination the user switched off is off wherever it
* is offered.
*
* Which four tabs remains plan 016's committed subset; this only
* removes from it. Hiding all four leaves "More", which is always
* present and reaches everything.
*/
private visibilityCtrl = new ViewVisibilityController(this);
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
@@ -196,9 +167,12 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer);
}
private onGlobalNavigate = () => {
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -228,17 +202,13 @@ export class BottomNav extends LitElement {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS
.filter((tab) => this.visibilityCtrl.visible(tab.id))
.map((tab) => html`
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeCtrl.isActive(tab.id)
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
@@ -85,21 +85,6 @@ export class ConfigField extends LitElement {
gap: 0.5em;
}
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
@@ -110,7 +95,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
min-width: 0;
min-block-size: 44px;
flex: 1;
}
@@ -133,7 +117,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
min-block-size: 44px;
flex: 1;
}
@@ -156,7 +139,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
min-block-size: 44px;
}
button:hover {
@@ -176,12 +158,8 @@ export class ConfigField extends LitElement {
}
input[type='color'] {
/* border-box, or the 2px border makes this 48 and the
assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
width: 2.5em;
height: 2.5em;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
@@ -209,32 +187,12 @@ export class ConfigField extends LitElement {
display: flex;
align-items: center;
justify-content: space-between;
min-block-size: 44px;
}
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch {
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
position: relative;
width: 2.5em;
height: 1.4em;
}
.toggle-switch input {
@@ -244,10 +202,9 @@ export class ConfigField extends LitElement {
}
.toggle-slider {
position: relative;
position: absolute;
cursor: pointer;
inline-size: 2.5em;
block-size: 1.4em;
inset: 0;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
@@ -9,13 +9,7 @@ import {
RemoveLibrary,
GetRemovalImpact,
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import '@components/jobs/job-panel';
import {
GetScanConcurrency,
SetScanConcurrency,
@@ -25,8 +19,6 @@ import {
SetQueueFallback,
GetAllowMeteredCatalogDownload,
SetAllowMeteredCatalogDownload,
GetPopupVolume,
SetPopupVolume,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { notificationStore } from '@store/notification-store';
@@ -35,9 +27,6 @@ import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import { downloadStore } from '@store/download-store';
import { GetAllPlaylists } from '@go/playlist/service.js';
import type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events';
@@ -56,10 +45,6 @@ import {
import './config-field';
import './config-section';
// The view filter's home (#148). The same component the top bar
// carries, placed a second time rather than reimplemented -- two
// definitions of "which library am I browsing" is what this is for.
import '@components/library-filter/library-filter';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
@@ -86,27 +71,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Favorites controller ---
private favCtrl = new FavoritesController(this);
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
/**
* The job snapshot, for the per-library scan status (#27).
*
* Held as state rather than read from the store in `render()` so
* Lit sees the dependency: the store notifies, and a getter read
* inside a template is not a reactive input.
*/
@state() private jobs: Job[] = [];
/**
* Set between pressing a scan button and the job snapshot that
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
* is long enough for a second click to start a second scan.
*/
@state() private startingScan = false;
private unsubscribeJobs: (() => void) | null = null;
// --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this);
@@ -131,8 +95,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@state() private concurrencyMode = 'auto';
@state() private defaultPage = 'home';
@state() private queueFallback = 'favorites';
@state() private popupVolume = false;
@state() private indexStatus: explore.IndexStatus | null = null;
/** Three states, not one: the panel used to say "Loading status"
* for the entire session, because the only thing that ever set
@@ -176,13 +138,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
/* The app's 44px touch floor (#56, #186), stated once for
all 41 buttons this page renders rather than per class.
Height is free here: Settings has no overflow fit, so
the header's "only width is contested" rule does not
bind, and the two classes that need more than a height
say so below. */
min-block-size: 44px;
}
button:disabled {
@@ -242,42 +197,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex-wrap: wrap;
}
/* #148, and the second half of #57.
library-filter is the only control in the app that calls
setSelectedLibrary, and it lived in the top bar -- which
#57 takes out of the layout on a phone, and which #143
already refused to hide as a fit step precisely because
hiding it takes away an action. So the selection gets a home
that does not depend on that bar existing.
At every width, not below 600px: a phone-only copy would be
a second place the control lives, and "where do I change
which library I am browsing" having two answers by size is
the fault, not the fix. */
.library-scope {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
margin-bottom: 1em;
}
.library-scope .scope-label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
display: block;
}
.library-scope .scope-description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0.35em 0 0;
max-width: 40em;
}
.save-row {
display: flex;
gap: 0.5em;
@@ -516,24 +435,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
margin: 0;
}
/* The two column lists are the densest thing in the app, and
the density argument is why they are shaped the way they
are rather than simply grown (#186).
Measured on the reference device: the row was already
335x36 -- it is the controls *inside* it that were 16x16 and
**16x14**, the smallest anywhere in this app, 36 of them.
So the fix grows the controls into the row they already
occupy and only takes the row from 36 to 44, which over the
two lists (10 and 19 items) is 232px of extra scroll on a
439px screen. Growing each control to its own 44px row
instead would have cost four screens. */
.column-item {
display: flex;
align-items: stretch;
align-items: center;
gap: 0.5em;
padding: 0 0.75em;
min-block-size: 44px;
padding: 0.5em 0.75em;
border-bottom: 1px solid
var(--yj-border-subtle, #333);
font-size: 0.85em;
@@ -551,19 +457,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* A native checkbox cannot grow its hit area without growing
its paint, and a 44px checkbox is not what anyone wants. So
the target is the label instead: .column-label is a real
<label for> now, which makes the column's *name* the thing
you tap -- ~250x44 rather than 16x16.
That is the argument config-field already makes one file
over for its own labels: "a real label association also
makes the label text a click target for the control, which
is behaviour, not annotation". Here it is the whole fix. */
.column-toggle {
cursor: pointer;
align-self: center;
accent-color: var(
--yj-accent,
#ffd43b
@@ -572,32 +467,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.column-label {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
min-block-size: 44px;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
/* The row stretches its children so the label can be a
full-height target; this is text, not a target. */
align-self: center;
}
.column-arrows {
display: flex;
align-items: stretch;
gap: 0.15em;
margin-left: auto;
}
/* 16x14 before this, and they carry background: none and a
transparent border -- so padding out to 44px grows the
target and changes nothing anyone can see until hover,
which is precisely what #186's Direction asks for. */
.column-arrow-btn {
background: none;
border: 1px solid transparent;
@@ -607,8 +484,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
font-size: 0.65em;
line-height: 1;
padding: 0.2em 0.35em;
min-inline-size: 44px;
min-block-size: 44px;
transition:
color 0.15s,
border-color 0.15s;
@@ -756,11 +631,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
/* Square, so it needs the width too -- the shared rule
above only gives it a height. It was 31x31, and it is
the only route to "Remove library", which is the case
#55 settled one component over: the way out is 44px. */
min-inline-size: 44px;
}
.overflow-btn:hover {
@@ -982,14 +852,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
// Scanning is started and watched here (#27), so the job
// snapshot is a live input to this page.
this.unsubscribeJobs = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
// Scan progress lives in the jobs panel now — this page only
// needs to know when the library list itself changes.
this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded,
() => void this.loadLibraries(),
@@ -1020,9 +884,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
protected override onViewDeactivate(): void {
this.unsubscribeJobs?.();
this.unsubscribeJobs = null;
this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.();
@@ -1032,28 +893,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
private async loadLibraries(): Promise<void> {
try {
const [
libs,
mode,
defaultPage,
queueFallback,
allowMetered,
popupVolume,
] = await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
GetAllowMeteredCatalogDownload(),
GetPopupVolume(),
]);
const [libs, mode, defaultPage, queueFallback, allowMetered] =
await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
GetAllowMeteredCatalogDownload(),
]);
this.libraries = libs ?? [];
this.concurrencyMode = mode;
this.defaultPage = defaultPage;
this.queueFallback = queueFallback;
this.allowMeteredCatalogDownload = allowMetered;
this.popupVolume = popupVolume;
} catch (err) {
console.error(
@@ -1231,133 +1084,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
}
// ===================================================================
// SCANNING (#27 — back from the Jobs tab)
// ===================================================================
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return this.jobs.find((job) => job.id === `scan:${id}`);
}
/** The status line under a library name while it is being scanned. */
private libraryScanStatus(id: number): string | null {
const job = this.jobForLibrary(id);
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private get anyScanning(): boolean {
return this.libraries.some(
(lib) => this.libraryScanStatus(lib.id) !== null,
);
}
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.startingScan) return;
this.startingScan = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.startingScan = false;
}
}
private handleScanLibrary = (id: number): void => {
this.activeMenuId = null;
void this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => this.handleScanLibrary(id),
);
};
private handleScanAll = (): void => {
void this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => this.handleScanAll(),
);
};
private handleFullRescan = async (): Promise<void> => {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded '
+ 'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up '
+ 'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.handleFullRescan(),
);
};
private handleViewToggle = (
view: string,
visible: boolean,
): void => {
this.viewsCtrl
.setVisible(view, visible)
.catch((err: unknown) => {
console.error('Failed to save view visibility:', err);
notificationStore.transient({
key: 'view-visibility',
text: `Could not change which views are shown. ${describeError(err)}`,
detail: String(err),
});
// The checkbox has already flipped itself; the store is
// the truth, so redraw from it.
this.requestUpdate();
});
};
private handleDefaultPageChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
@@ -1702,7 +1428,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
-->
${this.renderLibrarySection()}
${this.renderGeneralSection()}
${this.renderNavigationSection()}
${this.renderNowPlayingSection()}
${this.renderThemeSection()}
${this.renderTrackListSection()}
@@ -1797,20 +1522,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange}
></config-field>
<!--
The tier list above says what the build is *doing*;
this says it is a job, and gives it the pause, cancel
and log the tier list never had (#27). Cancelling one
still asks first that confirmation is inside
applyJobControl, keyed on the kind, which is why
this embeds the shared rows rather than drawing its
own.
-->
<job-panel
kinds="index-build,catalog-enrich"
heading="Index jobs"
></job-panel>
</config-section>
`;
}
@@ -1931,15 +1642,18 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
description:
'The page the app opens to on launch.',
type: 'select' as const,
// Derived, not written out: this list was a
// second copy of the launchable set, and #27
// removing a destination is exactly the change
// that would have left the two disagreeing.
// Settings is excluded because it is the one
// view the backend refuses to launch into.
options: VIEW_META
.filter((v) => v.alwaysShown !== true)
.map((v) => ({ value: v.id, label: v.label })),
options: [
{ value: 'home', label: 'Home' },
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
{ value: 'genres', label: 'Genres' },
{ value: 'playlists', label: 'Playlists' },
{ value: 'explore', label: 'Explore' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'autotag', label: 'Autotag' },
{ value: 'jobs', label: 'Jobs' },
],
}}
.value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange}
@@ -1960,121 +1674,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.queueFallback}
@config-change=${this.handleQueueFallbackChange}
></config-field>
<config-field
.schema=${{
key: 'popupVolume',
label: 'Volume opens in a popup',
description:
'Off, the volume slider is always visible in the '
+ 'player bar. On, it hides behind the speaker '
+ 'icon. The slider stands down on a phone either '
+ 'way, where the hardware keys own volume.',
type: 'toggle' as const,
}}
.value=${this.popupVolume}
@config-change=${this.handlePopupVolumeChange}
></config-field>
</config-section>
`;
}
/**
* The volume control's presentation (#42).
*
* In General rather than beside the theme because it is about the
* transport's behaviour rather than its colours, and next to "When
* the Queue Ends" because both are answers to "how should the
* player behave".
*/
private handlePopupVolumeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const popup = Boolean(e.detail.value);
const previous = this.popupVolume;
this.popupVolume = popup;
void SetPopupVolume(popup).catch((err: unknown) => {
console.error('failed to save the volume control setting', err);
this.popupVolume = previous;
notificationStore.transient({
key: 'popup-volume-setting',
title: 'Setting not saved',
text: describeError(err, 'That setting could not be saved.'),
});
});
};
// --- Navigation section ---
/**
* Which destinations the sidebar and the phone's tab bar offer.
*
* Two items are drawn but not editable, and both say why in place
* rather than being silently inert. Settings is never hideable --
* the backend refuses it too, because `config.toml` is
* hand-editable. The launch page is not hideable *while it is the
* launch page*, which is a state the user can leave by changing the
* launch page above; refusing is preferable to the alternatives,
* since resetting their launch page silently changes a second thing
* they chose and allowing it lands the app on a page nothing points
* at.
*/
private renderNavigationSection() {
return html`
<config-section
heading="Navigation"
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
>
<ul class="column-list">
${repeat(VIEW_META, (v) => v.id, (v) => {
const checked = this.viewsCtrl.enabled(v.id);
const isLaunchPage = this.defaultPage === v.id;
const locked = v.alwaysShown === true || isLaunchPage;
let note = '';
if (v.alwaysShown === true) {
note = 'Always shown.';
} else if (isLaunchPage) {
note = 'This is the launch page.';
} else if (
v.id === 'downloads' &&
checked &&
!downloadStore.available
) {
// The config says show it and the nav does not, which
// would otherwise read as the checkbox not working.
note = 'Hidden until a download client is configured.';
}
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="view-${v.id}"
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
.checked=${checked}
?disabled=${locked}
@change=${(e: Event) =>
this.handleViewToggle(
v.id,
(e.target as HTMLInputElement).checked,
)}
/>
<label class="column-label" for="view-${v.id}">
${v.label}
</label>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
</li>
`;
})}
</ul>
</config-section>
`;
}
@@ -2263,7 +1862,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="column-${id}"
type="checkbox"
class="column-toggle"
aria-label="Show the ${columnLabel} column"
@@ -2274,12 +1872,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
id,
)}
/>
<label
<span
class="column-label"
for="column-${id}"
>
${columnLabel}
</label>
</span>
<span
class="column-arrows"
>
@@ -2459,25 +2056,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
return html`
<config-section
heading="Libraries"
description="Manage your music library folders, and scan them
for new and changed files."
description="Manage your music library folders. Scanning and its
progress live in the Jobs panel."
.open=${true}
>
<div class="library-scope">
<div>
<span class="scope-label">Showing</span>
<p class="scope-description">
Which library the Albums, Artists and Genres
views show. This is a view filter, not a
setting about the libraries themselves the
list below is where they are added, renamed
and scanned.
</p>
</div>
<library-filter data-testid="settings-library-filter">
</library-filter>
</div>
<div class="scan-actions">
<button
class="btn-primary"
@@ -2485,23 +2067,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
>
Add Library
</button>
<button
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleScanAll}
>
Scan All
</button>
<button
class="btn-danger"
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleFullRescan}
>
Full Rescan
</button>
</div>
${this.libraries.length > 0
@@ -2539,8 +2104,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
<span class="library-count">
${this.removingLibraryId === lib.id
? 'Removing…'
: this.libraryScanStatus(lib.id)
?? html`${lib.trackCount} tracks`}
: html`${lib.trackCount} tracks`}
</span>
<div class="overflow-wrapper">
<button
@@ -2563,16 +2127,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
>
Rename
</div>
${this.libraryScanStatus(lib.id) === null
? html`
<div
class="overflow-item"
@click=${() => this.handleScanLibrary(lib.id)}
>
Scan now
</div>
`
: nothing}
<div
class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)}
@@ -2616,11 +2170,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange}
></config-field>
<job-panel
kinds="library-scan"
heading="Scans"
></job-panel>
</config-section>
`;
}
@@ -8,7 +8,6 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type {
DownloadDescriptor,
DownloadProvider,
@@ -23,7 +22,6 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
import '@components/jobs/job-panel';
import { pickDirectory } from '../../utils/pick-directory';
/**
@@ -137,7 +135,6 @@ export class DownloadClients extends LitElement {
static override styles = [
designTokens,
waTouchFloor,
css`
:host {
display: block;
@@ -241,17 +238,12 @@ export class DownloadClients extends LitElement {
margin-top: 0.4em;
}
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
min-block-size: 44px;
}
`,
];
@@ -282,17 +274,6 @@ export class DownloadClients extends LitElement {
</wa-button>
</div>
`}
<!--
The Downloads view already shows every download's
lifecycle state; what it has never had is pause,
cancel and the log, which the Jobs tab carried (#27).
Renders nothing while nothing is downloading.
-->
<job-panel
kinds="download"
heading="Downloads in progress"
></job-panel>
</config-section>
<config-section
@@ -25,11 +25,6 @@ export class ShortcutCapture extends LitElement {
:host {
display: inline-block;
}
/* 80x25, twenty-six of them -- the most numerous control on
the Settings page after the column lists (#186). The floor
is a height here and nothing else: the width was already
past it, and the type stays where it is so a shortcut still
reads as a key rather than as a button. */
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
@@ -40,7 +35,6 @@ export class ShortcutCapture extends LitElement {
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
min-height: 44px;
text-align: center;
transition:
border-color 0.15s,
@@ -67,11 +61,6 @@ export class ShortcutCapture extends LitElement {
opacity: 0.7;
}
}
/* Reset renders only for a rebound shortcut, so a sweep of a
freshly-installed app never sees it -- it is not in #186's
tables for that reason, and it is a touch target the moment
anybody uses the feature. It also has no background, so the
padding out to 44px is invisible. */
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
@@ -80,8 +69,7 @@ export class ShortcutCapture extends LitElement {
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: 44px;
min-height: 44px;
min-width: auto;
opacity: 0;
transition: opacity 0.15s;
}
@@ -91,17 +79,6 @@ export class ShortcutCapture extends LitElement {
.reset-btn:hover {
color: var(--yj-accent-text, #ffd43b);
}
/*
* Reset is the only way to put a rebound shortcut back, so where
* the device has no hover it is always visible rather than an
* invisible button holding its hit area. The inverse of #68's
* rule, which applies where the hover control is redundant.
*/
@media not all and (hover: hover) {
.reset-btn {
opacity: 1;
}
}
`;
private handleClick = () => {
@@ -23,8 +23,7 @@ import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
@@ -52,7 +51,7 @@ import {
ContextMenuController,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { creditStore } from '@store/credit-store';
@@ -416,17 +415,17 @@ export class CoverGrid
splitIndex = 0;
@query('#context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup!: MenuSurface;
private playlistSubmenuPopup!: WaPopup;
// ContextMenuHost interface.
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): MenuTarget | undefined {
getPlaylistSubmenuPopup(): WaPopup | undefined {
return this.playlistSubmenuPopup;
}
@@ -2088,8 +2087,11 @@ export class CoverGrid
const { ctxMenu } = this;
return html`
<menu-surface
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${ctxMenu.contextMenuOpen}
>
${ctxMenu.contextMenuOpen
@@ -2202,12 +2204,13 @@ export class CoverGrid
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<menu-surface
<wa-popup
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${ctxMenu.playlistSubmenuOpen}
>
${ctxMenu.playlistSubmenuOpen
@@ -2229,7 +2232,7 @@ export class CoverGrid
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<track-details></track-details>
`;
@@ -246,7 +246,7 @@ export class DownloadPicker extends LitElement {
return html`
<wa-callout variant="success">
Found a clear match and started downloading it. Progress is
on the Downloads page.
in the background jobs panel.
</wa-callout>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
@@ -93,26 +92,8 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
/* 85x34 and 96x34 before this (#186). A tab is the only
route to the panel it names, so it is the last control
that should be hard to hit -- and the underline that
marks the active one is drawn on the bottom border,
which a taller box moves further from the label. So the
height goes on *padding*, keeping the border against
the label rather than 10px below a centred one.
The min-size is the floor and is not redundant: padding
alone made this 44px here and **43px in CI**, because
the total is 13 + 13 + 2 + whatever line box the font
gives 13px text, and ubuntu:24.04's is a pixel shorter
than this machine's. A height computed from a font's
line box is not a height you control -- the same
mistake #195 made about a layout property measured on
one engine, one layer down, and caught here by the test
rather than by a person. */
.tab {
min-block-size: 44px;
padding: 13px 14px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
@@ -265,23 +246,23 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header
heading="Downloads"
.actions=${this.tab === 'requests'
? ([
{
id: 'check-now',
label: this.checking
? 'Searching\u2026'
: 'Check now',
icon: 'rotate',
disabled: this.checking,
title: 'Search every download client for everything on this list right now, instead of waiting for the next scheduled check',
onSelect: () => void this.checkNow(),
},
] satisfies PageAction[])
: []}
></page-header>
<page-header heading="Downloads">
${this.tab === 'requests'
? html`
<wa-button
slot="actions"
size="small"
appearance="outlined"
?disabled=${this.checking}
title="Search every download client for everything on this list right now, instead of waiting for the next scheduled check"
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Searching…' : 'Check now'}
</wa-button>
`
: nothing}
</page-header>
<p class="subtitle">
Music you have requested, and the download attempts that
@@ -2,7 +2,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import {
@@ -50,11 +49,9 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -330,7 +327,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private ctxMenuTrack: MBTrack | null = null;
@query('#track-context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -340,11 +337,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// -- ContextMenuHost interface --
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): MenuTarget | undefined {
getPlaylistSubmenuPopup(): WaPopup | undefined {
return this.playlistSubmenuPopup;
}
@@ -356,7 +353,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
srOnly,
@@ -381,6 +377,25 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -875,67 +890,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
.track-row .track-request {
flex-shrink: 0;
}
/* The phone (#66)
*
* **This block is last on purpose**, for index.css's
* reason: a media query adds no specificity, so a rule
* written above the plain one it overrides loses to it and
* every declaration here is silently dead.
*
* Two faults, one shape. The page is a fixed header over a
* scrolling tracklist the desktop arrangement so at the
* reference device's 424x439 the header owned 253 of the
* panel's 318px and the tracklist scrolled inside the 64px
* that were left. And the header's flex row squeezed
* .album-info to 112px, so the title drew as one ellipsised
* glyph and two of the album's three primary actions were
* clipped by the host's own overflow: Shuffle album ended
* at x=443 in a 424px box, unreachable by any gesture.
*
* .album-info carries min-width: 0 and was shrinking as
* asked, so another one is not the fix the row has to
* stack, or the info column has nothing to be wide with.
*
* The scroller moves to the host and .content stops being
* one, which is what makes the header scroll away; the
* tracklist is plain DOM rather than a virtualizer, so
* nothing inside wants a scroll window of its own. */
@media (max-width: 599px) {
:host {
overflow-y: auto;
}
.album-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* Stacked, the art is the whole of the header's width
* budget and its 200px square is 45% of the reference
* device's height. It is still what identifies the
* album, so it shrinks rather than going. */
.cover-art-container {
width: 140px;
height: 140px;
}
/* A column flex item takes its content's width from
* align-items: flex-start above, which would leave the
* actions wrapping inside a box narrower than the row
* they now have to themselves. */
.album-info {
align-self: stretch;
}
.content {
flex: 0 0 auto;
overflow-y: visible;
padding: 16px 16px 24px;
}
}
`,
];
@@ -3827,8 +3781,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
const track = this.ctxMenuTrack;
return html`
<menu-surface
<wa-popup
id="track-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && track
@@ -3889,12 +3846,13 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<menu-surface
<wa-popup
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -3911,7 +3869,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</menu-surface>
</wa-popup>
`;
}
}
@@ -3,7 +3,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import {
LookupArtist,
BrowseReleaseGroups,
@@ -62,11 +61,9 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -218,7 +215,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private ctxMenuTarget: ContextMenuTarget | null = null;
@query('#context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -236,11 +233,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
// -- ContextMenuHost interface --
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): MenuTarget | undefined {
getPlaylistSubmenuPopup(): WaPopup | undefined {
return this.playlistSubmenuPopup;
}
@@ -267,7 +264,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
@@ -291,6 +287,25 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -2606,8 +2621,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const target = this.ctxMenuTarget;
return html`
<menu-surface
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -2625,12 +2643,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<menu-surface
<wa-popup
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -2647,7 +2666,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</menu-surface>
</wa-popup>
`;
}
@@ -37,11 +37,9 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import { ICON_QUEUE } from '@utils/icon-language';
@@ -208,13 +206,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
@state() private ctxMenuTarget: ExploreMenuTarget | null = null;
@litQuery('#explore-context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
// -- ContextMenuHost interface --
// No playlist submenu — same reason as the album/artist detail
// pages: every action here resolves its one file lazily.
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
@@ -256,18 +254,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
margin-bottom: 10px;
}
/* 89x26 and 79x26 before this (#186). */
.search-mode-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-block-size: 44px;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
@@ -292,7 +287,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
border-radius: 6px;
padding: 0 12px;
gap: 8px;
min-height: 44px;
height: 36px;
max-width: 520px;
transition: border-color 0.15s ease;
}
@@ -367,15 +362,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
flex-shrink: 0;
}
/* The input measured 325x**18** and the box around it 36,
which is two faults rather than one (#186): the row was
under the floor, and the input did not fill it, so eight
of those pixels were not a target at all. The container
is 44 and the input stretches to it -- a tap anywhere in
the box now lands on the input rather than beside it. */
input {
flex: 1;
align-self: stretch;
background: none;
border: none;
outline: none;
@@ -389,21 +377,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
color: var(--yj-text-tertiary, #888);
}
/* No background until hover, so the target grows and the
glyph does not. It is inside a 44px box already, hence
the width alone. */
.clear-button {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 0;
min-inline-size: 44px;
margin-inline-end: -12px;
font-size: var(--yj-text-sm);
flex-shrink: 0;
}
@@ -1359,8 +1341,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const owned = Boolean(target?.localId);
return html`
<menu-surface
<wa-popup
id="explore-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -1389,7 +1374,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
</div>
`
: nothing}
</menu-surface>
</wa-popup>
`;
}
@@ -15,7 +15,6 @@ import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
@customElement('genre-details')
@@ -38,7 +37,7 @@ export class GenreDetails extends LitElement {
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = [designTokens, backButton, css`
static override styles = [designTokens, css`
:host {
display: flex;
flex-direction: column;
@@ -78,6 +77,31 @@ export class GenreDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -24,15 +24,14 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dictByName } from '@utils/binding';
@@ -138,19 +137,19 @@ export class GenresView
private contextMenuGenreName: string | null = null;
@query('#context-menu')
private contextMenuPopup!: MenuSurface;
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup!: MenuSurface;
private playlistSubmenuPopup!: WaPopup;
// ----- ContextMenuHost interface -----
getContextMenuPopup(): MenuTarget | undefined {
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| MenuTarget
| WaPopup
| undefined {
return this.playlistSubmenuPopup;
}
@@ -1177,8 +1176,11 @@ export class GenresView
private renderContextMenu() {
return html`
<menu-surface
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1282,12 +1284,13 @@ export class GenresView
</div>
`
: nothing}
</menu-surface>
</wa-popup>
<menu-surface
<wa-popup
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1315,7 +1318,7 @@ export class GenresView
</div>
`
: nothing}
</menu-surface>
</wa-popup>
`;
}
+38 -63
View File
@@ -1,9 +1,8 @@
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/button/button.js';
import { GetShelves } from '@go/home/service.js';
import { ICON_SHUFFLE } from '@utils/icon-language';
import type { PageAction } from '@components/page-header/page-header';
import { GetAlbumTracks } from '@go/library/library.js';
import type * as home from '@go/home/models.js';
import type * as library from '@go/library/models.js';
@@ -161,52 +160,29 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
user-select: none;
}
/*
* The hover play button is a *hover* affordance, so it is
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/touch-gestures.ts is measuring for a long press
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
*
* display:none outside the query rather than opacity:0 on
* its own: an opacity-0 button still takes taps and is
* still in the accessibility tree, so the invisible control
* would keep the hit area it was never meant to have on
* touch. Everything else stays inside, so the desktop
* animation is unchanged.
*/
.play {
display: none;
position: absolute;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
@media (hover: hover) and (pointer: fine) {
.play {
position: absolute;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
.name {
@@ -284,24 +260,23 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header
heading="Home"
.actions=${[
{
// "Shuffle" alone was two different controls
// with one name: this one and the transport's
// shuffle mode. They were never on screen
// together until the app started landing on
// Home (H-8), and a cached view is in the
// accessibility tree either way.
id: 'shuffle-suggestions',
label: 'Shuffle suggestions',
icon: ICON_SHUFFLE,
title: 'Reshuffle the suggestions',
onSelect: () => void this.load(),
},
] satisfies PageAction[]}
></page-header>
<page-header heading="Home">
<!-- "Shuffle" alone was two different controls with one
name: this one and the transport's shuffle mode.
They were never on screen together until the app
started landing on Home (H-8), and a cached view is
in the accessibility tree either way. -->
<wa-button
slot="actions"
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@click=${() => void this.load()}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle suggestions
</wa-button>
</page-header>
<p class="lede">Somewhere to start listening.</p>
${this.renderBody()}
`;
-132
View File
@@ -1,132 +0,0 @@
/**
* The phone's view of background work (#62).
*
* The header `job-indicator` is a *popover*, anchored to a bar 3.25em
* tall on a screen 439 CSS px tall, and it was reported as unreadable
* behind other UI. Two things are wrong with it there regardless of
* that symptom: a popover is a **disclosure**, and background work is
* the one thing a phone should not make you open something to see; and
* #57 deletes the bar it is anchored to, and is blocked on this issue
* precisely because the indicator needs somewhere else to live first.
*
* This is that somewhere. Below 600px the indicator stands down
* (`index.css`) and its work appears here instead.
*
* Four things about it are load-bearing.
*
* **It is the existing `job-panel`, not a second job UI.** Pause,
* cancel, Details and the log all come along and, more to the point,
* so does `applyJobControl`, which is what carries the "you will
* discard hours of downloading" confirmation for an index build. A
* host drawing its own buttons drops that silently, which is the trap
* #27 already named.
*
* **It is in the layout, not over it**, and that was measured rather
* than assumed. The first version of this put the panel in
* `notification-host`'s fixed band, which reads fine in a screenshot
* and is unusable: at 424x439 a compact panel is ~200px of a 439px
* screen, and it *covers* what is under it. Four e2e specs failed
* two phone-shell journeys and the header's action menu because the
* panel was intercepting the taps. A band that hides the app to tell
* you the app is busy is worse than the popover it replaced. In flow
* it pushes instead, so nothing is covered and nothing is unreachable,
* which is #24's one sentence across all three bands.
*
* **It shows active work only.** A finished row that lingers is a
* banner that stays after the work is done, which is the opposite of
* what #62 asks for ("dismissed automatically on completion") and, in
* flow, is furniture that keeps the content pushed down. Finished jobs
* are still shown where the work was started, which is #27's rule and
* unaffected.
*
* **It renders nothing at all above 600px**, from `matchMedia` rather
* than a media query, because this decides whether the element
* *exists*. `bottom-nav` learned that the expensive way: rendering its
* duplicate `<app-sidebar>` unconditionally put a second copy of every
* `nav-*` testid in the DOM and broke 30 specs on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so a
* fifth answering for *every* kind is the same trap.
*/
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { jobStore } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '../../utils/breakpoints';
import './job-panel';
@customElement('job-band')
export class JobBand extends LitElement {
@state() private phone = false;
@state() private active = 0;
private media?: MediaQueryList;
private unsubscribe?: () => void;
static override styles = [
designTokens,
css`
:host {
display: block;
min-width: 0;
}
/* The panel's own margin is for a settings section; here the
band owns the spacing. */
job-panel {
margin-top: 0;
padding: 0 0.5em 0.5em;
}
`,
];
private onMedia = (e: MediaQueryListEvent | MediaQueryList) => {
this.phone = e.matches;
};
private onJobs = () => {
this.active = jobStore.jobs.filter((job) => !isTerminal(job)).length;
};
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
// The band decides whether to render *at all*, and a panel that
// hides itself cannot tell its host that.
this.unsubscribe = jobStore.subscribe(this.onJobs);
this.onJobs();
void jobStore.init();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.media?.removeEventListener('change', this.onMedia);
}
override render() {
// `hidden` rather than an empty render, so the grid row this
// sits in costs nothing at all while there is no work -- the
// rule `job-panel` already follows one layer down.
this.hidden = !(this.phone && this.active > 0);
if (this.hidden) return nothing;
return html`
<job-panel kinds="*" density="compact" active-only></job-panel>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-band': JobBand;
}
}
+1 -15
View File
@@ -155,27 +155,13 @@ export class JobIndicator extends LitElement {
goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the
state change.
[compact] is the same removal asked for by measurement
rather than by width, and it is set from outside: the
shell's fit pass (services/top-bar-fit.ts, #143) owns
it, because between 600 and 900 whether this label fits
depends on what else is in the bar and on how long the
running job's title is -- 235px for "Scanning Music from
the external drive" -- rather than on the viewport. Two
triggers, one effect, and the phone's is unconditional
because it was argued and pinned before this existed. */
state change. */
@media (max-width: 599px) {
.label {
display: none;
}
}
:host([compact]) .label {
display: none;
}
.alert-dot {
width: 6px;
height: 6px;
-267
View File
@@ -1,267 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job, JobKind } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
/**
* The background work of one kind, rendered wherever that work is
* started or configured.
*
* #27 folded the Jobs tab away, and the shape it folded into is this
* rather than one "Background jobs" panel in Settings which would
* have been the tab again under another name. Reading the app first
* turned up that **four of the five job kinds already had a home**
* showing their work: Settings Search Index draws per-tier index
* progress, `downloads-view` draws every download's lifecycle state,
* `autotag-view` draws its own apply ring, and only `library-scan` had
* nowhere but the tab. What none of those four had is the *generic*
* affordances pause, cancel, "Details", the log, and a finished job
* you can dismiss which is what this carries to each of them.
*
* Three things about it are load-bearing.
*
* **The controls are `applyJobControl`, not a reimplementation.** That
* is what keeps the "you will discard hours of downloading"
* confirmation on an index build alive across the move: it is keyed on
* `KindIndexBuild` inside the shared handler, and a host that rendered
* its own buttons would silently drop it.
*
* **A panel with nothing to say renders nothing at all**, host padding
* included an idle panel in four places is four pieces of furniture
* describing an absence. That is the rule `startBackfillJob` follows
* for the indicator, one layer up.
*
* **There is no "Clear finished" here**, because `ClearFinishedJobs` is
* global: a Clear in the Libraries panel would silently discard the
* index build's history too. A finished row dismisses itself, which is
* per-job and is what `job-row` already offers.
*/
@customElement('job-panel')
export class JobPanel extends LitElement {
/**
* Comma-separated job kinds, e.g. `index-build,catalog-enrich`.
*
* An attribute rather than a property because every call site is a
* literal in a template, and one of them is inside an HTMX-adjacent
* settings page where a property binding would be one more thing to
* remember.
*
* **`*` means every kind**, which is the phone's band (#62) and
* nothing else: there, this panel is standing in for the header
* indicator, whose whole job was to be the one view of everything
* at once. It is spelled `*` rather than taken as the meaning of an
* empty attribute, because empty is what a typo and a missing
* binding both produce and "show everything" is the wrong thing to
* do by accident. Empty still shows nothing.
*/
@property({ type: String })
kinds = '';
/** Heading above the rows. Omitted renders no heading. */
@property({ type: String })
heading = '';
/**
* Row density, passed to `job-row`.
*
* `full` adds elapsed time and per-job statistics and is what a
* settings section wants, so it stays the default and the four
* existing call sites are unchanged. `compact` is what `job-row`
* itself calls "the popover density", and it is what the phone's
* band uses (#62) there this panel *is* the popover, on a screen
* 439 CSS px tall, and the full density spent 259 of them.
*/
@property({ type: String })
density: 'compact' | 'full' = 'full';
/**
* Drop finished rows.
*
* For the phone's band (#62), which is *in the layout*: a finished
* row there is a banner that stays after the work is done and keeps
* the content pushed down. Settings keeps them, because that is
* where "did the last scan work" is asked, and a finished row there
* dismisses itself.
*/
@property({ type: Boolean, attribute: 'active-only' })
activeOnly = false;
@state()
private jobs: Job[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
margin-top: 1em;
}
/* An empty panel takes no room at all, margin included. */
:host([hidden]) {
display: none;
}
h3 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 0.5em;
}
.card {
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border, #495057);
border-radius: 6px;
overflow: hidden;
}
.job-entry {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.6em 0.8em;
border-bottom: 1px solid var(--yj-border-subtle, #3a4046);
}
.job-entry:last-child {
border-bottom: none;
}
job-row {
flex: 1;
/* A grid child's implicit minimum is its content, and a
job title is long. */
min-width: 0;
}
.details-btn {
background: none;
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-text-sm);
padding: 0.3em 0.6em;
white-space: nowrap;
}
.details-btn:hover {
color: var(--yj-text-primary, #e9ecef);
}
`,
];
override connectedCallback() {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
/** The kinds this panel answers for. */
private get wanted(): ReadonlySet<string> {
return new Set(
this.kinds
.split(',')
.map((k) => k.trim())
.filter(Boolean),
);
}
private get mine(): Job[] {
const ofKind =
this.kinds.trim() === '*'
? this.jobs
: this.jobs.filter((job) =>
this.wanted.has(job.kind as JobKind),
);
return this.activeOnly ? ofKind.filter((job) => !isTerminal(job)) : ofKind;
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
override render() {
const mine = this.mine;
// Hidden rather than empty: see the class comment. The drawer
// goes with it, since it can only have been opened from a row.
this.hidden = mine.length === 0;
if (mine.length === 0) return nothing;
const active = mine.filter((job) => !isTerminal(job));
const finished = mine.filter(isTerminal);
return html`
${this.heading ? html`<h3>${this.heading}</h3>` : nothing}
<div class="card">
${[...active, ...finished].map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant=${this.density}
@job-control=${applyJobControl}
></job-row>
<button
type="button"
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-panel': JobPanel;
}
}

Some files were not shown because too many files have changed in this diff Show More