Compare commits
13
Commits
ced537ecf2
...
b1cdef8769
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd | ||
|
|
7e0be8fa30 | ||
|
|
1b05dde382 | ||
|
|
29299d17da | ||
|
|
57fbbdf0d2 | ||
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc |
@@ -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 }}'" || {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.** A1–A3 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ type Callbacks struct {
|
||||
OnPrevious func()
|
||||
OnSeek func(positionSec int)
|
||||
OnVolume func(volume float64) // 0.0–1.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user