Compare commits

...
13 Commits
Author SHA1 Message Date
logan b1cdef8769 docs: record what a phone said that no tier could
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 5s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 3m28s
The first device run of the published APK, and the first runtime
evidence any of the Android work has ever had -- A4 shipped entirely
reasoned from source.

It confirms A4 whole: playback survives the screen locking, and the
transport notification appears with cover art, which settles four
open questions at once (the service starts, the permission was granted
and the notification is visible, the lock screen picks up the session,
and art decoded from a MANAGE_EXTERNAL_STORAGE path by a service is
readable -- the one nobody could argue from documentation).

It also found the two faults fixed in the preceding commits, and the
lesson worth keeping is why *those two*: both are things the platform
adds rather than things the app draws. So the skill's Android tier now
says to ask a device about system bars, the back gesture, focus and
audio interruptions, permissions and the keyboard -- and not about
layout, which the other five tiers already cover.
2026-08-17 02:02:10 -04:00
logan d661836347 fix(android): keep the app out from under the system bars
Reported from the first device run: the playback controls are off
screen. `targetSdk 35` is Android 15, which lays every app out
edge-to-edge and ignores the deprecated `statusBarColor` and
`navigationBarColor` the scaffold's theme still sets -- so a
`match_parent` WebView draws the page's bottom band, which on a phone
is the transport *and* the tab bar, underneath the gesture bar.

`applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them, so the WebView is laid out inside them. The keyboard is
in the mask because a search box the keyboard covers is the same bug
one surface over.

The window background goes black to match the app's own default ramp:
that padding is what shows through, and a band of the scaffold's
blue-grey above and below reads as the app failing to fill the screen.

No tier we have can see this class of fault -- a browser viewport has
no system bars, so `phone-shell.spec.ts` at 390x844 renders a shell
that fits at the moment the device is clipping it. Verified only as far
as the APK building; the insets need the next build on a phone.
2026-08-17 02:02:10 -04:00
logan 28eecf0a97 fix(ui): the Android back button had nowhere to go
Reported from the first device run: back does not navigate back in the
app. The scaffold's `MainActivity.onBackPressed` asks
`webView.canGoBack()` and finishes the activity otherwise -- and this
app had never touched `history`, so that was false at every depth and
back quit from anywhere.

The fix is here rather than in Java, because the mechanism the scaffold
already uses is the one we were failing to feed: a navigation is a
history entry now, and `popstate` replays it. Nothing on the Android
side changes, and the behaviour becomes assertable in a browser with
`page.goBack()` instead of only on a phone.

The entry keeps the same URL -- the app has no routes, and a path a
reload cannot resolve is worse than none -- and carries the destination
in its state.

Two rules keep the stacks from disagreeing. The first navigation
*replaces* the launch entry rather than pushing one, or every launch
costs a back press before the app will close. And the in-app back
buttons go through `history.back()` rather than popping a stack of
their own: `navStack` is deleted, not kept alongside, because two
stacks is precisely how a detail view's own button and the phone's
gesture come to disagree about how far one press goes. The third spec
pins that invariant.
2026-08-17 02:01:57 -04:00
logan e8690476bd feat(ui): long-press opens the menus a right-click opens
Every context menu in the app opens from a `contextmenu` event, bound
three different ways across six components -- delegated on a
virtualizer, per row, per card. A phone has no right-click, so a phone
reached none of them (plan 016 B2 phase 3).

This is one document-capture listener installed once from `index.ts`,
not six components' worth of touch handling: a touch that holds still
for 500ms dispatches a synthetic `contextmenu` at the touch point, and
every existing handler runs unchanged. A seam no component has to opt
into is one no future component can forget.

Four details are load-bearing, each a way the obvious version fails.
The target is `composedPath()[0]`, not `elementFromPoint`, which stops
at the outermost shadow host -- every menu here is bound inside one, so
a host-targeted event reaches a delegated listener and no per-row one.
A browser that fires its own long-press `contextmenu` (Chromium does;
WebKit and the Android WebView vary) wins, and ours is told from theirs
by identity rather than `isTrusted`: `isTrusted` works in the app and
is untestable, which would leave the suppression path as the one thing
with no coverage. And the click ending the gesture is swallowed, keyed
on the gesture rather than a time window, or the first tap on the menu
it just opened is eaten too.

The e2e spec presses `.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.
2026-08-17 02:01:46 -04:00
logan 7e0be8fa30 fix(indexexport): read an index older than the binary
Build & publish Arch package / arch-package (push) Successful in 2m24s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m38s
`maintain-index` failed with

    indexexport: copy rows: SQL logic error: no such column: total_tracks

three minutes into the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.

The cause is the exception that keeps that checkpoint alive. The job's
/cache is a real YJ_HOME that survives between runs, so its
explore_index is classified Cache and is deliberately *not* dropped and
recreated by cmd/indexbuild's schema repair -- which means a column
added to the schema afterwards is absent from it. total_tracks arrived
with the album-completeness work; the exporter selected it regardless.

The fix is the rule the importing side already follows.
artifactHasTotals exists because "adding a column to the importer's
SELECT is how you break every artifact already published"; the mirror
image, reading an index older than the binary, had no such guard.
sourceColumns asks pragma_table_info and selects a literal 0 when the
column is absent -- which is what that column already means by "the
catalog does not say", and what the app renders as unknown rather than
as incomplete. The artifact keeps every column, so an importer needs no
second shape.

The test reproduces the failure symptom first: with the fix removed it
fails with the CI message verbatim. Its own first version proved
nothing, though, and that is worth the comment it now carries --
`strings.Replace(catalogColumns, "total_tracks, ", …)` matches nothing,
because the list is formatted across lines and the name is followed by
a newline, so the "old" index was built with every current column.
2026-08-17 00:39:19 -04:00
logan 1b05dde382 feat(ui): the full-screen now playing a phone needs
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / e2e (push) Successful in 5m55s
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
2026-08-17 00:22:58 -04:00
logan 29299d17da fix(dev): run the local e2e tier against the app CI runs
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / check (push) Successful in 2m30s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m5s
Two specs failed locally and passed in CI, which is the least useful
direction for a disagreement to point.

**`dev-headless.sh` was the only launcher not stubbing out the
catalog.** `seed-sandbox.sh` and `ci.yml` both send
`YJ_CORE_INDEX_URL` to a dead address; the dev launcher did not, so the
app downloaded and built the real ~1M-row Explore catalog into the
run's YJ_HOME and every local `make e2e` after that ran against a world
CI never sees. Found by reading the failure screenshot: the spec had
searched Explore for its fixture album and the page was full of real
ones. It defaults to the dead address now and takes an explicit one for
exploring by hand.

**And the shared backend carries spec state between runs.**
`explore-shelves` staged its catalog only `IfEmpty`, so one album row
left behind by `requested-badge` satisfied that gate: the shelves were
drawn from a single foreign row and the artist card the spec clicks did
not exist. It failed on the *second* local run and passed on the first,
and never in CI, where every run gets a fresh home.

"Is the catalog empty" was the wrong question and "are my rows there"
is the right one, so staging is unconditional (INSERT OR IGNORE keyed
on the MBID) and the assertion moved from *this insert wrote a row* to
*every fixture row is present*. That is both idempotent and stronger:
an MBID that fails CHECK(length(mbid) = 16) is silently dropped by OR
IGNORE, which the old per-insert count caught only on a cold catalog
and the new one catches always.

Verified by running the whole suite twice against one app: 97/3 before,
100 passed both times after.
2026-08-16 23:47:24 -04:00
logan 57fbbdf0d2 feat(ui): a shell a phone can be held in
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
Section A of plan 016 is closed and B1 is decided, so the three tenses
move together: CLAUDE.md for what mediacontrols now is, the skill for
what to run, NOTES.md for what was measured and when.

The entry worth reading is the one that disproves a claim written here
earlier in the same session. Dropping x86_64 was expected to make
make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS.
Measured, it installs and launches: Google's google_apis x86_64 images
carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader
maps lib/arm64/libwails.so and runs it. It dies before any of our code
with SIGILL, and the disassembly names the reason exactly --
`mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature
register at runtime init, which the translator does not implement. So
no Go binary starts under it, and that is not a property of this app.

Which closes the last plausible shortcut. There are now three distinct
ways this app fails on an x86_64 Android -- seccomp on the x86_64
build, an unimplemented system register on the translated arm64 one,
and a real device still unverified -- and none of them is a bug in it.
A phone remains the only verification path.

Plan 016 also carries the B2 scope, now decided rather than
recommended: option 1's data model with option 2's surface. The phone
gets home, library browse, now-playing-as-a-view, the queue, search and
playlists; it does not get autotag, downloads, Explore or the 93-control
Settings page, and each of those has a reason written beside it. One
rule for the work: no view forks, because a phone template that copies
a view's is two templates to fix every bug in.
2026-08-16 22:26:39 -04:00
logan c99c8efa11 ci(android): tell a wrong password apart from a wrong keystore
The v1.5.0 run reported that the keystore did not open, and the
diagnostics could not say why. They now clear the two causes that look
identical to a wrong password.

**A password pasted with its shell quotes** is two characters longer
than the password and nothing in keytool's error says so. The step
retries with the surrounding quotes stripped and, if *that* opens the
keystore, says exactly that. It does not strip them and carry on: a
password may legitimately contain a quote, so this reports a diagnosis
rather than guessing at a fix.

**A password that is right for a different keystore** is the other one,
and it is the one currently in play -- the secret decodes to a valid
2280-byte PKCS12 and the password is the length the owner expects, which
leaves "is this the keystore I have locally?" as the open question. The
step prints the decoded file's sha256 so that is answerable by
comparing one line against sha256sum. Hashing a certificate store gives
nothing away.
2026-08-16 22:26:29 -04:00
logan 904786b941 fix(dev): the Android harness did not parse, and then chose any device
Two bugs, and the first had made every make android-* target dead since
the commit that introduced it.

**The script did not parse at all.** A case pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash rejects
the *whole file*, so android-emulator, android-install, android-smoke
and android-logs all died with "line 190: syntax error near unexpected
token `do'" -- a message that points at a line nobody had reason to
suspect, in a file that had been working. Quoting the inner words fixes
it. A shell script only ever run by hand can carry a syntax error
indefinitely; nothing in the pre-commit hooks runs bash -n.

**A bare adb addresses whatever is attached.** With a second emulator
present -- another project's, or this one's own corpse left `offline` by
a previous run -- every adb call fails with "more than one device", and
cmd_install reported that as "no device - run 'make android-emulator'
first" *directly after* that had printed "waiting for boot ok". Which
is the harness's own house rule broken: a failure that names the wrong
cause is worse than one that names none.

pick_device resolves ANDROID_SERIAL from ro.boot.qemu.avd_name before
any device command. The AVD name is the identity because serials are
assigned in boot order and change between runs; a caller's own
ANDROID_SERIAL wins, and a single device that is not ours is taken as
the target, since that is a phone and a phone is what this tier
actually wants. Verified with both emulators running.
2026-08-16 22:26:21 -04:00
logan b6651310ea build(android): drop the x86_64 ABI, which no Android can run
The fat APK's second half was 31 MB that cannot execute on any Android
device. modernc.org/libc's Xlstat64 issues a raw lstat syscall on
linux/amd64, and Android's seccomp policy forbids it because bionic
never issues it, so the process takes SIGSYS the first time anything
touches the database -- which for this app is startup. That is every
x86_64 Android, x86 Chromebooks included, not merely the emulator.
arm64 is structurally unaffected: the architecture has no lstat syscall
at all, so modernc routes through fstatat.

27,059,130 bytes to 15,898,465, and one lib/ entry.

Three places had to agree, and the third is what would have made this a
silent no-op: abiFilters (what Gradle packages), android:package rather
than package:fat (what Go *compiles* -- otherwise the library is still
built and then discarded), and the native-code assertion in CI. That
assertion is anchored, `native-code: 'arm64-v8a'$`, because without the
anchor it also matches the fat APK's line and would pass on exactly the
thing it exists to catch. Checked against a real artifact.

Adding the ABI back, if modernc ever fixes Xlstat64, is those same
three edits.
2026-08-16 22:26:11 -04:00
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00
48 changed files with 4566 additions and 168 deletions
+36 -5
View File
@@ -1,7 +1,7 @@
name: Build & publish the Android APK
# The fifth workflow, and the second that publishes. It builds a signed
# fat APK (arm64-v8a + x86_64) on every version tag and puts it in
# arm64-v8a APK on every version tag and puts it in
# Gitea's *generic* package registry, which — unlike the repository — is
# readable without credentials. That is what lets an Obtainium client
# poll a plain URL with no token and no public mirror of the source.
@@ -225,7 +225,7 @@ jobs:
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
# that are *verbatim* a secret, so a trimmed one could print in
# clear — or repeating the trimming logic in both.
- name: Build the signed fat APK
- name: Build the signed APK
working-directory: /src
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
@@ -277,6 +277,18 @@ jobs:
size=$(stat -c %s "$keystore")
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
echo "keystore: $size bytes, first four bytes: $magic"
# The fingerprint of the decoded file, so "is the secret the
# keystore I have locally?" is answerable without guessing.
# A hash of a *public* certificate store gives nothing away,
# and the alternative is comparing byte counts by eye.
#
# sha256sum ~/path/to/yellowjacket-release.jks
#
# A password that is right for one keystore and wrong for
# another is indistinguishable from a wrong password, and this
# is the line that distinguishes them.
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
case "$magic" in
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
"fe ed fe ed") echo " header: legacy JKS" ;;
@@ -291,6 +303,20 @@ jobs:
echo " password length after trimming: ${#pass}" >&2
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
echo >&2
# A password pasted *with its shell quotes* is the one
# remaining cause that looks identical to a wrong password:
# the secret is two characters longer than the password and
# nothing in the error says so. Naming it is safe --
# stripping the quotes and carrying on would not be, since a
# password may legitimately contain them.
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
if [ "$unquoted" != "$pass" ] &&
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
echo " ** it opens with the surrounding quotes removed. **" >&2
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
echo >&2
fi
echo "Check it locally with the same two values:" >&2
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
@@ -333,9 +359,14 @@ jobs:
ls -la "$apk"
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
# Both ABIs, or the artifact is not the fat APK it claims to be.
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a' 'x86_64'" || {
echo "the APK does not carry both ABIs" >&2; exit 1; }
# arm64 and *only* arm64. x86_64 Android cannot run this app
# (modernc's raw lstat against Android's seccomp filter, which
# is every x86_64 device and not merely the emulator), so an
# x86_64 slice would be ~31 MB that runs nowhere -- and its
# reappearance would mean someone had put the ABI back in
# app/build.gradle without knowing that.
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
# The identity the pipeline exists to keep stable.
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
+9
View File
@@ -96,6 +96,15 @@ reference, because you need them *before* the failure, not after.
Run against the `bulk` seed a measurement session left behind and a
third of them fail (13 of 36, when it was measured), in a list that
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
- **The catalog is stubbed out locally now, like CI.**
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
because it was the only launcher that did not — `seed-sandbox.sh` and
`ci.yml` always have. Without it the app downloads the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and specs that stage their
own catalog rows then search a million real ones and fail *locally
only*, which reads as a regression and is an environment. Pass
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
explore by hand.
- **…and the suite spends state it cannot always give back.**
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
of the eleven the seed has, and does not put it back — so around the
@@ -47,7 +47,7 @@ make android-setup # SDK pieces + the yj-test AVD, idempotent
Then:
```bash
make android # fat APK (arm64 + x86_64) -> bin/yellowjacket.apk
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
make android-emulator # boot headless in the background, wait for boot
make android-install # adb install -r
make android-smoke # launch, then assert the same pid survives 10s
@@ -63,6 +63,17 @@ command line and kills it, silently dropping the rest of your compound
command. The emulator is addressed by its saved pid in
`.dev/emulator.pid`, same discipline as `make dev-stop`.
**adb is addressed by AVD name, not by whatever is plugged in.** The
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
any device command, because a second emulator (another project's, or
this one's own corpse left `offline` by a previous run) makes a bare
`adb` fail with "more than one device" — which `cmd_install` reported
as *"no device — run 'make android-emulator' first"* immediately after
that had succeeded. Serials are assigned in boot order and change
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
yourself and it is honoured; one device that is not ours (a phone) is
taken as the target.
## Things that cost a cycle
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
@@ -135,11 +146,57 @@ FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
Google dropped cross-architecture emulation; there is no flag. The
options are an arm64 host, a physical device, or `adb connect` to one.
Two consequences worth holding onto. The x86_64 half of the fat APK is
*only* useful for emulators, and cannot work on any Android until
modernc fixes this — including x86 Chromebooks. And the tombstone is at
least honest: unlike the `os.Exit` that came before it, this one leaves
a real crash record with a backtrace.
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
`build/android/app/build.gradle`, `android:package` rather than
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
assertion in `android-apk.yml` that fails if it comes back). It could
not run on any Android until modernc fixes this — x86 Chromebooks
included — and dropping it took the artifact from 27 MB to 15.9 MB.
The tombstone was at least honest while it lasted: unlike the
`os.Exit` that came before it, it left a real crash record with a
backtrace.
### The emulator still installs it, and it still does not run
The obvious guess about dropping x86_64 — that `make android-install`
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
and was measured wrong before it was written down.** Google's
`google_apis` x86_64 images carry arm64 translation:
```
ro.product.cpu.abilist = x86_64,arm64-v8a
```
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
dies **before any of our code**, with SIGILL rather than SIGSYS:
```
signal 4 (SIGILL), code -6 (SI_TKILL)
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
```
Disassembling that offset names the reason exactly:
```
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
```
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
at runtime init, which the translator does not implement. So it is not
"our Go program is unlucky": **no Go binary starts under this
translation layer**, and no amount of work on this app changes it.
The three failures are worth holding side by side, because each looks
like the app's fault and none is:
| build | on x86_64 Android | signal |
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | unverified, still |
**A physical arm64 device remains the only verification path.**
### What was fixed to get here
@@ -155,12 +212,37 @@ the `indexbuild` tag.
### What is still not done
MPRIS is compiled in (`android` implies the `linux` build tag), the
shell is still a desktop shell, and — the largest one — open-*directory*
dialogs return an error on Android, because the Storage Access Framework
yields tree URIs rather than filesystem paths. This app's first run is
"choose your music folder" and its library model is filesystem paths, so
that is a design question rather than a port.
The shell is still a desktop shell, and the x86_64 half of the APK is
still dead weight. Everything in plan 016's section A is now built:
storage access, an in-app folder picker (Android's directory dialog
returns an error, since the Storage Access Framework yields tree URIs
rather than paths), MPRIS excluded, and a MediaSession with a transport
notification and audio focus.
### Compiling the `android`-tagged Go by hand
`make lint` and `make test` never see it: their three tag sets are all
linux/amd64, so the only thing that compiles `backend/mediacontrols/
android.go` is `make android` — a full APK build for a Go type error.
The short way round:
```bash
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
```
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
compile against the host sysroot and fail on `android/log.h` and
`sys/system_properties.h`, which reads like a broken or missing NDK.
Restrict it to `./backend/...`: `./...` additionally builds
`build/android/gen`, a scaffold shim that only resolves inside the
wails task and fails with `undefined: main` on its own.
A Go method added to a bound service also reaches the frontend unless
it says not to — `//wails:ignore` above the func, which `make bindings`
then honours. `Player.SetDuck` is driven by OS audio focus and carries
one.
## The scaffold's own tasks
@@ -204,3 +286,27 @@ Related, and it will bite once: the launcher activity is
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.
## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them
ours — see plan 016), so the phone in someone's pocket is a tier, and
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
the whole of A4 and found two faults **no other tier can see**:
- **The back gesture.** `MainActivity.onBackPressed` asks
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
so no spec had ever called `page.goBack()` and the app had never
pushed a history entry — back quit from any depth. It is a history
entry per navigation now, which is also what made it assertable in the
browser tier (`e2e/specs/back-navigation.spec.ts`).
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
transport and the tab bar sat under the gesture bar. **A browser
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
keep passing on a build the device is clipping 48dp off. Insets are
handled in `applyWindowInsets()`.
So when asking for a device run, ask about what the platform *adds*
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.
+479
View File
@@ -2659,3 +2659,482 @@ it. So the arm64 claim above rests on reading modernc's two code paths,
not on having run it: verifying the shipped ABI needs an arm64 host, a
physical device, or `adb connect` to one. The image was deleted again;
do not re-download it.
## Android media controls need no new JNI and no new dependency (2026-08-16)
Plan 016's A4 — playback that survives the screen locking — turned out
to be reachable entirely through seams that already exist, which is the
finding worth keeping. The obvious blocker is that Wails' `androidBridge*`
helpers are unexported, so Go cannot call arbitrary Java. It does not
need to:
- **Go → Java** is `application.Android.StartForegroundService(json)`,
which *is* exported, and `build/android/` is our tree — so widening
the JSON that `WailsBridge.startForegroundService` accepts is a local
edit, not a fork of the runtime.
- **Java → Go** is `WailsBridge.emitEvent(name, json)` →
`nativeEmitEvent` → `app.Event.Emit`, which a Go `app.Event.On`
subscriber receives with `Data` as a `map[string]any`.
So the handler is one JSON document out and one command event back, and
`backend/mediacontrols`' existing `Handler`/`Callbacks` interface — written
for MPRIS — needed one addition (`OnDuck`) to cover a MediaSession.
**The Java side needs no androidx.media either.** `MediaSessionCompat`
is the documented route, but `android.media.session.MediaSession` and
`Notification.MediaStyle` are both API 21 and minSdk here is 21, so the
platform API covers it with two `Build.VERSION` branches (the channel,
and PendingIntent mutability flags) and no new Gradle dependency.
Four things measured or reasoned along the way, each of which would
have been a bug:
- **From API 26 the framework ducks the app itself** and sends no
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. So a duck implemented in the
player is a *pre-Oreo* path, and `setWillPauseWhenDucked(true)` —
which is how you get the callback back — would mean pausing for
every notification tone. Implementing both attenuates twice.
- **A duck must not touch the user's volume.** `Player.SetDuck` holds
the attenuation as a separate offset and re-applies the user's level
through `setVolumeLocked`, so it cannot accumulate across repeated
ducks and `getUserVolume` — which feeds the event, the persisted
state and every relative change — still reports what the user chose.
- **From Android 12 a background app may not *start* a foreground
service**, but it may keep delivering intents to one already running.
Every update after the first is exactly that case (a track change
with the screen off), so `WailsBridge` picks `startService` over
`startForegroundService` once `WailsForegroundService.running` is set.
- **A service started with `startForegroundService` that returns from
`onStartCommand` without calling `startForeground` is killed**, so
the transport-button intents call it too rather than only the payload
path.
**`make lint` does not see any of this.** Its three passes are the app,
`indexbuild` and `dev` tag sets, all on linux/amd64, and `android.go` is
behind the `android` build tag — the only thing that compiles it is the
cross-compiler in `make android`. That is why the payload keys, the
state words and the command names live in `androidpayload.go` *without*
a build tag, with a test: it is the half that can be checked on the
machine doing the work. A quick manual check of the tagged half is
```bash
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
```
— `CXX` matters: without it the oboe C++ sources compile against the
host sysroot and fail on `android/log.h`, which reads like a missing NDK.
**None of it has run.** The APK builds for both ABIs and the Go and Java
halves compile; everything above about behaviour is read from the
Android documentation and the source. The x86_64 emulator still cannot
run this app (modernc `lstat`/seccomp, above) and an arm64 AVD still
cannot exist on an x86_64 host, so A4's first real test is a device.
## Dropping x86_64 cut the APK by 41% (measured 2026-08-16)
Plan 016's B1, decided: the ABI is gone.
| | fat (arm64 + x86_64) | arm64 only |
|---|---|---|
| `bin/yellowjacket.apk` | 27,059,130 B | 15,898,465 B |
| `lib/` entries | 2 | 1 |
It buys nothing to keep. x86_64 Android takes SIGSYS the first time it
touches the database (modernc's raw `lstat` against Android's seccomp
filter, above), which is *every* x86_64 device — emulators and x86
Chromebooks alike — not merely the emulator here.
Three places had to agree, and the third is the one that would have
made this a silent no-op: `abiFilters` in `build/android/app/
build.gradle` (what Gradle packages), `android:package` rather than
`android:package:fat` in the Makefile (what Go compiles — otherwise the
31 MB library is still built and then discarded), and the `native-code`
assertion in `android-apk.yml`'s Verify step, which is now
`native-code: 'arm64-v8a'$` and fails if a second ABI ever comes back.
The anchor is deliberate and was checked against a real artifact:
without it the pattern also matches the fat APK's line.
One consequence for the dev tier was written down before it was
checked, and checking it proved it false — see the next entry.
## arm64 translation runs Go until Go asks the CPU what it is (measured 2026-08-16)
Predicted, when the x86_64 ABI was dropped: `make android-install`
against the emulator would now fail with
`INSTALL_FAILED_NO_MATCHING_ABIS`. **Measured: it installs and
launches.** Google's `google_apis` x86_64 images carry arm64
translation —
```
ro.product.cpu.abilist = x86_64,arm64-v8a
```
— so the loader maps `lib/arm64/libwails.so` and executes it; the
tombstone confirms it with `ABI: 'x86_64'` / `Guest architecture:
'arm64'`.
It dies anyway, before a line of our code, and the instruction says
exactly why. The fault is at `libwails.so+0x15911d0`:
```
signal 4 (SIGILL), code -6 (SI_TKILL)
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
```
That is Go's `internal/cpu` reading the arm64 feature-ID system
register during runtime init. The translator does not implement it, so
**no Go binary starts under it** — this is not a property of this app
and no work here would change it. (`code -6 (SI_TKILL)` also means the
signal was re-raised by the process itself: Go's handler caught the
SIGILL, printed a traceback to a stdout that goes to `/dev/null`, and
re-raised. The invisible-failure rule again.)
So there are now three distinct ways this app fails on an x86_64
Android, none of them a bug in it:
| build | cause | signal |
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | still unverified |
**A physical arm64 device is still the only verification path**, which
is the conclusion the previous session reached by a different route.
The value of this entry is that it closes the remaining plausible
shortcut, with the instruction that closes it.
### Two bugs the attempt found in the harness itself
Both were on `main`, and the first had made the whole tier unusable
since the commit that added it.
**`scripts/android-emulator.sh` did not parse.** A `case` pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash fails
the parse of the *entire file*, so `make android-emulator`,
`android-install`, `android-smoke` and `android-logs` all died with
`line 190: syntax error near unexpected token 'do'`. Quoting the inner
words fixes it. A shell script that is only run interactively can carry
a syntax error indefinitely — `bash -n` in the pre-commit hook would
have caught it, and does not exist.
**A bare `adb` addresses whatever is attached.** With a second emulator
present (another project's, or a stale `offline` entry from a previous
run), every adb call fails with "more than one device", and
`cmd_install` reported that as *"no device — run 'make
android-emulator' first"* — directly after that had printed "waiting
for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from
`ro.boot.qemu.avd_name`, since serials are assigned in boot order and
the AVD name is the stable identity. Verified with both emulators
running: it selects `yj-test` and installs.
## The phone shell fits, and what it cost to make it fit (2026-08-16)
Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and
390×844 against the real app (`make dev-headless` + Playwright, which
is the tier that can answer this — server mode serves the same document
an Android WebView renders).
**What overflowed, and by how much.** The body was 652px wide in a
360px viewport before any of this. Walking every element and its shadow
roots for a `right` past the viewport named the causes in order:
| element | width | why |
|---|---|---|
| `header.top-bar` | 580 | its children's minimums, summed |
| `search-bar` | 320 | `.search-container { min-width: 200px }` |
| `job-indicator` | 157 | the label, "3 background jobs" |
A `min-width` in a flex row is a *hard* floor — it does not shrink — and
a grid item's implicit minimum is `auto`, i.e. its content. So the
header could not get smaller than the sum of what it held, the body grew
to the header, and `overflow-x: hidden` would then have hidden a third
of the app rather than fitting it. `min-width: 0` on the boxes between
the viewport and the content, plus each component standing its own
non-essential parts down in its own stylesheet, takes 360 → 360 exactly.
At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact.
**So an existing spec now asserts the opposite of what it did**, and
that is the fix landing rather than the test being weakened.
`layout-overflow.spec.ts` used to assert that the 464px of app behind
`overflow: hidden` *could be scrolled to* with a wheel gesture, which
was the remedy available when the shell had one layout. It reflows now,
which is what 1.4.10 asks for; scrolling to the overflow was the
concession.
**And a shared component brings its test handles with it.**
`bottom-nav`'s "More" opens the *existing* `<app-sidebar>` in a drawer —
the whole point being not to write a second list of destinations — but
rendering it unconditionally put a second `data-testid="nav-home"` (and
ten siblings) in the DOM. **30 existing specs failed** with "strict mode
violation: resolved to 2 elements", on a *desktop* viewport where
`bottom-nav` is `display: none` and the drawer can never open. Lazy
rendering fixes it; the component test asserts the absence, because the
failure is invisible from inside the component and appears in files
nobody touched.
Three smaller things worth keeping:
- **A new icon name is a runtime failure, not a build one.** `bars` was
not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it —
the sweep asserts `window.__yjIconMisses` is empty. `node
frontend/scripts/fetch-icons.mjs` re-vendors after adding a line.
- **A `wa-drawer` animates, so a test asserts its events**, not its
`open` property: setting `open = false` starts a hide that has not
finished on the next microtask, and a test reading the property in
between sees the state it is leaving.
- **`update(el)` in the component tier takes two arguments**
(`update(el, {})`), which is only visible from `tsc`, not from a
failing test.
### The local e2e tier was not running the same app CI runs
`requested-badge.spec.ts` failed two of three tests locally while CI was
green, and the reason is worth more than the fix: **`dev-headless.sh`
was the only place that did not neutralise `YJ_CORE_INDEX_URL`.**
`seed-sandbox.sh` and `ci.yml` both point it at `127.0.0.1:1`; the dev
launcher did not, so the app downloaded and built the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and a local `make e2e` then
ran against a world CI never sees.
Found by reading the failure screenshot: the spec had searched Explore
for its fixture album and the page was full of *real* ones — Real
Estate, Arrested Youth, The Yes Album. The staged row was there and
invisible among a million others.
`dev-headless.sh` now defaults the variable to the dead address and
takes an explicit one if you want the real catalog for exploring by
hand. `make e2e` locally: 97 passed / 3 failed before, 100 passed
after.
The second half of the same problem is that **the backend is one shared
process with one database, and specs leave rows in it.**
`explore-shelves` staged its catalog only `IfEmpty`, so a single album
row left behind by `requested-badge` satisfied that gate, the shelves
were drawn from one foreign row, and the artist card the spec clicks did
not exist. It fails on the *second* local run and passes on the first,
which is the least useful order, and never in CI, where every run gets a
fresh `YJ_HOME`.
"Is the catalog empty" was the wrong question; "are my rows there" is
the right one. The staging is unconditional now (`INSERT OR IGNORE`
keyed on the MBID) and the assertion moved from *this insert wrote a
row* to *every fixture row is present* — which is both idempotent and a
stronger check, since an MBID failing `CHECK(length(mbid) = 16)` is
silently dropped by OR IGNORE and would otherwise show up as an empty
page rather than a failed setup.
**Verified: the full suite runs twice against the same app, 100 passed
both times.** That is the property to keep — a spec tier whose second
run differs from its first is a tier that will one day blame the wrong
commit.
## A media query adds no specificity, and dead CSS looks like working CSS (2026-08-16)
Plan 016 B2 phase 2 shipped the full-screen now-playing view, and
checking it with a screenshot found that **phase 1's shell rules had
never applied**.
`index.css` is base rules then component rules, and the phone block had
been inserted in the middle — above the plain `.top-bar` and `.title`
rules it meant to override. A media query is not a specificity boost,
so with equal specificity the *later* declaration wins. Measured at
390px before the fix:
| declared for the phone | actually computed |
|---|---|
| `padding-left: 0.75em` | 32px (the 2em base) |
| `gap: 0.5em` | 16px (base) |
| `font-size: 1.1em` | 24px (the 1.5em base) |
| `grid-template-columns: minmax(0,1fr) auto auto` | `320px 1fr auto` (base) |
After moving the block to the end of the file: 12px, 8px, 17.6px, and
`154px 187px 33px`.
**Nothing failed while they were dead**, which is the part worth
keeping. The phone spec asserts that the shell does not scroll
sideways, and it did not — because the fitting was being done by
`min-width: 0` and by each component's *own* media query, which live in
their own stylesheets and so had no later rule to lose to. The
declarations that did nothing were the cosmetic ones, and no assertion
was ever going to see them. A screenshot did, in about ten seconds.
The file now ends with one phone section, and says why it is last.
### What the same screenshot found about the view itself
The bottom bar was still rendering the mini player *underneath* the
full-screen view — 4em of a 844px phone spent saying exactly what the
view above it says, and invisible to every assertion about either one
(both were correct on their own). `index.css` hides `.bottom-bar` while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from `index.ts`: which view is showing is
already published as an attribute, and a second expression of the same
fact is a second thing to keep in step.
That took the queue button away with it, since that button lives in the
bar — so the view carries its own, toggling the same `open` attribute
on the same panel element.
**And a css`` literal cannot contain a backtick.** A comment reading
"the track size is set on the `wa-slider` inside its shadow root"
terminates the tagged template, and the failure arrives as
`Expected "]" but found "wa"` from the CSS parser, at a line number in
the *comment*. `make css-check` exists for this and named it
immediately.
## The index artifact could not be exported, and the reason is a rule this repo already had (2026-08-16)
`maintain-index` failed on an unrelated push:
```
indexexport: copy rows: SQL logic error: no such column: total_tracks (1)
```
Three minutes in, on the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.
**The cause is the exception that keeps that checkpoint alive.** The
index job's `/cache` is a real `YJ_HOME` that survives between runs, so
`explore_index` there is classified `Cache` and is deliberately *not*
dropped and recreated by `cmd/indexbuild`'s schema repair
(`staleschema.go`). A column added to the schema afterwards is
therefore simply absent from that database — and `total_tracks` was
added by the album-completeness work. The exporter selected it anyway.
**The fix is the rule the importer already follows.**
`artifactHasTotals()` exists precisely because "adding a column to the
importer's SELECT is how you break every artifact already published";
the mirror image — *reading* an index older than the binary — had no
such guard. `sourceColumns()` asks
`pragma_table_info('explore_index', 'main')` and selects a literal `0`
when the column is not there, which is what the column already means by
"the catalog does not say" and what the app already renders as unknown
rather than as incomplete. The destination keeps every column, so an
importer needs no second shape.
So the pattern generalises, and is worth stating once: **any query that
crosses a version boundary in either direction asks the schema rather
than trusting it.** There are now three of these — `artifactStoresText`
(encoding), `artifactHasTotals` (import), `sourceColumns` (export).
Two things about the test are worth keeping.
It reproduces the failure **symptom first**: with the fix removed it
fails with the CI message verbatim, `copy rows: SQL logic error: no
such column: total_tracks (1)`. That was checked, not assumed.
And its first version silently proved nothing. `oldColumns` was
`strings.Replace(catalogColumns, "total_tracks, ", "", 1)` — which
matches *nothing*, because the list is formatted across lines and the
name is followed by a newline rather than a space. So the "old" index
had every current column, the probe correctly said so, and the only
reason this was caught is that the assertion about the probe ran before
the assertion about the export. A fixture built by string surgery on a
formatted constant needs to be whitespace-independent; it filters the
list now.
## Long-press is one document listener, and the header row is a row (2026-08-17)
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
in this app opens from a `contextmenu` event — six components' worth,
bound three different ways (delegated on a virtualizer, per row, per
card). `frontend/src/utils/long-press.ts` is one document-capture
listener installed once from `index.ts`: a touch that holds still for
500 ms dispatches a synthetic `contextmenu` at the touch point, and
**every existing handler runs unchanged**. No component opted in, and
none can forget to.
Four things it has to get right, and each is a way the obvious version
fails:
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
stops at the outermost shadow host. Every menu here is bound inside
one, so a host-targeted event reaches a delegated listener and no
per-row one.
- **A browser that fires its own must win.** Chromium already dispatches
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
during the press cancels ours; one arriving after ours is swallowed at
document capture.
- **Ours is told from theirs by identity** (a `WeakSet`), not by
`isTrusted`. `isTrusted` would work in the app and is untestable — no
test can dispatch a trusted event — so the suppression path would have
been the one thing with no coverage.
- **The click ending the gesture is swallowed**, keyed on the gesture
(cleared by the next `pointerdown`) rather than a time window, or a
quick tap on the menu that just opened is eaten too.
**What cost the time was the assertion, not the code.** The e2e spec
pressed `[role="row"]` — which is the *column header*, and it is the
first one. The gesture fired correctly, the header correctly ignored it,
and the failure looked exactly like a menu that would not open. Found by
probing the running app (`playwright-cli eval`, dispatching the same
pointer events and logging what saw the `contextmenu`), which showed the
event reaching the row's own listener with no menu behind it — i.e. the
handler was refusing it, not missing it. `.track-row` is the selector.
Verified by execution: 8 component tests (real browser, real shadow
boundary, real timings) and 2 e2e specs against the running app, twice
in a row. Not verified: any of it under a real finger on a real
WebView — the pointer events are dispatched, because neither Desktop
Chrome nor Desktop Safari has touch and there is no device tier.
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
first runtime evidence any of the Android work has ever had** — A4
shipped entirely reasoned from source.
**What holds.** Playback survives the screen locking. The MediaSession
notification appears in the status pane *with album art* — which
answers, in one observation, four of the open questions from plan 016:
the foreground service starts, POST_NOTIFICATIONS was granted and the
notification is visible, the session is picked up, and **cover art
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
readable**. The last was the one nobody could argue from documentation.
**Two bugs, and neither is visible from any tier we have.**
*Back did not navigate back.* The scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise — and this app had never touched `history`, so that
was false at every depth and back quit from anywhere. The fix is in the
frontend, not in Java: a navigation is a `history` entry now
(`recordNavigation` in `index.ts`, same URL, the destination in the
entry's state) and `popstate` replays it with `_isBack`. The Java half
needs no change, because the mechanism it already uses is the one we
were failing to feed.
Two rules keep it honest. The **first** navigation replaces the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons go through
`history.back()` rather than popping a stack of their own — `navStack`
is **deleted**, not kept alongside, because two stacks is exactly how
the detail view's own button and the phone's gesture come to disagree
about how far back one press goes. `back-navigation.spec.ts` pins that
invariant.
*The transport was off screen.* **`targetSdk 35` is Android 15, which
lays every app out edge-to-edge**, ignores the deprecated
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
the app a window the size of the screen. The WebView is `match_parent`,
so the page's bottom band — the transport, and on a phone the tab bar —
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
container by `systemBars | displayCutout | ime` and returns the insets
rather than consuming them. The window background goes black to match
the app's own ramp, or the padding shows as a blue-grey band.
**Neither is findable in the browser tier, and that is the lesson worth
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
390x844 renders a shell that fits perfectly while the device cuts 48dp
off the bottom — and `page.goBack()` was never called because nothing in
a desktop shell has a back gesture. The Android tier's own note says
failure there is invisible; this is the milder version, where the app
works and is simply wrong in ways only the platform can show you.
Verified by execution: the APK builds with the Java change; 3 e2e specs
cover the history behaviour, on Chromium locally and WebKit in CI.
Not verified: the insets themselves, which need the next APK on the
owner's phone. What to look for is one thing — the transport and the tab
bar clear of the gesture bar, and the header clear of the status bar.
@@ -1,12 +1,14 @@
# 016 — What Android parity would actually take
> **Status: A1, A2 and A3 are done** (commit "let the app reach the
> user's music"). The direction taken is **option 1, the full
> librarian**: `MANAGE_EXTERNAL_STORAGE` plus an in-app folder browser,
> which keeps the path-keyed model intact. A4 (MediaSession and audio
> focus) and B1/B2 remain. The sections below are kept as written,
> because they are the argument the decision rests on — see "What is
> left" at the end for the current state.
> **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
> plus an in-app folder browser, which keeps the path-keyed model
> intact. B1/B2 remain, both awaiting a decision rather than work. The
> sections below are kept as written, because they are the argument the
> decision rests on — see "What is left" at the end for the current
> state.
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
versioned, and publishes from CI. This is the assessment of what stands
@@ -194,6 +196,63 @@ option 3 if the goal is the least work for the most value. Option 1 is
the only one that answers "feature parity" literally, and it is the one
worth arguing hardest against.
> **Decided:** option 1's *data model* (the librarian keeps its
> filesystem and its scanner — A1 shipped that) with option 2's
> *surface*. The phone is a player over the library this app already
> builds; it does not get every view. The list is below.
## The phone gets a subset (decided)
B2 is not a stylesheet pass and not a second front end either. A view
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
so the phone build is **a different loader table and a different
chrome**, over the same stores.
**In**, because each is something a person does with a phone in their
hand:
- **Home** — the shelves are already a phone-shaped surface.
- **Library browse** — albums, artists, genres. The grids are already
virtualized and card-shaped.
- **Now playing** — which on a phone is a *view*, not a 4em bar.
- **The queue.**
- **Search** — the header box, scoped as it already is.
- **Playlists**, including smart ones, as lists to play rather than to
edit.
**Out**, and each for a reason rather than by omission:
- **Autotag** — the review UI is a wide table and the action rewrites
files on disk; B3 has not been verified even as *possible* yet.
- **Downloads** — two tab panels of client configuration.
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
the last thing to earn a phone's storage.
- **Settings** — not the page. The phone needs a handful of settings
(theme, the library folder, playback) and not the 93 controls the
desktop page carries.
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
has no keyboard and no resizable columns, and the jobs indicator is
enough.
What the shell has to lose, from the audit at the top of this section:
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
bar over the five things above), hover as a route to anything,
right-click as the only route to a context menu (long-press is the
gesture), and ctrl/shift multi-select.
One rule for the work: **no view forks.** A phone layout that copies a
view's template is two templates to fix every bug in. Where a view
cannot serve both, the split belongs at the chunk boundary that already
exists.
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
inside the drawer means reusing its `data-testid`s too, and a second
copy standing by in the DOM broke 30 specs that had nothing to do with
the phone. The rule holds — a second list of destinations would be
worse — but a shared component must be rendered only when it is wanted,
and the guard belongs in a test that names the reason.
## What is worth doing regardless of that decision
Cheap, independently useful, and each unblocks measurement:
@@ -212,37 +271,114 @@ Cheap, independently useful, and each unblocks measurement:
behaviour.
## What is left (updated after A1-A3)
## What is left (updated after A4)
**A4, playback that survives the screen locking.** The manifest and the
service are typed `mediaPlayback` now and the permission is declared,
so the foundation is in place; what is missing is a `MediaSession`, a
transport notification and audio-focus handling. The plumbing for it
exists and needs no new JNI: Go can call
`application.Android.StartForegroundService(json)` (exported by Wails),
and Java can call `WailsBridge.emitEvent(name, json)` back into the
application event bus, which Go subscribes to. So the shape is a JSON
payload of title/artist/state going out and transport commands coming
back, with `backend/mediacontrols` gaining an Android handler beside
the MPRIS one — the interface it already defines is the right shape.
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
beside the MPRIS one, and the Java half is
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
transport notification and audio focus. It needed no new JNI and no new
Gradle dependency — `application.Android.StartForegroundService(json)`
going out, `WailsBridge.emitEvent` → the application event bus coming
back, and the platform `android.media.session` API rather than
androidx.media, which minSdk 21 makes available anyway.
Audio focus is the half that is easy to forget and the more important
one: pause on a phone call, duck for a notification, pause on headphone
unplug. `oto` will happily keep writing to a stream nobody can hear.
Four decisions in it are worth keeping:
**B1, the x86_64 half of the APK**, which cannot run on any Android
because of the modernc `lstat` seccomp trap. Still undecided; dropping
it is a five-minute change that halves the artifact.
- **Ducking is a player concept, not a volume change.**
`Player.SetDuck` re-applies the *user's* level with an attenuation
offset, so `getUserVolume` still reports what the user chose and
nothing is persisted or emitted. A duck that wrote through to the
volume would let one notification tone permanently turn the music
down.
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
the app itself and sends no `CAN_DUCK` focus change, so asking to be
told instead (`setWillPauseWhenDucked`) would mean pausing for every
notification tone, and doing both would attenuate twice.
- **An unchanged payload is not an event here either.** Every push
crosses JNI and re-delivers an Intent, and the player pushes state on
several paths that can agree.
- **After the first start, updates use `startService`.** From Android
12 an app in the background may not *start* a foreground service, but
it may keep delivering intents to one it already has — which is every
track change with the screen off.
**B2, the desktop shell.** Untouched and the largest remaining piece.
The contract with Java — the payload keys, the state words, the command
names — is in `androidpayload.go`, deliberately *without* the `android`
build tag, so `go test` exercises it on every platform. Everything left
in `android.go` is untested by construction: it compiles only under a
cross-compiler and runs only on a phone.
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
places had to agree — `abiFilters`, the Makefile's `android:package`
(or Go still compiles a library Gradle then discards) and the
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
are done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before.
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
and volume went. A detail view, so Back pops the nav stack; it
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
listener, installed once from `index.ts`, which turns a 500 ms
stationary touch into a synthetic `contextmenu` at the touch point.
Every menu in the app opens from that event, so all six components
gained the gesture without one of them changing — which is the same
argument `ContextMenuController` rests on, one layer lower. The
details that are not obvious are in `NOTES.md` (2026-08-17); the one
worth repeating is that ours is told from the browser's own
long-press event by **identity**, not `isTrusted`, because a test
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
### What A1-A3 did not answer
### What the first device run answered (2026-08-17)
A4 **works**: playback survives the screen locking, and the transport
notification appears with cover art — which also settles the service's
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
the lock-screen session in one observation. Everything below in "what
none of section A answered" was written before this and is now answered
except the OEM permission-flow variance.
It also found two faults no browser tier can see, both fixed and both
awaiting the next APK for confirmation (`NOTES.md`, same date):
- **Back quit the app from any depth.** The scaffold asks
`webView.canGoBack()`; the frontend had never used `history`. A
navigation is a history entry now, and `navStack` is gone rather than
kept beside it.
- **The transport was under the gesture bar.** `targetSdk 35` is
edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads
by `systemBars | displayCutout | ime`.
The standing item is unchanged in kind: **B3 (tag writing) and the
permission flow still need a device**, and so does confirming these two.
### What none of section A answered
Nothing here has been observed on a device. The permission flow in
particular is the kind of thing that behaves differently across OEM
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
implemented inconsistently, which is why there is a fallback to the
global list, and neither path has been exercised.
A4 adds its own list of things only a device can answer, and they are
the likely first failures: whether the notification appears at all
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
user who declines gets a service with an invisible notification),
whether audio focus arrives while `oto`/oboe holds the output, whether
the lock screen picks up the session, and whether cover art decoded
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
+129 -3
View File
@@ -343,7 +343,26 @@ rather than renaming them.
came about.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols`MPRIS integration on Linux via D-Bus.
- `mediacontrols`OS media controls behind one `Handler`: MPRIS over
D-Bus on desktop Linux, a MediaSession on Android, a no-op stub
elsewhere. The split is by build tag and `android` implies `linux`,
so the three files read `linux && !android`, `android` and `!linux`.
Its Android half needs no JNI beyond what Wails exports — a JSON
payload out through `application.Android.StartForegroundService`, a
command event back through `WailsBridge.emitEvent` — and the Java it
talks to is `build/android/.../WailsForegroundService.java`. That
contract (payload keys, state words, command names) is in
`androidpayload.go` **without** the build tag, because a tagged file
is compiled by nothing `make lint` or `make test` runs and is
untestable off a phone.
`OnDuck` is the one callback MPRIS does not use: Android asks for
attenuation rather than a pause when something short needs the
output. `Player.SetDuck` keeps it as an offset on top of the user's
level rather than writing through to the volume, so it cannot
accumulate and nothing persists or emits a level the user did not
choose — and it only ever fires below API 26, where the framework
does not already duck the app itself.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below.
Its **shelves** (`shelves.go`) are the page Explore shows before
@@ -664,6 +683,27 @@ moment it is most needed is the likeliest moment loading one fails.
`first-run-wizard` and the startup chrome are eager for the ordinary
reason — they are the first paint.
**A navigation is a history entry, and that is the whole back stack.**
`index.ts` records each navigation with `pushState` (same URL — the app
has no routes, and a path a reload cannot resolve is worse than none)
and replays `popstate` with `_isBack`. It exists for Android, whose back
button is not a key the page can bind: the scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise, so an app that never touched `history` quit from any
depth — which is what a device reported. Hooking the platform's own
mechanism rather than adding a JNI callback is also what makes it
testable in a browser (`page.goBack()`), and the Java half needed no
change at all.
Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press
means.
**A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so
@@ -819,6 +859,20 @@ against the real components:
moving focus without setting it leaves the highlight on whichever
item the mouse last touched.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
that ends the gesture is swallowed, keyed on the gesture rather than on
a time window so the first tap on the menu it opened is not eaten too.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -989,6 +1043,65 @@ this app promises, no scrollbar appears. Note that `overflow: hidden`
still permits *programmatic* scrolling, so a probe that sets
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
**Below 600px it reflows instead, and that is the phone.** The sideways
scroll above was the concession available while the shell had one
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
sidebar column, `<bottom-nav>` takes over as the primary navigation,
the header's controls shrink or stand down, and the shell measures
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
the answer there is a narrower sidebar, which is still a sidebar.
Three rules in it are load-bearing, and the second cost 30 specs.
**A grid item's implicit minimum is its content**, so one child that
insists on 580px makes the *body* 580px wide inside a 360px viewport
and `overflow-x: hidden` then hides a third of the app rather than
fitting it. Every box between the viewport and the content that must
shrink carries `min-width: 0`, and the things that cannot shrink say so
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
label, `audio-player`'s seek bar and volume. A media query inside a
shadow root is answered by the viewport, so a component states what it
drops at phone width itself rather than the shell reaching in.
**A duplicated component duplicates its handles.** `bottom-nav`'s
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
listing the destinations again — but rendering it unconditionally put a
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
specs failed with "strict mode violation: resolved to 2 elements" on a
desktop viewport where the element is not even visible. It renders only
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
before that.
**The tab bar is four destinations and a way to the rest.** Three to
five is where touch targets stop being thumb-sized; eleven over 360px
is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**The phone section of `index.css` is last on purpose.** A media query
adds no specificity, so a `@media (max-width: 599px)` block placed
above the plain rules it overrides loses to them — which is how phase 1
shipped a header that kept its 2em gutters and 24px title on a 390px
phone with every declaration dead and nothing failing. The shell fitted
anyway, because the fitting is done by `min-width: 0` and by each
component's own media query, which live in their own stylesheets and
have no later rule to lose to. Cosmetic declarations are exactly what
no assertion sees; a screenshot found it.
**`<now-playing-view>` is where the seek bar and volume went.** It is a
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
a tab you cannot leave by pressing again is not a tab), reached from a
phone-only button over the mini player's art, and it **composes the
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
than reimplementing them. While it is up, `index.css` hides the bottom
bar through `body:has(#main-content[data-active-view="now-playing"])`
the active view is already published as an attribute, and a class
toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
@@ -1774,8 +1887,9 @@ publish (`arch-package`, `homebrew-formula`, `index-artifact`,
deciding whether a push was healthy.
**`android-apk.yml` is the only one keyed on a tag and the only one
that can lose something irrecoverable.** It builds the signed fat APK
on every `v*` tag and publishes it to the *generic* registry, which is
that can lose something irrecoverable.** It builds the signed
`arm64-v8a` APK (the only ABI Android can run this app on — see
`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is
readable without credentials — the reason Obtainium can poll a plain
URL. Android refuses to update an app whose signing certificate
changed, and the only remedy is an uninstall that takes the user's
@@ -1881,6 +1995,18 @@ like source** — it was generated once into a scratch directory and
copied across (plan 015), it carries one deliberate edit to its
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
still not carried and its `includes:` entry is still dropped.
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
not leave that to the theme.** Android 15 lays every app out
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
scaffold's theme sets, and the WebView is `match_parent` — so the page's
bottom band, which on a phone is the transport *and* the tab bar, was
drawn under the gesture bar. `applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them; the window background is black to match the app's own
ramp, since that padding is what shows through. **No browser tier can
see this class of fault** — a viewport has no system bars, so the phone
specs render a shell that fits at the moment the device is clipping it.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
+7 -2
View File
@@ -52,8 +52,13 @@ ANDROID_SDK ?= $(HOME)/Android/Sdk
ANDROID_NDK ?= /opt/android-ndk
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
android: build-frontend ## Build the fat APK (arm64 + x86_64) into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package:fat
# `package`, not `package:fat`: x86_64 Android cannot run this app at
# all (modernc's raw lstat vs Android's seccomp -- see
# android-tier.md), so the second ABI was ~31 MB that could not run
# anywhere. app/build.gradle's abiFilters says the same thing to
# Gradle; both have to agree or the .so is built and then dropped.
android: build-frontend ## Build the arm64 APK into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
+10 -5
View File
@@ -484,20 +484,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
// Initialize OS media controls (MPRIS on desktop Linux, a
// MediaSession on Android, no-op elsewhere). The callbacks are the
// same on every platform; only what delivers them differs.
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play,
OnPause: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Pause failed", "err", err)
yj.logger.Warn("Media controls Pause failed", "err", err)
}
},
OnPlayPause: func() {
if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
yj.logger.Warn(
"Media controls PlayPause(pause) failed", "err", err,
)
}
} else {
yj.queue.Play()
@@ -505,14 +509,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
},
OnStop: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Stop failed", "err", err)
yj.logger.Warn("Media controls Stop failed", "err", err)
}
},
OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("MPRIS Seek failed", "err", err)
yj.logger.Warn("Media controls Seek failed", "err", err)
}
},
OnVolume: func(vol float64) {
@@ -522,6 +526,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
),
)
},
OnDuck: yj.player.SetDuck,
}); err != nil {
yj.logger.Error(
"Failed to initialize media controls",
+215
View File
@@ -0,0 +1,215 @@
//go:build android
// Android's answer to MPRIS is a MediaSession, and reaching it needs no
// new JNI: Wails exports application.Android.StartForegroundService(json)
// going out, and Java's WailsBridge.emitEvent lands on the application
// event bus coming back. So this handler is one JSON payload pushed to
// the foreground service and one command event read from it. The Java
// half is
// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
// and the payload keys below are its contract.
package mediacontrols
import (
"errors"
"log/slog"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
)
// commandEvent is the event name the Java side emits transport
// commands on. It is a plain string on both sides; changing it means
// changing WailsForegroundService too.
const commandEvent = "yj:media:command"
var errNoApplication = errors.New(
"no running application to attach media controls to",
)
// androidHandler drives the media notification, the lock-screen
// transport and audio focus through the foreground service.
type androidHandler struct {
logger *slog.Logger
mu sync.Mutex
callbacks Callbacks
meta Metadata
state PlaybackState
positionSec int
// running tracks whether the foreground service has been started.
// Android 12+ forbids starting one from the background, so it is
// started when playback starts -- a user action, in a visible app
// -- and stopped only when playback stops, which is what keeps
// queue auto-advance working with the screen off.
running bool
// lastPayload is the last JSON sent. An unchanged payload is not
// an event here either: every push crosses JNI and re-delivers an
// Intent, and the player pushes state on several paths that can
// agree.
lastPayload string
unsubscribe func()
}
// NewHandler returns the Android media-session handler.
func NewHandler(logger *slog.Logger) Handler {
return &androidHandler{logger: logger, state: StateStopped}
}
// Init subscribes to the transport commands the Java side emits.
func (a *androidHandler) Init(callbacks Callbacks) error {
app := application.Get()
if app == nil {
return errNoApplication
}
a.mu.Lock()
a.callbacks = callbacks
a.mu.Unlock()
a.unsubscribe = app.Event.On(commandEvent, a.onCommand)
return nil
}
// onCommand dispatches one transport command from the notification,
// the lock screen, a headset button or an audio-focus change.
//
// Every callback runs on its own goroutine, for the reason the MPRIS
// handler does the same: they take the player and queue mutexes, and
// this runs on the event processor's dispatch goroutine.
func (a *androidHandler) onCommand(event *application.CustomEvent) {
data, ok := event.Data.(map[string]any)
if !ok {
return
}
command := parseMediaCommand(data)
a.mu.Lock()
cb := a.callbacks
a.mu.Unlock()
switch command.name {
case cmdPlay:
run(cb.OnPlay)
case cmdPause:
run(cb.OnPause)
case cmdPlayPause:
run(cb.OnPlayPause)
case cmdStop:
run(cb.OnStop)
case cmdNext:
run(cb.OnNext)
case cmdPrevious:
run(cb.OnPrevious)
case cmdSeek:
if cb.OnSeek != nil {
go cb.OnSeek(command.positionSec)
}
case cmdDuck:
if cb.OnDuck != nil {
go cb.OnDuck(command.duck)
}
default:
a.logger.Warn("Unknown media command", "command", command.name)
}
}
// run invokes a callback on its own goroutine, tolerating a nil one.
func run(fn func()) {
if fn != nil {
go fn()
}
}
// UpdateMetadata pushes new track details to the notification.
func (a *androidHandler) UpdateMetadata(meta Metadata) {
a.mu.Lock()
defer a.mu.Unlock()
a.meta = meta
a.push()
}
// UpdatePlaybackState pushes the state and a fresh position anchor;
// the MediaSession interpolates from there while playing.
func (a *androidHandler) UpdatePlaybackState(
state PlaybackState,
positionSec int,
) {
a.mu.Lock()
defer a.mu.Unlock()
a.state = state
a.positionSec = positionSec
a.push()
}
// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has
// no separate seeked signal -- a new state with a new position is the
// whole mechanism.
func (a *androidHandler) NotifySeek(positionSec int) {
a.mu.Lock()
defer a.mu.Unlock()
a.positionSec = positionSec
a.push()
}
// UpdateVolume is deliberately a no-op. Android's volume keys act on
// the media stream, which the OS owns; an app that also moved its own
// volume in response would move it twice.
func (a *androidHandler) UpdateVolume(_ float64) {}
// Close stops the service and drops the command subscription.
func (a *androidHandler) Close() {
a.mu.Lock()
defer a.mu.Unlock()
if a.unsubscribe != nil {
a.unsubscribe()
a.unsubscribe = nil
}
if a.running {
application.Android.StopForegroundService()
a.running = false
}
}
// push sends the current state to the Java side, if it has changed.
// The caller holds a.mu.
func (a *androidHandler) push() {
if a.state == StateStopped {
// Nothing is playing, so nothing justifies an ongoing
// notification or the process staying alive.
if a.running {
application.Android.StopForegroundService()
a.running = false
a.lastPayload = ""
}
return
}
payload, err := mediaPayload(a.meta, a.state, a.positionSec)
if err != nil {
a.logger.Error("Failed to encode media payload", "err", err)
return
}
if payload == a.lastPayload {
return
}
a.lastPayload = payload
a.running = true
application.Android.StartForegroundService(payload)
}
+85
View File
@@ -0,0 +1,85 @@
// The contract between the Android handler and the Java
// WailsForegroundService is two JSON documents -- one pushed out with
// the track and the state, one read back with a transport command --
// and neither side can check the other.
//
// It lives here, *without* the android build tag, so that `go test` on
// any platform exercises it. android.go itself can only be compiled by
// a cross-compiler and only be run by a phone, so anything left in it
// is untested by construction; this is the half worth not leaving
// there.
package mediacontrols
import "encoding/json"
// Media command names, as the Java side spells them.
const (
cmdPlay = "play"
cmdPause = "pause"
cmdPlayPause = "playpause"
cmdStop = "stop"
cmdNext = "next"
cmdPrevious = "previous"
cmdSeek = "seek"
cmdDuck = "duck"
)
// stateNames are what the payload's "state" key carries. Words rather
// than the PlaybackState integers, because the Java side reads them as
// JSON and a renumbered constant would silently mean something else
// there.
var stateNames = map[PlaybackState]string{
StateStopped: "stopped",
StatePlaying: "playing",
StatePaused: "paused",
}
// mediaCommand is one transport command from the notification, the
// lock screen, a headset button or an audio-focus change.
type mediaCommand struct {
name string
positionSec int
duck bool
}
// mediaPayload encodes the state the notification and MediaSession
// render.
func mediaPayload(
meta Metadata,
state PlaybackState,
positionSec int,
) (string, error) {
payload, err := json.Marshal(map[string]any{
"title": meta.Title,
"artist": meta.Artist,
"album": meta.Album,
"artPath": meta.ArtFilePath,
"durationSec": meta.DurationSec,
"positionSec": positionSec,
"state": stateNames[state],
})
if err != nil {
return "", err
}
return string(payload), nil
}
// parseMediaCommand reads one command out of the event payload.
//
// The numbers arrive as float64 because they came through
// encoding/json as an untyped document -- asserting int here is the
// way a seek silently becomes a seek to zero.
func parseMediaCommand(data map[string]any) mediaCommand {
cmd := mediaCommand{}
cmd.name, _ = data["command"].(string)
if position, ok := data["positionSec"].(float64); ok {
cmd.positionSec = int(position)
}
cmd.duck, _ = data["on"].(bool)
return cmd
}
@@ -0,0 +1,165 @@
package mediacontrols
import (
"encoding/json"
"testing"
)
// TestMediaPayloadKeys pins the document the Java side parses. The
// keys are the contract: a rename here is silently a track with no
// title on the lock screen, because WailsForegroundService reads them
// with optString and a missing key is simply "".
func TestMediaPayloadKeys(t *testing.T) {
t.Parallel()
payload, err := mediaPayload(Metadata{
Title: "Tideline",
Artist: "Sea Change",
Album: "Ebb",
ArtFilePath: "/covers/ebb_lg.jpg",
DurationSec: 245,
}, StatePlaying, 30)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
want := map[string]any{
"title": "Tideline",
"artist": "Sea Change",
"album": "Ebb",
"artPath": "/covers/ebb_lg.jpg",
"durationSec": float64(245),
"positionSec": float64(30),
"state": "playing",
}
if len(got) != len(want) {
t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload)
}
for key, expected := range want {
if got[key] != expected {
t.Errorf("payload[%q] = %v, want %v", key, got[key], expected)
}
}
}
// TestMediaPayloadStateNames covers the one value the Java side
// compares against a literal.
func TestMediaPayloadStateNames(t *testing.T) {
t.Parallel()
tests := []struct {
state PlaybackState
want string
}{
{StatePlaying, "playing"},
{StatePaused, "paused"},
{StateStopped, "stopped"},
}
for _, tt := range tests {
payload, err := mediaPayload(Metadata{}, tt.state, 0)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got struct {
State string `json:"state"`
}
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
if got.State != tt.want {
t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want)
}
}
}
// TestParseMediaCommand covers the direction that arrives untyped.
// The seek case is the one with teeth: the position crosses as a JSON
// number, so it is a float64 in the map and an int assertion would
// make every seek a seek to zero.
func TestParseMediaCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]any
want mediaCommand
}{
{
name: "play",
data: map[string]any{"command": "play"},
want: mediaCommand{name: cmdPlay},
},
{
name: "seek carries a position",
data: map[string]any{"command": "seek", "positionSec": float64(93)},
want: mediaCommand{name: cmdSeek, positionSec: 93},
},
{
name: "duck carries a flag",
data: map[string]any{"command": "duck", "on": true},
want: mediaCommand{name: cmdDuck, duck: true},
},
{
name: "unduck",
data: map[string]any{"command": "duck", "on": false},
want: mediaCommand{name: cmdDuck},
},
{
name: "a command with nothing in it is not a panic",
data: map[string]any{},
want: mediaCommand{},
},
{
name: "wrongly typed fields fall back to zero",
data: map[string]any{"command": "seek", "positionSec": "93"},
want: mediaCommand{name: cmdSeek},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := parseMediaCommand(tt.data); got != tt.want {
t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want)
}
})
}
}
// TestMediaCommandNamesAreWhatJavaSends is a spelling check against
// the Java side, which builds these strings by hand. It is a list, not
// a mechanism: nothing can reach across into the .java file, so the
// point is that changing one of these constants fails a test that
// names the file to change with it.
//
// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java.
func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) {
t.Parallel()
want := []string{
"play", "pause", "playpause", "stop",
"next", "previous", "seek", "duck",
}
got := []string{
cmdPlay, cmdPause, cmdPlayPause, cmdStop,
cmdNext, cmdPrevious, cmdSeek, cmdDuck,
}
for i, name := range want {
if got[i] != name {
t.Errorf("command %d = %q, want %q", i, got[i], name)
}
}
}
+7
View File
@@ -34,6 +34,13 @@ type Callbacks struct {
OnPrevious func()
OnSeek func(positionSec int)
OnVolume func(volume float64) // 0.01.0 linear scale.
// OnDuck asks for playback to be attenuated (true) or restored
// (false) without changing the user's volume. Android alone sends
// it, and only below API 26 -- from Oreo the audio framework ducks
// the app itself and reports no such focus change, so doing both
// would attenuate twice.
OnDuck func(ducked bool)
}
// Handler manages the OS media control integration.
+5 -6
View File
@@ -1,10 +1,9 @@
//go:build !linux || android
//go:build !linux
// Android is covered here rather than by mpris_linux.go: it satisfies
// the `linux` tag but has no D-Bus session bus. Its real equivalent is
// a MediaSession, which is Java-side work and not yet built -- so for
// now the app simply has no lock-screen transport there, which is a
// missing feature rather than a broken one.
// Windows and macOS have no media-control integration yet. `!linux`
// covers Android too without naming it, since `android` implies the
// `linux` tag -- android.go claims it, mpris_linux.go excludes it, and
// this file is left with the platforms neither wants.
package mediacontrols
+39 -2
View File
@@ -56,6 +56,13 @@ type Player struct {
trackChangeID uint64
mediaControls mediacontrols.Handler
// duckAmount is the attenuation currently applied on top of the
// user's volume, in the same base-2 exponent effects.Volume uses.
// It is deliberately not persisted and emits no VolumeChanged: a
// duck is something the OS did for the length of a notification,
// not something the user chose.
duckAmount float64
// 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
@@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
speaker.Lock()
volume := clampVolume(desiredVolume)
p.volume.Volume = float64(volume.ToVolume())
p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount
p.volume.Silent = volume == MinUserVol
speaker.Unlock()
}
// SetDuck attenuates playback (or restores it) without changing the
// user's volume, for an OS that has asked us to get out of the way of
// something short -- a navigation prompt, a notification tone.
//
// It re-applies the *user's* level through setVolumeLocked rather than
// nudging the effect directly, so the offset cannot accumulate across
// repeated ducks, and it neither emits nor persists: the level the user
// set has not changed and the UI must not claim it has.
//
//wails:ignore // driven by OS audio focus, not by the frontend.
func (p *Player) SetDuck(ducked bool) {
p.mu.Lock()
defer p.mu.Unlock()
amount := 0.0
if ducked {
amount = duckAttenuation
}
if p.volume == nil || amount == p.duckAmount {
return
}
current := p.getUserVolume()
p.duckAmount = amount
p.setVolumeLocked(current)
}
// ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock()
@@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
}
func (p *Player) getUserVolume() UserVolume {
return Volume(p.volume.Volume).ToUserVolume()
// Undo any duck, so every caller -- the event, the persisted
// state, a relative change -- sees the level the user chose.
return Volume(p.volume.Volume + p.duckAmount).ToUserVolume()
}
// Muted reports whether playback is currently silenced.
+6
View File
@@ -19,6 +19,12 @@ const (
MaxVol Volume = 0
)
// duckAttenuation is how far playback drops when the OS asks us to
// duck, on the same base-2 exponent scale: two steps is a quarter of
// the amplitude (-12 dB), which is audible under a spoken notification
// without sounding like a pause.
const duckAttenuation = 2.0
// ToVolume converts user volume to internal player volume.
func (oldVol UserVolume) ToVolume() Volume {
var newVol Volume
+60
View File
@@ -1,9 +1,12 @@
package player
import (
"log/slog"
"math"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/mediacontrols"
)
@@ -202,3 +205,60 @@ func TestStateToMediaControls(t *testing.T) {
})
}
}
// TestSetDuck covers the property the duck rests on: the attenuation
// is applied to the output and is invisible to everything that asks
// what the volume is -- the event, the persisted state, a relative
// change. Getting that wrong would let one notification tone
// permanently rewrite the user's volume.
func TestSetDuck(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(80)
unducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= unducked {
t.Errorf(
"ducked output volume = %v, want less than %v",
p.volume.Volume, unducked,
)
}
if got := p.getUserVolume(); got != 80 {
t.Errorf("user volume while ducked = %d, want 80", got)
}
// A second duck must not stack: the offset is re-applied to the
// user's level, never subtracted again from the current output.
ducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume != ducked {
t.Errorf(
"duck applied twice = %v, want %v", p.volume.Volume, ducked,
)
}
// Changing the volume while ducked keeps the attenuation.
p.setVolumeLocked(60)
if got := p.getUserVolume(); got != 60 {
t.Errorf("user volume set while ducked = %d, want 60", got)
}
if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want {
t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want)
}
p.SetDuck(false)
if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want)
}
}
+14 -3
View File
@@ -40,9 +40,21 @@ android {
versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")
versionName System.getenv("YJ_VERSION") ?: "0.0.0"
// Configure supported ABIs
// **arm64 only, and x86_64 is not a gap.** `modernc.org/libc`'s
// Xlstat64 issues a raw lstat syscall on linux/amd64, which
// Android's seccomp policy forbids (bionic never issues it), so
// the process takes SIGSYS the first time anything touches the
// database -- which for this app is startup. That is every
// x86_64 Android, emulators and x86 Chromebooks alike, not just
// some. arm64 is structurally unaffected: the architecture has
// no lstat syscall at all, so modernc routes through fstatat.
//
// So the second ABI was ~31 MB of an artifact that could not run
// anywhere. If modernc fixes it, adding 'x86_64' back here and
// to the native-code assertion in android-apk.yml is the whole
// change.
ndk {
abiFilters 'arm64-v8a', 'x86_64'
abiFilters 'arm64-v8a'
}
}
@@ -93,7 +105,6 @@ android {
packagingOptions {
// Don't strip Go symbols in debug builds
doNotStrip '*/arm64-v8a/libwails.so'
doNotStrip '*/x86_64/libwails.so'
}
}
@@ -31,9 +31,16 @@ import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.FileProvider;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.webkit.WebViewAssetLoader;
import org.json.JSONObject;
@@ -88,6 +95,10 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Before anything renders: the page is laid out inside the
// window, and on Android 15 the window is the whole screen.
applyWindowInsets();
// Initialize the native Go library
bridge = new WailsBridge(this);
bridge.initialize();
@@ -892,8 +903,61 @@ public class MainActivity extends AppCompatActivity {
}
}
/**
* Keep the web content inside the safe area.
*
* <p>targetSdk 35 is Android 15, which lays every app out
* edge-to-edge and ignores the {@code statusBarColor} and
* {@code navigationBarColor} this app's theme still sets. The
* WebView is {@code match_parent}, so the page's bottom band -- the
* transport and, on a phone, the tab bar -- was drawn underneath the
* gesture bar and reported from a device as "I can't see the
* playback controls, they seem to be off screen".
*
* <p>No web-tier test can see this: a browser viewport has no system
* bars, so the phone specs at 390x844 render a shell that fits
* while the device does not.
*
* <p>The insets are applied as padding and the window insets are
* returned rather than consumed, so the WebView is laid out inside
* them. {@code ime()} is in the mask because the same reasoning
* covers the keyboard: a focused search box that the keyboard
* covers is the same bug one surface over.
*/
private void applyWindowInsets() {
final View container = findViewById(R.id.main_container);
if (container == null) {
return;
}
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
Insets insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout()
| WindowInsetsCompat.Type.ime());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsets;
});
// The padded band shows the window background, which is dark
// (this app's own default ramp is black), so the system's icons
// have to be the light set or they vanish into it. The theme is
// DayNight and would otherwise ask for dark icons in light mode.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
@Override
public void onBackPressed() {
// The frontend records every navigation as a history entry, so
// this is the app's own back stack: `canGoBack()` is false only
// at the launch entry, which is where back should leave.
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
@@ -81,6 +81,9 @@ public class WailsBridge {
System.loadLibrary("wails");
}
/** The live bridge, for in-process components that are not given one. */
private static volatile WailsBridge instance;
private final Activity activity;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private WebView webView;
@@ -122,6 +125,7 @@ public class WailsBridge {
public WailsBridge(Activity activity) {
this.activity = activity;
instance = this;
}
/**
@@ -210,6 +214,19 @@ public class WailsBridge {
if (initialized) nativeEmitEvent(name, json);
}
/**
* Emit an event from a component that holds no bridge reference —
* {@link WailsForegroundService}, which Android constructs itself. It is a
* static hop rather than a binder because the service runs in this same
* process; before the bridge exists (or after it is gone) the event is
* dropped, which is the same thing {@link #emitEvent} does when the native
* library has not been initialized.
*/
public static void emitFromService(String name, String json) {
WailsBridge b = instance;
if (b != null) b.emitEvent(name, json);
}
/**
* Serve an asset from the Go asset server
*/
@@ -1193,7 +1210,16 @@ public class WailsBridge {
i.setAction(WailsForegroundService.ACTION_START);
i.putExtra("title", title);
i.putExtra("text", text);
ContextCompat.startForegroundService(activity, i);
// The whole document, for the media service: seven extras
// would be seven chances for the two sides to disagree about
// a key, and the service already has to parse JSON for the
// fields the scaffold's title/text pair cannot carry.
i.putExtra("payload", json);
if (WailsForegroundService.running) {
activity.startService(i);
} else {
ContextCompat.startForegroundService(activity, i);
}
emitEvent("android:foregroundService", "{\"running\":true}");
} catch (Exception e) {
Log.e(TAG, "startForegroundService failed", e);
@@ -4,71 +4,545 @@ import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.media.MediaMetadata;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* A minimal started foreground service. It does no work of its own — its purpose
* is to keep the app's process alive (with the required ongoing notification) so
* the developer's Go goroutines keep running while the app is backgrounded,
* which Android would otherwise be free to kill. Start it from
* {@link WailsBridge#startForegroundService(String)} and stop it with
* {@link WailsBridge#stopForegroundService()}.
* The foreground service that keeps playback alive with the screen off, and
* the app's whole media-control surface: a {@link MediaSession} for the lock
* screen and headset buttons, a transport notification, and audio focus.
*
* <p>The scaffold shipped this as a generic "keep the process alive" service
* typed {@code dataSync}. YellowJacket's reason for staying alive in the
* background is that a song is playing, so it is {@code mediaPlayback} — the
* manifest and {@code startForeground} must agree on that or the call throws.
*
* <p>It is driven entirely from Go. {@code backend/mediacontrols/android.go}
* pushes a JSON payload through
* {@link WailsBridge#startForegroundService(String)}, and every command the
* user gives here — a notification button, the lock screen, a headset, or the
* OS taking audio focus away — goes back the other way as a
* {@code yj:media:command} event. Nothing about playback is decided here: this
* class renders state and reports intent.
*/
public class WailsForegroundService extends android.app.Service {
public static final String ACTION_START = "com.wails.app.FGS_START";
private static final String CHANNEL_ID = "wails_foreground";
// Transport actions, delivered to ourselves by the notification's
// PendingIntents. getService rather than a broadcast: a receiver would
// have to be exported or registered, and this service is already the
// thing that has to be running for any of them to be meaningful.
private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY";
private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE";
private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT";
private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS";
private static final String TAG = "WailsMedia";
private static final String CHANNEL_ID = "yellowjacket_playback";
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
private static final String COMMAND_EVENT = "yj:media:command";
/** Cover art is decoded down to this, which is larger than any lock screen. */
private static final int ART_MAX_PX = 512;
/**
* Whether an instance is alive. {@link WailsBridge} reads it to decide
* between startForegroundService and startService: from Android 12 an app
* in the background may not <em>start</em> a foreground service, but it
* may go on delivering intents to one it already has — and every update
* after the first (a track change with the screen off, most of them) is
* exactly that case.
*/
static volatile boolean running = false;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ExecutorService artExecutor = Executors.newSingleThreadExecutor();
private MediaSession session;
private AudioManager audioManager;
private AudioFocusRequest focusRequest; // API 26+ only.
private AudioManager.OnAudioFocusChangeListener focusListener;
private String title = "";
private String artist = "";
private String album = "";
private String artPath = "";
private long durationMs = 0;
private long positionMs = 0;
private boolean playing = false;
private Bitmap art;
private boolean hasFocus = false;
/**
* Whether *we* paused because focus went away. Only then does regaining it
* resume: a user who paused during a phone call did not ask us to start
* again when it ended.
*/
private boolean pausedByFocusLoss = false;
private boolean noisyRegistered = false;
/** Headphones pulled out. Anything else and the room hears the album. */
private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
emitCommand("pause");
}
}
};
@Override
public void onCreate() {
super.onCreate();
running = true;
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
createChannel();
createSession();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String title = "Wails";
String text = "Running in the background";
if (intent != null) {
if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title");
if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text");
String action = intent == null ? null : intent.getAction();
if (ACTION_PLAY.equals(action)) {
emitCommand("play");
} else if (ACTION_PAUSE.equals(action)) {
emitCommand("pause");
} else if (ACTION_NEXT.equals(action)) {
emitCommand("next");
} else if (ACTION_PREVIOUS.equals(action)) {
emitCommand("previous");
} else if (intent != null) {
applyPayload(intent);
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(ch);
// Unconditionally, on every path: a service started with
// startForegroundService that returns from onStartCommand without
// calling startForeground is killed with a
// ForegroundServiceDidNotStartInTimeException.
goForeground();
return START_STICKY;
}
/**
* Read the state Go pushed. "payload" is the whole JSON document; the
* title/text extras are the scaffold's original contract and are kept as a
* fallback so a non-media caller still gets a sensible notification.
*/
private void applyPayload(Intent intent) {
String payload = intent.getStringExtra("payload");
if (payload == null || payload.isEmpty()) {
if (intent.getStringExtra("title") != null) {
title = intent.getStringExtra("title");
}
if (intent.getStringExtra("text") != null) {
artist = intent.getStringExtra("text");
}
return;
}
PendingIntent contentIntent = null;
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? PendingIntent.FLAG_IMMUTABLE : 0;
contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags);
try {
JSONObject o = new JSONObject(payload);
title = o.optString("title", "");
artist = o.optString("artist", "");
album = o.optString("album", "");
durationMs = o.optLong("durationSec", 0) * 1000L;
positionMs = o.optLong("positionSec", 0) * 1000L;
playing = "playing".equals(o.optString("state", "paused"));
String path = o.optString("artPath", "");
if (!path.equals(artPath)) {
artPath = path;
loadArt(path);
}
} catch (Exception e) {
Log.e(TAG, "bad media payload", e);
return;
}
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setContentIntent(contentIntent)
if (playing) {
requestFocus();
registerNoisy();
} else {
unregisterNoisy();
}
updateSession();
}
// --- MediaSession ------------------------------------------------------
private void createSession() {
session = new MediaSession(this, "YellowJacket");
session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
| MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
session.setCallback(new MediaSession.Callback() {
@Override
public void onPlay() {
emitCommand("play");
}
@Override
public void onPause() {
emitCommand("pause");
}
@Override
public void onStop() {
emitCommand("stop");
}
@Override
public void onSkipToNext() {
emitCommand("next");
}
@Override
public void onSkipToPrevious() {
emitCommand("previous");
}
@Override
public void onSeekTo(long pos) {
try {
JSONObject o = new JSONObject();
o.put("command", "seek");
o.put("positionSec", pos / 1000L);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "seek command failed", e);
}
}
});
session.setActive(true);
}
private void updateSession() {
MediaMetadata.Builder meta = new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
.putString(MediaMetadata.METADATA_KEY_ALBUM, album)
.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs);
if (art != null) {
meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art);
}
session.setMetadata(meta.build());
// The position is an anchor, not a clock: the state carries the
// playback speed and the OS interpolates from here, which is why the
// Go side only pushes on a real state change or a seek.
PlaybackState state = new PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY
| PlaybackState.ACTION_PAUSE
| PlaybackState.ACTION_PLAY_PAUSE
| PlaybackState.ACTION_STOP
| PlaybackState.ACTION_SKIP_TO_NEXT
| PlaybackState.ACTION_SKIP_TO_PREVIOUS
| PlaybackState.ACTION_SEEK_TO)
.setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,
positionMs, playing ? 1.0f : 0.0f)
.build();
session.setPlaybackState(state);
}
// --- Notification ------------------------------------------------------
private void createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
// LOW: a transport notification is a control surface, not news, and
// IMPORTANCE_DEFAULT would make a sound on every track change.
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
ch.setShowBadge(false);
nm.createNotificationChannel(ch);
}
private void goForeground() {
Notification n = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match
// android:foregroundServiceType in the manifest, or
// startForeground throws; and on Android 14+ the declared type
// is what decides whether the service may start from the
// background at all.
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, n);
}
// Restart if the OS kills us while still wanted.
return START_STICKY;
}
@SuppressWarnings("deprecation")
private Notification buildNotification() {
Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Notification.Builder(this, CHANNEL_ID)
: new Notification.Builder(this);
b.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title)
.setContentText(artist)
.setSubText(album)
.setOngoing(playing)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setContentIntent(launchIntent());
if (art != null) {
b.setLargeIcon(art);
}
b.addAction(new Notification.Action.Builder(
android.R.drawable.ic_media_previous, "Previous",
transportIntent(ACTION_PREVIOUS, 1)).build());
b.addAction(playing
? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause",
transportIntent(ACTION_PAUSE, 2)).build()
: new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play",
transportIntent(ACTION_PLAY, 3)).build());
b.addAction(new Notification.Action.Builder(
android.R.drawable.ic_media_next, "Next",
transportIntent(ACTION_NEXT, 4)).build());
Notification.MediaStyle style = new Notification.MediaStyle()
.setShowActionsInCompactView(0, 1, 2);
if (session != null) {
style.setMediaSession(session.getSessionToken());
}
b.setStyle(style);
return b.build();
}
private PendingIntent transportIntent(String action, int requestCode) {
Intent i = new Intent(this, WailsForegroundService.class).setAction(action);
return PendingIntent.getService(this, requestCode, i, pendingIntentFlags());
}
private PendingIntent launchIntent() {
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch == null) {
return null;
}
return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags());
}
private int pendingIntentFlags() {
// Mandatory from S, unavailable before M.
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT;
}
// --- Cover art ---------------------------------------------------------
/**
* Decode the cover off the main thread and redraw when it lands. A track
* change must not wait on a JPEG, and the notification is correct without
* one — it simply has no image until this returns.
*/
private void loadArt(final String path) {
art = null;
if (path == null || path.isEmpty()) {
return;
}
artExecutor.execute(() -> {
Bitmap decoded = decodeScaled(path);
mainHandler.post(() -> {
// The track may have changed while we decoded.
if (!path.equals(artPath)) {
return;
}
art = decoded;
updateSession();
goForeground();
});
});
}
private Bitmap decodeScaled(String path) {
try {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, bounds);
int longest = Math.max(bounds.outWidth, bounds.outHeight);
int sample = 1;
while (longest / sample > ART_MAX_PX) {
sample *= 2;
}
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sample;
return BitmapFactory.decodeFile(path, opts);
} catch (Throwable t) {
// OutOfMemoryError included: a missing cover is not a crash.
Log.w(TAG, "cover art decode failed: " + path, t);
return null;
}
}
// --- Audio focus -------------------------------------------------------
private void requestFocus() {
if (hasFocus || audioManager == null) {
return;
}
if (focusListener == null) {
focusListener = this::onFocusChange;
}
int result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
// No setWillPauseWhenDucked: from Oreo the framework ducks us
// itself and reports no CAN_DUCK loss, so the Go-side duck below
// is a pre-Oreo path. Asking to be told instead would mean
// pausing for every notification tone.
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusListener, mainHandler)
.build();
result = audioManager.requestAudioFocus(focusRequest);
} else {
result = requestFocusLegacy();
}
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
@SuppressWarnings("deprecation")
private int requestFocusLegacy() {
return audioManager.requestAudioFocus(focusListener,
AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
}
@SuppressWarnings("deprecation")
private void abandonFocus() {
if (!hasFocus || audioManager == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) {
audioManager.abandonAudioFocusRequest(focusRequest);
} else {
audioManager.abandonAudioFocus(focusListener);
}
hasFocus = false;
}
private void onFocusChange(int change) {
switch (change) {
case AudioManager.AUDIOFOCUS_LOSS:
// Someone else owns the output now, for good.
hasFocus = false;
pausedByFocusLoss = false;
emitCommand("pause");
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
// A phone call. Remember that the pause was ours to undo.
pausedByFocusLoss = playing;
emitCommand("pause");
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
emitDuck(true);
break;
case AudioManager.AUDIOFOCUS_GAIN:
hasFocus = true;
emitDuck(false);
if (pausedByFocusLoss) {
pausedByFocusLoss = false;
emitCommand("play");
}
break;
default:
break;
}
}
// --- Noisy (headphones) ------------------------------------------------
private void registerNoisy() {
if (noisyRegistered) {
return;
}
registerReceiver(noisyReceiver,
new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
noisyRegistered = true;
}
private void unregisterNoisy() {
if (!noisyRegistered) {
return;
}
try {
unregisterReceiver(noisyReceiver);
} catch (IllegalArgumentException ignored) {
// Already gone; nothing to undo.
}
noisyRegistered = false;
}
// --- Talking to Go -----------------------------------------------------
private void emitCommand(String command) {
try {
JSONObject o = new JSONObject();
o.put("command", command);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "command emit failed: " + command, e);
}
}
private void emitDuck(boolean on) {
try {
JSONObject o = new JSONObject();
o.put("command", "duck");
o.put("on", on);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "duck emit failed", e);
}
}
@Override
public void onDestroy() {
running = false;
unregisterNoisy();
abandonFocus();
if (session != null) {
session.setActive(false);
session.release();
session = null;
}
artExecutor.shutdownNow();
super.onDestroy();
}
@Nullable
@@ -2,7 +2,12 @@
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<color name="wails_background">#1B2636</color>
<!-- The window background, which is what the launch screen shows and
what the system-bar padding leaves visible. Black rather than the
scaffold's blue-grey because this app's own default ramp is
black: a band of #1B2636 above and below it reads as the app
failing to fill the screen. -->
<color name="wails_background">#000000</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>
+191
View File
@@ -0,0 +1,191 @@
//go:build indexbuild
package main
import (
"database/sql"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
)
// The columns an index built before the completeness work has: every
// current one except total_tracks.
//
// Filtered rather than string-replaced, because the list is formatted
// across lines: `strings.Replace(catalogColumns, "total_tracks, ", …)`
// matches nothing (the name is followed by a newline, not a space) and
// silently yields the *current* list -- so the test built a modern
// source index and proved nothing while passing its own premise.
var oldColumns = withoutTotals(catalogColumns)
func withoutTotals(cols string) string {
kept := make([]string, 0, 20)
for _, part := range strings.Split(cols, ",") {
if strings.TrimSpace(part) == "total_tracks" {
continue
}
kept = append(kept, strings.TrimSpace(part))
}
return strings.Join(kept, ", ")
}
// TestExportFromAnIndexWithoutTotals reproduces the failure that broke
// the index-artifact job, symptom first.
//
// The job's /cache volume is a real YJ_HOME that survives between runs
// and holds ~205 GB, so its explore_index is Cache and is deliberately
// not dropped by cmd/indexbuild's schema repair -- which means a column
// added to the schema afterwards is simply absent from it. The exporter
// selected it anyway and the whole run died with
//
// indexexport: copy rows: SQL logic error: no such column: total_tracks
//
// after three minutes of work, on a job that publishes the catalog
// every user downloads.
func TestExportFromAnIndexWithoutTotals(t *testing.T) {
t.Parallel()
db := openWithSource(t, oldColumns)
if got := sourceColumns(db); strings.Contains(got, "total_tracks") {
t.Fatalf("source list still names total_tracks: %s", got)
}
if err := copyRows(db, 10, 5, 5); err != nil {
t.Fatalf("export from an index without total_tracks: %v", err)
}
// Zero, not absent: the artifact keeps every column so an importer
// needs no second shape, and 0 is what the column already means by
// "the catalog does not say".
var total int
if err := db.QueryRow(
`SELECT total_tracks FROM core.explore_index WHERE entity_type = 2`,
).Scan(&total); err != nil {
t.Fatalf("read exported total_tracks: %v", err)
}
if total != 0 {
t.Errorf("total_tracks = %d, want 0", total)
}
}
// TestExportCarriesTotalsWhenTheIndexHasThem is the other half: the
// probe must not cost the totals of an index that does have them.
func TestExportCarriesTotalsWhenTheIndexHasThem(t *testing.T) {
t.Parallel()
db := openWithSource(t, catalogColumns)
if got := sourceColumns(db); !strings.Contains(got, "total_tracks") {
t.Fatalf("source list dropped total_tracks: %s", got)
}
if err := copyRows(db, 10, 5, 5); err != nil {
t.Fatalf("export: %v", err)
}
var total int
if err := db.QueryRow(
`SELECT total_tracks FROM core.explore_index WHERE entity_type = 2`,
).Scan(&total); err != nil {
t.Fatalf("read exported total_tracks: %v", err)
}
if total != 12 {
t.Errorf("total_tracks = %d, want 12", total)
}
}
// openWithSource builds a source index carrying exactly `columns`, with
// one artist and one of its release groups, and attaches a fresh
// artifact database as `core`.
func openWithSource(t *testing.T, columns string) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := sql.Open("sqlite", filepath.Join(dir, "src.db"))
if err != nil {
t.Fatalf("open source: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// The source's shape is the point of the test, so it is spelled
// out here rather than taken from the app's schema, which is
// always current by definition.
create := `CREATE TABLE explore_index (
id INTEGER PRIMARY KEY,
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL,
title TEXT NOT NULL DEFAULT '',
artist_name TEXT NOT NULL DEFAULT '',
artist_mbid BLOB NOT NULL DEFAULT x'',
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid BLOB NOT NULL DEFAULT x'',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
total_tracks INTEGER NOT NULL DEFAULT 0,
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0
)`
if !strings.Contains(columns, "total_tracks") {
create = strings.Replace(
create, "total_tracks INTEGER NOT NULL DEFAULT 0,\n", "", 1,
)
}
if _, err := db.Exec(create); err != nil {
t.Fatalf("create source: %v", err)
}
seed := `INSERT INTO explore_index (` + columns + `) VALUES `
if strings.Contains(columns, "total_tracks") {
seed += `(1, x'00000000000000000000000000000001', 'A', 'A',
x'00000000000000000000000000000001', '', 100, 100, 0, x'',
'', '', '', '', 12, '', '', '', '', 0),
(2, x'00000000000000000000000000000002', 'RG', 'A',
x'00000000000000000000000000000001', '', 90, 90, 0, x'',
'', 'Album', '', '', 12, '', '', '', '', 0)`
} else {
seed += `(1, x'00000000000000000000000000000001', 'A', 'A',
x'00000000000000000000000000000001', '', 100, 100, 0, x'',
'', '', '', '', '', '', '', '', 0),
(2, x'00000000000000000000000000000002', 'RG', 'A',
x'00000000000000000000000000000001', '', 90, 90, 0, x'',
'', 'Album', '', '', '', '', '', '', 0)`
}
if _, err := db.Exec(seed); err != nil {
t.Fatalf("seed source: %v", err)
}
if _, err := db.Exec(
`ATTACH DATABASE ? AS core`, filepath.Join(dir, "core.db"),
); err != nil {
t.Fatalf("attach core: %v", err)
}
if err := createSchema(db); err != nil {
t.Fatalf("create artifact schema: %v", err)
}
return db
}
+42 -2
View File
@@ -25,6 +25,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -43,6 +44,41 @@ const catalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
release_name, primary_type, secondary_types, release_date, total_tracks,
artist_type, country, disambiguation, sort_name, discog_fetched`
// sourceColumns is catalogColumns as read *from* the built index,
// which is not always shaped like the one this binary was compiled
// against.
//
// The index job's /cache volume is a real YJ_HOME that survives
// between runs and holds ~205 GB nobody can re-download casually, so
// its explore_index is classified Cache and is deliberately **not**
// dropped and recreated by cmd/indexbuild's schema repair. A column
// added to the schema after that database was built is therefore
// absent from it, and selecting it fails the whole export with
// "no such column: total_tracks" -- which is what happened the first
// time the job ran after the completeness work.
//
// So the source list is asked for rather than assumed, exactly as
// artifactHasTotals does on the importing side. Zero is what the
// column means by "the catalog does not say", and the app already
// renders that as unknown rather than as incomplete.
func sourceColumns(db *sql.DB) string {
var n int
err := db.QueryRow(
`SELECT COUNT(*) FROM pragma_table_info('explore_index', 'main')
WHERE name = 'total_tracks'`,
).Scan(&n)
if err == nil && n > 0 {
return catalogColumns
}
fmt.Println(
" note: this index predates total_tracks; exporting 0 for it",
)
return strings.Replace(catalogColumns, "total_tracks", "0", 1)
}
var errNoHome = errors.New(
"YJ_HOME must be set to the directory holding the built index",
)
@@ -189,6 +225,10 @@ func createSchema(db *sql.DB) error {
// dumpcatalog.go — a flat global top-N would give a handful of
// superstars everything and everyone else nothing.
func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
// The destination is created by this binary and always has every
// column; only the source may be older.
srcColumns := sourceColumns(db)
if _, err := db.Exec(`
CREATE TEMP TABLE core_artists AS
SELECT mbid FROM main.explore_index
@@ -201,7 +241,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
copied, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+`
SELECT `+srcColumns+`
FROM main.explore_index
WHERE entity_type = 1 /* artist */
AND mbid IN (SELECT mbid FROM core_artists)`)
@@ -228,7 +268,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
// most `limit` rows, ranked by their own listen counts.
n, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+` FROM (
SELECT `+srcColumns+` FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY artist_mbid ORDER BY popularity DESC
) AS rn
+8 -4
View File
@@ -1,7 +1,7 @@
# Releasing the Android APK
`.gitea/workflows/android-apk.yml` builds a signed fat APK
(`arm64-v8a` + `x86_64`) on every `v*` tag and publishes it to Gitea's
`.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on
every `v*` tag and publishes it to Gitea's
**generic** package registry, which is readable without credentials —
which is what lets Obtainium poll a plain URL with no token.
@@ -98,8 +98,12 @@ publish `1.100.0`**, and never move a tag that has already been built.
## What the workflow checks before publishing
- the APK exists and is non-empty;
- it carries **both** ABIs (`native-code: 'arm64-v8a' 'x86_64'`), or it
is not the fat APK it claims to be;
- it carries **exactly one** ABI (`native-code: 'arm64-v8a'`). x86_64
Android cannot run this app at all — `modernc.org/libc` issues a raw
`lstat` syscall that Android's seccomp policy forbids, on every
x86_64 device and not merely the emulator — so an x86_64 slice would
be ~31 MB that runs nowhere, and its reappearance means someone put
the ABI back in `app/build.gradle` without knowing that;
- its `versionCode` is the one derived from the tag;
- it is **not** signed with the debug key.
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '../support/fixtures.js';
/**
* Back is the platform's, and the app has to have somewhere for it to
* go (reported from a device: "the Android back button does not
* navigate back in the app").
*
* The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()`
* and finishes the activity otherwise. This app never touched
* `history`, so that was always false and back quit from any depth. A
* navigation is a history entry now, which is why this is assertable
* here at all: `page.goBack()` is the same `popstate` the phone's
* gesture produces, so the browser tier can answer a question that
* otherwise needs a device.
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
* A library artist opens `explore-artist-details` -- the catalog panel
* standing in for a library one, as `explore-link.ts` describes -- and
* the view name follows the component, not the source of the click.
*/
async function openAnArtist(app: Page): Promise<void> {
await app.getByTestId('nav-artists').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
// A card, by the name on it: the grid is virtualized and positioned
// by transform, so a click at coordinates is a click at whatever
// happens to be there.
await app.locator('artists-view').getByText('Aurora Fields').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
}
test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({
app,
}) => {
await openAnArtist(app);
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
});
test('walks back through primary views, one press per navigation', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
// Forward is free once back works, and it is what proves the entry
// was restored rather than the view merely re-rendered.
await app.goForward();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
// The detail view's own back button and the phone's gesture are the
// same press: if each popped its own stack, this would land two
// navigations back instead of one.
await app
.locator('explore-artist-details')
.getByRole('button', { name: 'Back to explore' })
.click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
});
});
+58 -11
View File
@@ -26,9 +26,7 @@ import type { Page } from '@playwright/test';
*/
test.describe('Explore before anyone has typed', () => {
test.beforeEach(async ({ app }) => {
// Idempotent, so running it per test costs one count query when a
// catalog is already there — which is every developer machine.
await stageCatalogIfEmpty(app);
await stageCatalog(app);
await app.getByTestId('nav-explore').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
@@ -125,8 +123,7 @@ test.describe('Explore before anyone has typed', () => {
});
/**
* Give the app a catalog if it has none, so the empty-index environment
* still exercises the shelves rather than skipping them.
* Give the app the catalog these shelves are written against.
*
* Deliberately shaped: two artists with albums (one of them with
* three), and a third with none. The three albums are what "one album
@@ -135,9 +132,23 @@ test.describe('Explore before anyone has typed', () => {
* above it and correctly skipped — the first version of this fixture
* had only two, and the artists shelf was rightly omitted, which read
* as a broken page.
*
* **Unconditional, and it used to ask whether the catalog was empty.**
* "Any rows at all" is the wrong question: the backend is one shared
* process with one database, so a *single* row left by another spec
* file — `requested-badge` stages one album — satisfies that gate and
* this suite then draws a shelf page with no artist card on it and
* times out looking for one. It survives a suite run, so it is the
* second local `make e2e` that fails and the first that passes, which
* is the least useful order. CI never sees it: every run there is a
* fresh YJ_HOME.
*
* The inserts are `INSERT OR IGNORE` keyed on the MBID, so running
* this per test is idempotent, and adding seven low-popularity rows to
* a developer machine's real million-row catalog changes nothing the
* shelves show.
*/
async function stageCatalogIfEmpty(app: Page): Promise<void> {
if ((await catalogRows(app)) > 0) return;
async function stageCatalog(app: Page): Promise<void> {
// The catalog stores an MBID as its 16 raw bytes and an entity type
// as a small integer, so a staged row has to be spelled the way the
@@ -185,15 +196,51 @@ async function stageCatalogIfEmpty(app: Page): Promise<void> {
expect(result.status, `staging failed: ${result.body}`).toBe(200);
// …and `OR IGNORE` means a 200 is not a write. A CHECK the row
// violates is *ignored*, not reported, so the count below is the
// violates is *ignored*, not reported, so the check below is the
// only thing that can tell staging from silence.
//
// It is `0 or 1`, not `1`, because this helper is now
// unconditional: the second call of a run legitimately writes
// nothing. What must hold either way is that the rows are *there*,
// which is what the assertion after the loop says — a stronger
// statement than "this insert wrote something", and the one that
// actually protects the fixture.
expect(
(JSON.parse(result.body) as { rowsAffected?: number }).rowsAffected,
`staged nothing: ${result.body}`,
).toBe(1);
`staging error: ${result.body}`,
).toBeLessThanOrEqual(1);
}
expect(await catalogRows(app)).toBeGreaterThan(0);
// Every staged row is present, whoever put it there. An MBID that
// fails `CHECK(length(mbid) = 16)` is silently dropped by OR IGNORE,
// and this is where that shows up.
expect(await stagedRowCount(app), 'the staged catalog is incomplete')
.toBe(rows.length);
}
/** How many of the staged fixture rows are in the catalog. */
async function stagedRowCount(app: Page): Promise<number> {
const result = await app.evaluate(async () => {
const res = await fetch('/__test/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sql: `SELECT COUNT(*) AS n FROM explore_index
WHERE artist_name IN ('Staged Alpha', 'Staged Beta',
'Staged Gamma')`,
}),
});
return { status: res.status, body: await res.text() };
});
expect(result.status, `count failed: ${result.body}`).toBe(200);
const parsed = JSON.parse(result.body) as {
rows?: { n?: number }[];
};
return parsed.rows?.[0]?.n ?? 0;
}
/**
+23 -19
View File
@@ -199,35 +199,39 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
});
});
test('what does not fit sideways can be scrolled to', async ({ app }) => {
test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => {
// 320 CSS px is 400% page zoom of a 1280px viewport, which is the
// size 1.4.10 names. The shell is 784px wide there, so 464px of the
// app — the job indicator and the queue button among it — used to
// be behind `overflow: hidden` with no way to reach it.
// size 1.4.10 names.
//
// **This assertion is the inverse of the one it replaces, and that
// is the fix landing rather than the test being weakened.** The
// shell used to be 784px wide here, so 464px of the app — the job
// indicator and the queue button among it — sat behind
// `overflow: hidden` with no way to reach it; making the axis
// scrollable was the remedy available at the time. 016 B2's phone
// layout reflows instead: below 600px the sidebar becomes a bottom
// tab bar, the header's controls shrink, and the shell measures
// exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks
// for; being able to scroll to the overflow was the concession.
await app.setViewportSize({ width: 320, height: 256 });
// A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still
// permits programmatic scrolling, so the obvious probe passes on
// the build that has the bug. It did, first time.
await app.mouse.move(160, 20);
await app.mouse.wheel(400, 400);
await app.waitForTimeout(200);
const reach = await app.evaluate(() => {
const fit = await app.evaluate(() => {
const se = document.scrollingElement!;
return { left: se.scrollLeft, top: se.scrollTop };
return {
scrollWidth: se.scrollWidth,
clientWidth: document.documentElement.clientWidth,
scrollHeight: se.scrollHeight,
clientHeight: document.documentElement.clientHeight,
};
});
expect(reach.left).toBeGreaterThan(0);
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth);
// And the vertical axis stays fixed, which is what keeps the
// transport where a desktop player's transport belongs.
expect(reach.top).toBe(0);
// transport where a player's transport belongs.
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
await app.evaluate(() => {
document.scrollingElement!.scrollLeft = 0;
});
await app.setViewportSize({ width: 1440, height: 900 });
});
+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();
});
});
+170
View File
@@ -0,0 +1,170 @@
import { test, expect } from '../support/fixtures.js';
/**
* The phone shell (plan 016 B2, phase 1).
*
* This is the tier that can actually answer the question. Wails v3's
* server mode serves the real frontend, so a Chromium at 390×844 is the
* same document an Android WebView renders — the only thing a device
* adds here is the WebView's own quirks, and CI runs the WebKit half
* for exactly that reason.
*
* The assertions are the three things B2 is *for*: the eleven-item
* sidebar is gone, the four destinations plan 016 committed to are
* reachable with a thumb, and nothing scrolls sideways. The last one is
* the one that hides: `overflow-x: auto` on `body` means a shell that
* does not fit produces a scrollbar rather than a broken layout, which
* looks survivable in a screenshot and is not.
*/
/** A common small phone. Narrower than any device this is likely to meet. */
const PHONE = { width: 390, height: 844 };
/** The narrowest thing still sold, near enough. */
const SMALL_PHONE = { width: 360, height: 780 };
const horizontalOverflow = (page: import('@playwright/test').Page) =>
page.evaluate(() => ({
scrollWidth: document.body.scrollWidth,
clientWidth: document.body.clientWidth,
}));
test.describe('the shell on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
await expect(app.locator('div.sidebar')).toBeHidden();
const nav = app.locator('bottom-nav');
await expect(nav).toBeVisible();
// Four tabs and a way to everything else, which is the shape the
// plan argues for: a tab bar is 3-5 items before the targets stop
// being thumb-sized.
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
}
});
test('navigates from a tab', async ({ app }) => {
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'albums');
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'home');
});
test('reaches the views with no tab through the drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// Scoped to the drawer: the desktop sidebar is still in the DOM
// (hidden by the media query, not removed), so an unscoped testid
// matches two elements and Playwright's strict mode refuses --
// which is the right complaint, since the two really are different
// buttons.
//
// The drawer holds the *same* sidebar the desktop uses, so Settings
// -- which a phone still needs occasionally -- is reachable without
// a second list of destinations to keep in step.
const settings = app
.getByTestId('nav-drawer')
.getByTestId('nav-settings');
await expect(settings).toBeVisible();
await settings.click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'settings');
// And the drawer gets out of the way once it has done its job.
await expect(app.getByTestId('nav-drawer')).toBeHidden();
});
test('has a named drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// The a11y snapshot never prints a dialog's name, so this asks for
// the role and the name together -- which is the check that caught
// eleven unnamed dialogs.
await expect(
app.getByRole('dialog', { name: 'All views' }),
).toBeVisible();
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
.toBeLessThanOrEqual(clientWidth);
});
}
test('opens the full-screen now playing, and comes back', async ({ app }) => {
// Something has to be playing for the mini player to be a way in.
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
await app.locator('track-list .track-row').first().dblclick();
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
// The seek bar and volume that phase 1 took out of the bottom bar
// 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();
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
await app.getByTestId('npv-back').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
// The button exists in the markup at every size; CSS decides. If
// this becomes visible on a desktop it is a 48px hit target over
// the cover art, swallowing the clicks that open the preview.
await expect(app.getByTestId('open-now-playing')).toBeHidden();
});
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
// The player bar stays: this is a music player, and what is playing
// has to be visible and pausable from every view.
await expect(app.locator('audio-player')).toBeVisible();
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 a later phase's
// full-screen now-playing view.
await expect(app.locator('audio-player volume-control')).toBeHidden();
});
});
test.describe('the desktop shell is unchanged', () => {
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(app.locator('div.sidebar')).toBeVisible();
await expect(app.locator('bottom-nav')).toBeHidden();
});
});
+121
View File
@@ -41,6 +41,18 @@ body {
overflow-y: hidden;
}
/* Above the phone breakpoint the tab bar does not exist. It is in the
markup unconditionally and eagerly, for the reason notification-host
is: navigation that has to fetch a chunk before it can navigate is
not navigation. */
@media (min-width: 600px) {
bottom-nav {
display: none;
}
}
p {
margin: 0;
/* I want to set paragraph margins myself */
@@ -130,6 +142,8 @@ body div.sidebar {
contain: layout style paint;
}
.bottom-bar {
grid-area: bottom-bar;
padding: 0.25em;
@@ -241,3 +255,110 @@ body div.sidebar {
pointer-events: none !important;
contain: strict !important;
}
/* ===================================================================
The phone shell (plan 016 B2).
**This section is last on purpose.** A media query adds no
specificity, so `@media (max-width: 599px) { .title { … } }` placed
above the plain `.title` rule loses to it -- which is exactly what
happened when this landed in the middle of the file: the header kept
its 2em gutters, its 16px gap and its 24px title on a 390px phone,
and every one of these declarations was dead. Nothing failed,
because the shell fits for a different reason (the `min-width: 0`
below and each component's own media query), so a screenshot was
what caught it.
600px, not the sidebar's 900: 900 is a *laptop* and the response to
it is a narrower sidebar, which is still a sidebar. Below 600 there
is no room for one at all -- 360px of viewport over a 200px nav is
not a layout -- so the navigation moves to the bottom, where a thumb
is, and the eleven-item list moves into `bottom-nav`'s drawer.
=================================================================== */
@media (max-width: 599px) {
body {
grid-template:
"top-bar" 3.25em
"main-panel" 1fr
"bottom-bar" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
allowed to overflow a zoomed-in window (a11y.21 above); a
phone *is* the small viewport, so the shell has to fit it. */
overflow-x: hidden;
}
body div.sidebar {
display: none;
}
bottom-nav {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
`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 {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
overflow: hidden;
}
.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
every assertion about either one.
`:has()` rather than a class toggled from index.ts: which view
is showing is already published as an attribute, and a second
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 {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
}
.bottom-bar audio-player {
margin: 0.25em;
}
}
+7
View File
@@ -41,6 +41,13 @@
<wa-icon name="list"></wa-icon>
</button>
</footer>
<!-- 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
app on a phone. After the footer, because that is where it
renders -- the tab bar sits below the transport, and DOM order
is what a screen reader and the tab sequence follow. -->
<bottom-nav></bottom-nav>
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
+95 -25
View File
@@ -21,6 +21,7 @@ import '@components/audio-player/audio-player.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/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
@@ -49,6 +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 { installLongPressContextMenu } from '@utils/long-press';
import {
hasTrackPayload,
getDragPayload,
@@ -63,6 +65,11 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// 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
// ---------------------------------------------------------------------------
@@ -126,6 +133,11 @@ const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
import('@components/explore-artist-details/explore-artist-details.js'),
'explore-album-details': () =>
import('@components/explore-album-details/explore-album-details.js'),
// A detail view rather than a primary one on purpose: it is
// somewhere you go and come back from, so the nav stack carries
// the way out (016 B2 phase 2).
'now-playing': () =>
import('@components/now-playing-view/now-playing-view.ts'),
};
// Opened from a menu rather than by navigating, so they have no entry
@@ -148,10 +160,6 @@ const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
const mainContent = document.getElementById('main-content');
@@ -184,6 +192,71 @@ document.addEventListener('navigate', (e: Event) => {
void handleNavigate((e as CustomEvent).detail);
});
// ---------------------------------------------------------------------------
// The platform's back gesture
// ---------------------------------------------------------------------------
// Android's back button is not a keystroke the page can bind: the
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
// otherwise finishes the activity. This app never touched `history`, so
// that was always false and back quit the app from any depth -- reported
// from a device as "back does not navigate back".
//
// So a navigation is a history entry, and back is `popstate`. It hooks
// the platform's own mechanism rather than a JNI callback of our own,
// which is the same reason `events.ts` hooks the runtime's transport:
// the Java half needs no change, and the behaviour is testable in a
// browser (`page.goBack()`) instead of only on a phone.
//
// Two rules keep the two stacks from disagreeing. A navigation that
// *came from* history pushes nothing (`_isBack`), or going back would
// deepen the stack it is unwinding. And the in-app back buttons --
// `navigate-back`, which the detail views and `now-playing-view` fire --
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** 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;
/** 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` 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) {
history.pushState(state, '');
pushedEntries += 1;
} else {
history.replaceState(state, '');
historyStarted = true;
}
}
window.addEventListener('popstate', (e: PopStateEvent) => {
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;
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
@@ -193,6 +266,8 @@ async function handleNavigate(
const seq = ++navSeq;
if (!detail._isBack) recordNavigation(detail);
// 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
@@ -206,9 +281,6 @@ async function handleNavigate(
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Navigating to a primary view clears the history stack.
navStack.length = 0;
// Remove any active detail view first
if (currentDetailEl) {
deactivateView(currentDetailEl);
@@ -241,7 +313,6 @@ async function handleNavigate(
// the way out. Either way this is the call that starts it.
activateView(target);
currentViewEl = target;
currentNavDetail = { view };
return;
}
@@ -250,12 +321,6 @@ async function handleNavigate(
if (seq !== navSeq) return;
// --- Detail (ephemeral) views -----------------------------------------
// Push the current view onto the nav stack before switching
// (unless this is a back-navigation, which already popped).
if (!detail._isBack) {
navStack.push({ ...currentNavDetail });
}
// Hide the current primary view
if (currentViewEl) {
currentViewEl.classList.add('view-hidden');
@@ -268,8 +333,6 @@ async function handleNavigate(
currentDetailEl = null;
}
currentNavDetail = { ...detail };
switch (view) {
case 'artist-details': {
const { artistId, artistName } = detail;
@@ -304,6 +367,13 @@ async function handleNavigate(
currentDetailEl = spEl;
break;
}
case 'now-playing': {
const npEl = document.createElement('now-playing-view');
mainContent.appendChild(npEl);
currentDetailEl = npEl;
break;
}
case 'genre-details': {
const { genreName } = detail;
const genreEl = document.createElement('genre-details');
@@ -406,16 +476,16 @@ function schedule(fn: () => void): void {
setTimeout(fn, 200);
}
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
// Navigate-back: the in-app back buttons, which are the same press as
// the phone's. It goes through the history rather than a stack of its
// own, so one press is one entry however it arrived -- two stacks is
// how a detail view's own button and the back gesture come to disagree.
//
// At the root there is nothing of ours to go back to, and going back
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
const prev = navStack.pop();
if (prev) {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { ...prev, _isBack: true },
}));
}
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
@@ -0,0 +1 @@
<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 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement {
flex: 1;
}
/* The phone transport (plan 016 B2): the buttons, and nothing
else. A media query inside a shadow root is answered by the
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, which is the next phase. */
@media (max-width: 599px) {
volume-control,
seek-bar {
display: none;
}
}
`];
override render() {
@@ -27,6 +27,18 @@ export class SeekBar extends LitElement {
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;
@@ -0,0 +1,253 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
interface Tab {
id: View;
label: string;
icon: string;
}
/**
* The phone's primary navigation: a bottom tab bar, shown only below
* the phone breakpoint (index.css owns that; this element is
* `display: none` above it).
*
* **Four destinations and a way to everything else.** A tab bar is
* three to five items before the targets stop being thumb-sized —
* 360 px over eleven sidebar entries is 32 px each — so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the drawer) still has to
* move the highlight.
*/
@customElement('bottom-nav')
export class BottomNav extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
background-color: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border, #495057);
/* The home indicator on a gesture-navigation phone sits
under the last few pixels of the viewport, so the bar
pads itself out of the way where the browser reports
one and by nothing where it does not. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
nav ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
margin: 0;
padding: 0;
list-style: none;
}
button {
width: 100%;
/* 48px is the smallest target this should ever be; the
label sits under the icon rather than beside it, which
is what keeps five of them legible at 360px. */
min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 0;
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-font-size-xs, 0.7rem);
}
button wa-icon {
font-size: 1.15rem;
}
button.active {
color: var(--yj-accent, #ffd43b);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.label {
/* A tab label is an aid, not the name: the button's own
accessible name comes from its text, and truncating it
visually does not change that. */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
}
app-sidebar {
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
}
`];
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
* `data-testid` per destination, so a second copy standing by in
* the DOM makes every `nav-*` testid ambiguous **for the whole
* app** -- 30 existing specs failed with "strict mode violation:
* resolved to 2 elements" on a desktop viewport where this element
* is not even visible. A duplicate of a shared component is a
* duplicate of its handles.
*/
@state()
private drawerOpen = false;
@query('wa-drawer')
private drawer?: WaDrawer;
private static readonly TABS: Tab[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
];
override connectedCallback() {
super.connectedCallback();
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change.
nameDialog(this.drawer);
}
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.
this.drawerOpen = false;
};
private openDrawer = () => {
this.drawerOpen = true;
};
/**
* Web Awesome closes itself on Escape and on a click outside, and
* tells us afterwards rather than asking -- so the flag follows the
* element, or the next `open` would be a no-op against a drawer
* that thinks it is already open.
*/
private onDrawerHide = () => {
this.drawerOpen = false;
};
private navigate(view: View) {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
override render() {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
>
<wa-icon name=${tab.icon}></wa-icon>
<span class="label">${tab.label}</span>
</button>
</li>
`)}
<li>
<button
type="button"
data-testid="tab-more"
aria-haspopup="dialog"
@click=${this.openDrawer}
>
<wa-icon name="bars"></wa-icon>
<span class="label">More</span>
</button>
</li>
</ul>
</nav>
<wa-drawer
placement="start"
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bottom-nav': BottomNav;
}
}
@@ -149,6 +149,19 @@ export class JobIndicator extends LitElement {
text-overflow: ellipsis;
}
/* On a phone the ring is the whole indicator: "3 background
jobs" is 114px of a 360px header, and it pushed the
header past the viewport. Only the *visible* label
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. */
@media (max-width: 599px) {
.label {
display: none;
}
}
.alert-dot {
width: 6px;
height: 6px;
@@ -0,0 +1,332 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../audio-player/controls/player-controls';
import '../audio-player/seekbar/seek-bar';
import '../audio-player/volume-control/volume-control';
import {
artistLink,
albumLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
* phase 2).
*
* Phase 1 took the seek bar and the volume out of the bottom bar,
* because 4px of height is not a thumb target and a phone's volume
* belongs to its hardware keys. This is where they went: the same
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
* desktop transport uses, given room. **Not copies of them** — a phone
* layout that reimplements the transport is a second transport to fix
* every bug in, and the seek bar in particular carries the
* interpolation rules that took a plan of their own to get right.
*
* It is a *detail* view rather than a primary one: it is somewhere you
* go and come back from, so `index.ts` pushes the current view onto the
* nav stack and Back pops it. That is also why it is not in the tab
* bar — a tab you cannot leave by pressing the same tab again is not a
* tab.
*/
@customElement('now-playing-view')
export class NowPlayingView extends LitElement {
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0.75em 1em 1.25em;
gap: 0.75em;
background-color: var(--yj-bg-surface, #212529);
overflow-y: auto;
}
header {
display: flex;
align-items: center;
gap: 0.5em;
flex: 0 0 auto;
}
.context {
flex: 1 1 auto;
}
.back {
background: none;
border: none;
color: var(--yj-text-primary, #f8f9fa);
/* 48px is the touch-target floor, and this is the control
that gets a user out of a full-screen view. */
min-width: 48px;
min-height: 48px;
font-size: 1.1rem;
cursor: pointer;
border-radius: 6px;
}
.back:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.context {
font-size: var(--yj-font-size-xs, 0.75rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yj-text-secondary, #adb5bd);
}
.art {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
.art .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--yj-text-tertiary, #868e96);
}
.meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.75em;
min-width: 0;
}
.names {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0;
/* Two lines, then an ellipsis. A marquee is the bottom
bar's answer to a 320px box; here there is room to wrap,
and wrapping does not move. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist,
.album {
margin: 0;
font-size: 0.9rem;
color: var(--yj-text-secondary, #adb5bd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite {
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
min-width: 48px;
min-height: 48px;
font-size: 1.25rem;
cursor: pointer;
border-radius: 6px;
}
.favorite.on {
color: var(--yj-accent, #ffd43b);
}
.favorite:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.transport {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The seek bar is the reason this view exists. Its own
stylesheet thickens the track below the phone breakpoint --
the track size is set on the wa-slider inside its shadow
root, so a custom property set from here would not reach
it. */
seek-bar {
display: block;
}
.empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
`];
private back() {
this.dispatchEvent(new CustomEvent('navigate-back', {
bubbles: true,
composed: true,
}));
}
/**
* Open the queue.
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
}
private toggleFavorite() {
const path = this.player.currentTrack?.filePath;
if (path) void this.favCtrl.toggleFavorite(path);
}
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
${this.renderHeader()}
<p class="empty" data-testid="npv-empty">
Nothing is playing.
</p>
`;
}
const favorited = this.favCtrl.isFavorited(track.filePath);
// The largest kept tier, which is what `saveCoverArt` records as
// the path -- there is no full-resolution original to reach for.
const art = track.coverArtLarge || track.coverArt;
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${artistLink(track.artist, track.artistMbid)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
</button>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<volume-control></volume-control>
</div>
`;
}
private renderHeader() {
return html`
<header>
<button
type="button"
class="back"
data-testid="npv-back"
aria-label="Back"
@click=${this.back}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<span class="context">Now playing</span>
<button
type="button"
class="back"
data-testid="npv-queue"
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name="list"></wa-icon>
</button>
</header>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'now-playing-view': NowPlayingView;
}
}
@@ -146,6 +146,41 @@ export class NowPlaying extends LitElement {
position: relative;
}
/* The phone's way into the full-screen now-playing view (016 B2
phase 2). It sits over the cover art rather than being a
thirteenth control in a 360px bar, and it is a *button* rather
than a click handler on the art because it is an action with a
name -- the art itself is decorative and the title beside it
already navigates somewhere else (the catalog page).
CSS owns whether it exists, the same way it does for bottom-nav:
there is no viewport check in the component. */
.expand {
display: none;
}
@media (max-width: 599px) {
.expand {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
/* The art shows through; this is a target, not a picture. */
color: transparent;
}
.expand:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
}
.cover-preview-panel {
width: 500px;
height: 500px;
@@ -377,6 +412,13 @@ export class NowPlaying extends LitElement {
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div
class="cover-art"
@mouseenter=${this.handleCoverMouseEnter}
@@ -485,6 +527,15 @@ export class NowPlaying extends LitElement {
`;
}
/** Open the full-screen view. Phone only; see `.expand`. */
private openNowPlaying = () => {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
composed: true,
}));
};
// ===================================================================
// SCROLL LOGIC
// ===================================================================
@@ -67,6 +67,21 @@ export class SearchBar extends LitElement {
transition: border-color 0.15s ease;
}
/* The 200px floor is a desktop floor. On a phone the header is
the whole width there is, and a min-width in a flex row is a
*hard* one -- it does not shrink, so the header stayed 580px
wide inside a 360px viewport and the shell scrolled
sideways. Measured at 360px: 580 -> 360. */
@media (max-width: 599px) {
:host {
min-width: 0;
}
.search-container {
min-width: 0;
}
}
.search-container:focus-within {
border-color: var(--yj-accent, #ffd43b);
}
+14 -2
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { customElement, state, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
@@ -166,6 +166,17 @@ export class AppSidebar extends LitElement {
@state()
private collapsed = false;
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a drawer the shell is not what the sidebar is
* sharing space with.
*/
@property({ type: Boolean, reflect: true })
expanded = false;
/** The width the user chose, restored when the window grows back. */
private userWidth = DEFAULT_WIDTH;
@@ -344,7 +355,8 @@ export class AppSidebar extends LitElement {
*/
private applyViewportWidth() {
const narrow =
this.narrowViewport?.matches ?? false;
!this.expanded &&
(this.narrowViewport?.matches ?? false);
const width = narrow
? MIN_WIDTH
: this.userWidth;
+1
View File
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
solid/backward-step
solid/bars
regular/bookmark
solid/bookmark
solid/box-open
+201
View File
@@ -0,0 +1,201 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event —
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler — delegated or
* per-row, in any shadow root — runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture — where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* The phone's primary navigation (plan 016 B2).
*
* Three of these are about the thing that makes a second nav dangerous:
* it has to agree with the first one. `bottom-nav` emits the same
* bubbling, composed `navigate` event `app-sidebar` does and listens
* for that event globally, so a navigation from anywhere — a card, a
* detail view, the drawer's own sidebar — moves its highlight too. A
* tab bar that only tracks its own clicks looks right until the moment
* the user arrives somewhere by another route.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav';
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness';
type Nav = BottomNav;
const tabs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button');
/** Resolve on one occurrence of an event, or reject loudly on time. */
const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${name} never fired`)),
timeoutMs,
);
el.addEventListener(name, () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
describe('bottom-nav', () => {
beforeEach(() => {
resetHarness();
});
it('offers the four phone destinations and a way to the rest', async () => {
const el = await fixture<Nav>('bottom-nav');
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
'tab-home',
'tab-albums',
'tab-tracks',
'tab-playlists',
'tab-more',
]);
});
it('emits a navigate event that escapes its shadow root', async () => {
const el = await fixture<Nav>('bottom-nav');
const seen: string[] = [];
document.addEventListener('navigate', (e) => {
seen.push((e as CustomEvent<{ view: string }>).detail.view);
});
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
// Composed and bubbling, or index.ts's document-level listener --
// the only thing that actually changes the view -- never hears it.
expect(seen).toEqual(['albums']);
});
it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
await update(el, {});
const current = tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
expect(current).toEqual(['tab-tracks']);
});
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where
// the user is.
expect(
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
});
it('closes the drawer when a navigation happens', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
// The drawer animates, so the assertion is its own event rather
// than the `open` property: setting `open = false` starts a hide
// that has not finished on the next microtask, and a test that
// reads the property in between sees the state it is leaving.
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
const hidden = once(drawer, 'wa-after-hide');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await hidden;
expect(drawer.open).toBe(false);
});
it('gives every tab a name and a target big enough to hit', async () => {
const el = await fixture<Nav>('bottom-nav');
for (const button of tabs(el)) {
expect(button.textContent?.trim()).not.toBe('');
// 48px is the floor for a touch target; the bar is the one
// surface in this app that has no pointer to fall back on.
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
}
});
it('holds no second sidebar until the drawer is asked for', async () => {
const el = await fixture<Nav>('bottom-nav');
// `app-sidebar` carries a data-testid per destination, so a spare
// copy standing by makes every `nav-*` testid ambiguous for the
// *whole app*: rendering it unconditionally failed 30 existing
// specs with "strict mode violation: resolved to 2 elements", on a
// desktop viewport where this element is not even visible.
expect(shadow(el, 'app-sidebar')).toBeNull();
});
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
// Without this the sidebar's own auto-collapse (a response to a
// narrow *shell*) would render icons in a full-width drawer.
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
.toBe(true);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound — an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
@@ -0,0 +1,126 @@
/**
* The full-screen now-playing view (plan 016 B2, phase 2).
*
* What is worth pinning here is not the layout but the *composition*:
* it renders the same `<seek-bar>`, `<player-controls>` and
* `<volume-control>` the desktop transport does, rather than its own.
* A phone layout that reimplements the transport is a second transport
* to fix every bug in — and the seek bar in particular carries
* interpolation rules that took a plan of their own to get right.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/now-playing-view/now-playing-view';
import { Events } from '../../src/events';
import { emit, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
const TRACK: TrackInfo = {
fileName: 'tideline.mp3',
filePath: '/music/tideline.mp3',
trackLength: 245,
seekPosition: 0,
state: 'playing',
title: 'Tideline',
artist: 'Sea Change',
album: 'Ebb',
coverArt: '/covers/ebb.jpg',
coverArtSmall: '/covers/ebb_sm.jpg',
coverArtMedium: '/covers/ebb_md.jpg',
coverArtLarge: '/covers/ebb_lg.jpg',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('now-playing-view', () => {
beforeEach(() => {
resetHarness();
});
it('reuses the real transport components', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
}
});
it('shows the track, and the largest cover tier that is kept', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
// `saveCoverArt` records the largest *tier* as the path; there is
// no full-resolution original on disk to reach for.
expect(
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
).toBe('/covers/ebb_lg.jpg');
});
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
// The player store is a singleton and outlives a test, so "no
// track" has to be stated rather than assumed from a fresh mount.
emit(Events.TrackChanged, null);
const el = await fixture('now-playing-view');
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
// …and the way out is still there, which is the whole point of
// rendering the header in both branches.
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
});
it('leaves by the nav stack, not by guessing where it came from', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
let backs = 0;
document.addEventListener('navigate-back', () => {
backs += 1;
});
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
// `navigate-back` pops what index.ts pushed. Dispatching a
// `navigate` to a hardcoded view would strand anyone who arrived
// here from a detail page.
expect(backs).toBe(1);
});
it('gives the favourite button a target and a state', async () => {
stub('playlist.Service.ToggleFavorite', undefined);
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
expect(fav).not.toBeNull();
expect(fav?.getAttribute('aria-pressed')).toBe('false');
// A button that says only "heart" says nothing; the name carries
// the track and the playlist it goes to.
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
});
it('gives the way out a thumb-sized target', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
expect(back?.getAttribute('aria-label')).toBe('Back');
});
});
+49 -1
View File
@@ -60,6 +60,45 @@ need_sdk() {
[ -x "$EMULATOR" ] || die "no emulator at $EMULATOR — run 'make android-setup'"
}
# Address one device explicitly, because a bare `adb` addresses whatever
# is attached and there is very often something else attached: another
# project's emulator, or this one's own corpse left `offline` by a
# previous run. Both make every adb call here fail with "more than one
# device", which cmd_install then reports as "no device — run 'make
# android-emulator' first" *immediately after* that succeeded.
#
# The AVD name is the identity, not the serial: serials are assigned in
# boot order and change between runs. ANDROID_SERIAL is honoured if the
# caller set it, and is what every later `adb` in this script reads.
pick_device() {
[ -n "${ANDROID_SERIAL:-}" ] && return 0
online=$("$ADB" devices | awk '$2 == "device" { print $1 }')
[ -n "$online" ] || return 1
for serial in $online; do
name=$("$ADB" -s "$serial" shell getprop ro.boot.qemu.avd_name 2>/dev/null | tr -d '\r')
[ -n "$name" ] || name=$("$ADB" -s "$serial" shell getprop ro.kernel.qemu.avd_name 2>/dev/null | tr -d '\r')
if [ "$name" = "$AVD" ]; then
export ANDROID_SERIAL="$serial"
return 0
fi
done
# No AVD of ours, but exactly one device: a physical phone, which is
# the one target this tier actually wants (see android-tier.md).
if [ "$(printf '%s\n' "$online" | wc -l)" -eq 1 ]; then
export ANDROID_SERIAL="$online"
return 0
fi
echo "android: several devices and none is the '$AVD' AVD:" >&2
"$ADB" devices | sed '1d;/^$/d;s/^/ /' >&2
echo " set ANDROID_SERIAL to choose one" >&2
return 1
}
# The emulator is the only long-lived process here, and it is addressed
# by its saved pid. Never by name: `pkill -f emulator` matches this
# script's own command line and kills the shell running it, which is
@@ -126,6 +165,7 @@ cmd_start() {
echo -n "waiting for boot"
"$ADB" wait-for-device >/dev/null 2>&1 || die "device never appeared; see $LOGFILE"
pick_device || die "the emulator booted but could not be addressed"
for _ in $(seq 1 150); do
if [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
echo " ok"
@@ -161,6 +201,7 @@ cmd_stop() {
cmd_install() {
need_sdk
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
# The two ways this fails are both about identity rather than the
@@ -187,7 +228,12 @@ cmd_install() {
echo "or build with a version:"
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
;;
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *signatures do not match*)
# The inner quotes are load-bearing: `do` is a reserved word, and
# an unquoted one in a case pattern is a syntax error that fails
# the parse of the *whole file* -- so every subcommand here died
# with "line 190: syntax error near unexpected token `do'", not
# just install.
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *"signatures do not match"*)
echo
echo "The installed copy was signed with a different key. Android"
echo "never allows that as an update — which is exactly why CI"
@@ -202,6 +248,7 @@ cmd_install() {
cmd_launch() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" shell am force-stop "$PKG"
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
@@ -209,6 +256,7 @@ cmd_launch() {
cmd_logs() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
# The app's own tags plus the two that report its death. Chasing a
# raw logcat here is hopeless: the emulator emits thousands of lines
# a second, almost all of them WindowManager transitions.
+15
View File
@@ -157,7 +157,22 @@ fi
# YJ_TESTCTL mounts backend/testctl's /__test/ endpoints. It is opt-in
# rather than implied by the dev build so that a human's `make dev` does
# not carry an arbitrary-SQL endpoint on a listening port.
#
# YJ_CORE_INDEX_URL points at a dead address, which is what
# seed-sandbox.sh and ci.yml already do and what this script was the
# only one *not* doing. Without it the app downloads and builds the
# real ~1M-row Explore catalog into the run's YJ_HOME, so a local `make
# e2e` runs against a different world than CI: the specs that stage
# their own catalog rows (requested-badge) then search a catalog full
# of real albums, fail to find their fixture, and report it as a
# regression in whatever was last changed. A spec tier whose result
# depends on what a previous run downloaded is not a result -- the same
# rule as the emulator's `-no-snapshot`.
#
# Set YJ_CORE_INDEX_URL yourself to opt back in, for exploring Explore
# by hand.
YJ_TESTCTL=1 \
YJ_CORE_INDEX_URL="${YJ_CORE_INDEX_URL:-http://127.0.0.1:1/none.tar.zst}" \
WAILS_SERVER_PORT="$PORT" \
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
"$BIN" \