Skip to content

Develop - #3

Merged
EarMaster merged 17 commits into
mainfrom
develop
Aug 5, 2026
Merged

EarMaster merged 17 commits into
mainfrom
develop

Conversation

@EarMaster

Copy link
Copy Markdown
Owner

No description provided.

EarMaster and others added 17 commits August 4, 2026 02:27
main only advances via a develop merge at release time; PRs/branches
should target develop, never main directly.
Builds out the app described in docs/implementation-plan.md: the six-module
split, the ZeroMQ transport, a Room-backed library cache, the Compose UI,
the media session, launcher shortcuts and multi-box support. Targets
Android 16 (API 36), which Play requires for new apps from 31 August 2026.

Two protocol corrections against the upstream Python source, which both the
plan and the web UI's command table got wrong: shuffle and repeat take an
'option' string rather than MPD flags, mute sets an absolute state, and
get_folder_content's relpath is the path play_folder accepts.

The four translations are unreviewed drafts and are marked as such in their
files. Nothing has yet run against a real box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Store listing text and release notes now live under
fastlane/metadata/android/<locale>/, replacing the docs/whatsnew/
convention. Release notes are keyed by versionCode rather than
versionName, which is how fastlane and the Play API identify them.

Adds tools/check_store_metadata.sh so the per-locale character limits
live in one place, enforced by both /release and google-play.yml. A
missing or over-limit locale now fails the deploy instead of shipping
a blank What's New — Play has no English fallback for store text. The
script counts characters as bytes minus UTF-8 continuation bytes,
because wc -m silently counts bytes when the shell locale is not a
UTF-8 one, as in Git Bash on Windows.

google-play.yml now checks out the tag being deployed rather than the
default branch, so the versionCode it reads matches the AAB, and it
aborts when build.gradle.kts disagrees with the tag.

The four non-English locales are unreviewed drafts and still need a
fluent-speaker pass before a release ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running against real hardware found three wrong assumptions, all of them
shared by the plan and docs/protocol-notes.md:

- as_thread returns the Thread object rather than the call's result, so
  every library and cover request came back unusable. It is fire-and-forget
  only, and is no longer sent at all.
- There is no core RPC package; core.* are published topics. Asking for
  core.version answered with an error, which made both the connection test
  and the watchdog ping fail against a healthy box. Both now ping
  player.ctrl.playerstatus.
- Cover art takes two requests: the first only starts the extraction and
  answers CACHE_PENDING, which was mistaken for a file name.

Fixing as_thread then exposed a design fault. Every screen combines the
player state with coverUrl, and combine emits nothing until all of its
inputs have emitted once, so a cover lookup that was slow or restarting
froze the title, progress and controls with it. coverUrl is now a
StateFlow filled in by a background resolver.

Also: the volume slider sent a command per frame of a drag, and the album
list was only ever fetched when the box happened to be idle at app start.

The spike, the Python probe and the protocol notes carried the same wrong
assumptions and would have re-taught them, so they are corrected too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The player's star gave no clue whether it saved the track, the folder
or the album, and the only way to favourite something in the library
was a long press nobody discovers.

Favourite actions now name their target: a tap on the star saves the
folder, a long press offers folder and track as separate entries, each
showing whether it is already saved. Every library row and album cell
gains an overflow menu with Play, save as favourite, and a details
panel built strictly from cached data, so opening it never puts a
request on the socket the box shares with its card reader.

Single tracks become favouritable in their own right, which the plan
deliberately ruled out. That costs a TRACK type and a trackUrl column
(Room schema 2, migrated rather than dropped), a track variant of the
coil://play deep link, and settings backup format 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
How deep the user is in the folder tree is view model state, not a
navigation destination, so the system back gesture never saw it and
popped straight out to the player from three levels down. Back now does
what the breadcrumb's up arrow does, and leaves the library only from
the top level. Picking the album tab is in-library navigation too, so
back there returns to the folder tab.

Both handlers register before the content they guard, which leaves an
open details sheet taking back first and closing itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Multi-box was built but unreachable. "Add box" showed in settings only
while no box existed, and the switcher holding the only other entry
opens from the top bar only once there are two boxes, so one box was a
dead end in both directions -- the feature reads as missing rather than
hidden.

Settings now always offers "Add another box", and lists the configured
boxes as a picker once there are two or more, mirroring the switcher
because settings is where boxes are configured. The collapsed top bar
for a single box (7.5) is kept, and adding a box still does not make it
active: nothing should tear down a live connection unasked.

Switching box also left the library on the previous box's folder path
and fetched it from the new one, where it need not exist. It returns to
the root instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The coil://play deep link already worked from outside the app -- the
activity answering it is exported, and home screen shortcuts are just
this URI wrapped in a ShortcutInfoCompat -- but there was no way to
obtain one: the box id it carries is a UUID shown nowhere. So the
capability read as missing.

Copy link and Share link now sit on a favourite's menu, hidden for a row
that cannot be played so the menu never offers a dead link. The copied
confirmation is suppressed on Android 13 and up, which confirms a
clipboard write itself.

AGENTS.md documents the URI format, that it is a public surface, why the
box travels in the link, and that hand-written links would call for a
host= form rather than a second id scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Phoniebox protocol has no search command and list_all_dirs is off
limits, so search runs against the Room cache: instant, free, and
working with the box switched off.

Matching goes through a folded column rather than LIKE on the raw text.
SQLite's LIKE, lower() and COLLATE NOCASE are case-insensitive for ASCII
only, so "bar" would never find "Bar" with an umlaut -- and the launch
locales are full of them. SearchText folds both the stored text and the
query the same way; schema 3 adds the columns and clears the cached
library rows instead of backfilling, since accent folding cannot be
expressed in SQL and that cache is the one disposable thing here.

What is searchable is what has been fetched, so the empty state explains
that rather than implying an empty library, and indexLibrary walks the
whole tree to close the gap. It is opt-in, pauses between levels, will
not start while the box is playing, and reports hitting its own cap --
unattended crawling of the shared RPC socket is what section 6 rules
out.

The three copies of details-sheet wiring collapse into one composable on
the way past, which is what lets search reuse it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The box has four timers and three of them switch it off, which Coil
promises never to do -- a delayed shutdown is still a shutdown, and on an
unauthenticated LAN port it is one anything on the network could
schedule. Only timer_stop_player is addressable: Commands.timer is
hard-wired to that plugin, the SUB socket names that one topic rather
than the timers. family, and a test asserts no command mentions
shutdown, host. or reboot.

Two details from the box's source that would otherwise have failed
silently. start on a running timer is ignored, so setting 30 minutes
over a running 60 would keep the 60 -- the repository cancels first. And
state is published on change only, with no per-second tick, so the sheet
asks get_state once when it opens and the countdown is interpolated
locally, as the progress bar already does.

Shuffle and repeat move into one playback options menu with the timer:
three mode toggles flanking the play button is more than the screen
carries. A running timer also shows the time left under the transport
row, since a countdown to silence should not be hidden behind a tap.

This reverses the "no timers" scope line, so AGENTS.md, the README and
the protocol notes now say which timer is in and why the rest stay out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing above the view models was covered, and the only way to see a
change to the UI — or to the four unreviewed translations — was to put
the app on a phone.

Robolectric with native graphics rather than an emulator, because of the
clock: this UI interpolates elapsed time, counts a sleep timer down and
debounces the search field, and a paused looper is what makes the same
picture come out twice. Roborazzi writes and compares the files. A plain
`./gradlew test` neither records nor fails on a golden, so outside CI the
suite costs only the guarantee that every screen still composes.

Three levels of golden: the whole CoilApp scaffold on three device
profiles, so a picture includes the top bar, the navigation and the mini
player the way the app does; the player and library screens alone, for
states that are tedious to reach through the app; and the chrome
components, where connected and degraded differ by one coloured dot.
Repositories are faked through Hilt, so a run opens no socket, no
database and no DataStore file, and cover art resolves to a flat colour
rather than the box's HTTP cache.

Goldens are recorded on the Linux runner by screenshots.yml, since text
rendering differs between hosts far enough that a file recorded on
Windows can fail CI on antialiasing alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he app

[record-screenshots]

The listing had no images at all and the landing page had nothing to
show. Both now come out of the screenshot harness, so neither can drift
away from what the app actually looks like.

Not the goldens themselves, though, and the reasons are hard ones: Play
takes 24-bit PNG with no alpha, and caps the long side at twice the
short one. Roborazzi writes RGBA, and the goldens' phone frame is 2.23:1
— rejected on both counts. Beyond that, a golden's job is to change when
the UI changes, and a store asset's is to be stable and curated; wiring
the listing to a baseline would mean accepting a re-record silently
rewrote the shop window.

So StoreAssetTest renders its own frames (phone at 1.995:1, as tall as
Play allows; 7-inch and 10-inch tablets), from StoreFixtures content
chosen to read at thumbnail size rather than to stress the layout. The
alpha channel is flattened afterwards by a Gradle task, since java.awt
is not on an Android unit test's classpath, and the phone set is copied
into the Pages site from there.

check_store_metadata.sh now validates images beside the text — format,
alpha, dimensions, aspect ratio, file size and count — reading the PNG
header with od so it still needs nothing installed. google-play.yml
already runs that script, so an image Play would refuse now fails the
deploy instead of the upload.

screenshots.yml also takes a commit-message trigger. workflow_dispatch
only works once a workflow has reached the default branch, which for
this repo means after a release; [record-screenshots] in a push to
develop does the same job today, and keeps re-recording an explicit act.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[record-screenshots]

Many families run this on the family tablet, and there the player was
the one screen that did not work: a full-width square cover is taller
than a landscape window, so the transport controls — the entire job of
that screen — sat below the fold. The tablet golden had been showing
exactly this since it was recorded.

On a window wider than it is tall, or wider than Material's expanded
breakpoint, the cover now sits beside the title and controls. A 7-inch
tablet held upright stays in one column, where two panes would only be
two cramped ones. The same rule fixes a phone held sideways, which has
width to spare and no height at all, and the single-column path now caps
the cover against the window height rather than only the width, so a
short window keeps its controls either way.

Progress, transport, the timer line and volume are one composable used
by both layouts. A control that appeared on a phone but not on a tablet
would be a bug nobody notices for months.

The other screens are unchanged: stretched single columns read as roomy
rather than broken.

Also pins Robolectric to SDK 35. Its SDK 36 image needs a newer JDK than
the Temurin 17 every workflow pins, so the screenshot tests ran on a
developer machine and threw on the runner — which is where the goldens
are recorded, so it took the whole recording job down with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[record-screenshots]

The script is checked in 0644, like every tool here, so invoking it
directly failed the recording job with "Permission denied" after the
goldens had already been rendered. google-play.yml and /release both
call it through bash; this was the one caller that did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite was built on the assumption that text rendering differs
between hosts, and said so in three places. A recording run on the
Linux runner against goldens recorded on Windows has now reported every
file byte-identical, so the assumption was wrong for this project:
Robolectric brings its own fonts and its own Skia.

What does matter is the SDK level and the JDK major version, which is a
separate note and already recorded next to the sdk=35 pin.

screenshots.yml keeps its place as the way to accept a new look without
a local toolchain; it is no longer described as the only trustworthy one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[record-screenshots]

Both tools carry a shebang and are meant to be run, so the mode should
say so. Invoking them by path failed the recording job with exit 126 —
a message that names permissions rather than the missing bit — and the
workaround was to prefix `bash` at each call site, which every caller
then had to remember.

The mode is now 100755 and the four call sites drop the prefix: CI,
google-play.yml, /release and the fastlane README. The script's header
says to keep the mode, because `git add` on a new tool will not set it
and the failure it produces does not point at the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@EarMaster
EarMaster merged commit 5798a47 into main Aug 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants