- artist_detail_screen.dart missed an `import '../models/artist.dart'`
for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
parameters in <...> which lint reads as HTML. Switched to backticks.
Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
Three issues, all related to the player surface:
1. Player UI didn't update on track change. audio_handler's
_onCurrentIndexChanged only kicked off the cover load — it never
pushed the new MediaItem onto the mediaItem stream. Title/artist/
cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
set on first play. Now the listener pushes queue[idx] when the
index changes.
2. Player kebab "Go to artist" 404'd while the same item from
MostPlayed worked. Same TrackActionsSheet for both, but the
player's _trackRefFromMediaItem was hardcoding artistId: ''
because audio_handler's _toMediaItem never stashed it in extras.
Stash artist_id alongside album_id; player_bar +
now_playing_screen read it back. Both kebabs now navigate.
3. "Start radio" didn't exist on Flutter even though the server has
/api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
fetches + plays the result via the existing playTracks path.
New menu item between "Add to playlist" and the divider above
"Go to album", calls startRadio with a snackbar error fallback.
System playlists now show in the home row (good!) but tapping them
landed on an empty playlist. Same root cause as the earlier album
bug: playlistsListProvider wrote the playlist *row* to drift but
never the tracks. playlistDetailProvider only triggered cold-fetch
when the row was missing, so it yielded with an empty track list.
Refactor playlistDetailProvider to mirror albumProvider's approach:
- fetchAttempted guard (one-shot per subscription).
- Detect "playlist row exists but trackRows empty" and trigger the
same fetchAndPopulate the cold-cache path uses. Drift watch
re-emits with populated tracks.
- 10s timeout on the API fetch + diagnostic prints so any failure
surfaces in logs.
While here, fix two adjacent issues:
- Wipe + re-insert this playlist's track positions on every fetch
so server-side deletions actually propagate. Without the wipe,
removed tracks would linger in drift forever.
- Write the artist + album rows referenced by the fetched tracks
into cachedArtists / cachedAlbums (deduped). Without this, the
joined artistName/albumTitle columns in the playlist track rows
surface empty.
Cover art for system playlists is a separate issue — server emits
coverUrl but it may be empty for system mixes; PlaylistCard falls
back to the queue_music icon. Will tackle in a follow-up.
Artists tab 404: client called /api/library/artists, but the server
mounts the artists list at /api/artists (handleListArtists). Albums
sit at /api/library/albums for historical reasons — the paths aren't
symmetric. Switch listArtists() to /api/artists with sort=alpha.
Albums tab grid: matched the responsive 3-up layout we built for
artist detail. LayoutBuilder computes cellW from available width;
AlbumCard sized to the cell with titleMaxLines: 2; mainAxisExtent
matches actual content height (cover + 2-line title + artist line +
fudge). No more wide-aspect cells with empty space below the card.
Also wired `extra: ref` on the artists/albums grids and the Liked
tab so detail-screen nav hydration kicks in here too — taps from
library screens get the same instant header that home tiles do.
Three changes addressing the cold-start spinner + stale-on-revisit pain.
A. SWR on remaining cacheFirst providers
- artistProvider, artistAlbumsProvider, artistTracksProvider all gain
alwaysRefresh: true. Cache hit still renders instantly; one
background refresh per subscription keeps the row from going stale
forever.
- albumProvider (inline async*) and playlistDetailProvider (inline
async*) now keep a `revalidated` flag and kick a one-shot
background fetch on the first complete cache hit. Same effect as
cacheFirst's alwaysRefresh, just inline.
- Three providers gained the connectivity timeout that
album/artist/playlist already had.
B. Navigation hydration
- AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen
accepts `ArtistRef? seed`. When the live provider is still loading,
the seed populates cover/title/artist immediately so the page
isn't blank.
- Routing wires `extra: AlbumRef|ArtistRef` from go_router into
the seed parameter.
- Call sites updated: home (Recently added, Rediscover albums +
artists, Last played), artist detail album grid. Where a ref isn't
available (track actions sheet), the screen falls back to the
spinner — no regression.
C. Cold-start home skeleton
- Replace the full-screen CircularProgressIndicator on /home with a
layout-preserving skeleton: 5 section titles + 6 grey card-shaped
placeholders per row. The page feels populated immediately;
sections fill in independently as data arrives via the per-section
providers.
- Drops the unused DelayedLoading import.
Net effect: re-visits to detail screens render instantly (cache hit
+ silent refresh); first visit from a tile shows the seed header
immediately while tracks load; cold-start home shows a layout
skeleton instead of a 30s blank spinner.
Web UI shows system playlists; Flutter shows placeholders. Wire shape,
adapters, drift schema, and filter constants all line up on inspection,
so adding instrumentation to pinpoint where the system rows fall out:
- Log what /api/playlists?kind=all actually returns (owned/public
counts, including how many of owned are system).
- Log what cacheFirst sees on each drift emit: total rows, filtered,
how many are system, how many landed in owned vs pub, plus the
user.id used for the owned-vs-pub split.
Also add the 3s connectivity timeout that albumProvider/artistProvider
got — keeps the alwaysRefresh path from blocking on a stalled
connectivity stream.
Three log lines from one home-screen visit will tell us:
- Does the server emit system rows? (wire log: system=N)
- Do they land in drift? (drift log: filtered system=N)
- Do they survive the user-id filter? (drift log: owned system=N)
Two test failures from the comparator rewrite:
1. "different unparseable strings → newer" was useful — branch-name
builds (e.g. PackageInfo reports 'main' while server reports
'dev') should still surface the banner so the operator sees that
something is misaligned. Restore that fallback: if both sides
parse to all-zero components (no numeric structure on either),
compare as strings.
2. "honors prerelease ordering" tested pub_semver behavior we no
longer use. Replaced with tests that cover what the new
comparator actually does: zero-padding for length mismatch,
leading-zero normalization, 4-part date+build comparisons, and
month-rollover correctness without leading zeros.
flutter pub get rejects "2026.05.11.0+1" — it requires strict
MAJOR.MINOR.PATCH+BUILD with no leading zeros. Use 2026.5.11+1
as the local default; CI's --build-name="${TAG#v}" still injects
the full 4-part tag for release builds, so production APKs report
the tag verbatim while local dev builds get a sensible recent value.
Update banner showing on identical versions:
- pubspec.yaml was stuck at the placeholder 0.1.0+1, so
PackageInfo.version returned "0.1.0" while the server reported the
actual release tag (e.g. "2026.05.10.1"). Comparison correctly said
"newer" → banner always showed.
- Bump pubspec to 2026.05.11.0+1 so the local default matches the
release cadence even before CI overrides it.
- Update flutter.yml release step to pass --build-name="${TAG#v}" so
every tagged APK reports the tag as its PackageInfo.version. Future
releases stop drifting from pubspec.
- Rewrite isVersionNewer to do component-wise int comparison with
zero-padding: pub_semver.Version.parse rejects 4-part date versions
like "2026.05.10.1", at which point the old code fell back to
string inequality and treated "2026.05.10" as newer than itself
vs "2026.05.10.0". Drop the pub_semver import (no longer used).
Lock-screen play/pause not responding:
- PlaybackState only listed MediaAction.seek in systemActions, which
on Android 13+ means tapping the lock-screen play/pause button
doesn't route back to the AudioHandler. Add play, pause,
skipToNext, skipToPrevious to the set.
- Add androidCompactActionIndices: [0, 1, 2] so the compact
notification view explicitly maps the three buttons.
Album art being smaller than the lock-screen frame is upstream of
this commit — the cover-cache writes whatever pixel dimensions the
server returns. If the server's /api/albums/<id>/cover returns small
thumbnails for these albums, the lock screen renders them at that
size. Worth a separate look at the server cover-emit path.
artistAlbumsProvider populates cachedAlbums (album rows only) without
ever fetching their tracks. When the user taps from the artist grid
into an album, albumProvider sees a drift hit on the album row, never
triggers cold-cache, and yields with an empty track list — visible as
the album header rendering above an empty body.
Refactor albumProvider's cold-cache logic into a helper closure that
either branch (no album row OR album row + no tracks) can call. Add a
fetchAttempted guard so a server response that legitimately returns
zero tracks doesn't loop forever.
Decision flow:
- albumRows empty: cold-fetch (existing path), yield empty on
failure/offline
- albumRows hit but trackRows empty AND not yet attempted: same
cold-fetch — drift watch re-emits with the populated tracks
- albumRows hit, trackRows hit (or attempted already): yield as-is
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
numbers cramp.
Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
track in album detail; layout change wasn't the cause. Real cause:
albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
never cachedArtists. The drift JOINs that build the AlbumRef + each
TrackRef returned null for the artist row, so artistName fell back
to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
off the album header + each track, inserts them into cachedArtists
alongside the album/tracks. Subsequent JOINs populate artistName,
the audio handler stamps it on MediaItem, and the mini player picks
it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
re-fetched (cache invalidation TBD). Future cold-cache hits will.
Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
detail grid since the page header already names the artist. cellH
trimmed by the 16dp artist line.
AlbumCard gains optional `width` (default 140 keeps horizontal lists
unchanged) and `titleMaxLines` (default 1) so callers can let the
title wrap to a second line. Cover sizes to width - 16 instead of a
hardcoded 124, so the card scales down cleanly when a narrower cell
width is passed in.
Artist detail grid: switch to 3 columns. LayoutBuilder computes the
cell width from the available width so AlbumCard sizes to the cell
exactly (no overflow). cellH = cover + gap + 2-line title + artist
line + small fudge — tight enough that there's no visible gap below
the card, tall enough to fit a wrapped title.
Drift cache intentionally drops cover_url (server-derived). The
adapters comment says "REST cold-cache fallback briefly shows the real
values before drift takes over" — but once drift takes over, covers
are empty forever. Fix per source:
- Album cover: server constructs the URL deterministically as
/api/albums/<id>/cover (internal/api/convert.go:69). Mirror that
in CachedAlbumAdapter.toRef so AlbumRef.coverUrl is non-empty
whether the row came from a fresh fetch or a drift hit. Restores
cover art on the artist detail album grid (and any other surface
reading albums from drift).
- Artist cover: server picks a representative album and reuses its
cover (convert.go:98). Drift doesn't store the pointer, so derive
client-side via the artist's first loaded album. New _ArtistAvatar
prefers a non-empty server-emitted coverUrl and falls back to
/api/albums/<firstAlbumId>/cover, then slate while the album list
is still loading.
Album grid spacing was off because childAspectRatio: 0.8 inflated
each cell taller than the AlbumCard's actual ~160dp footprint,
leaving a visible gap below every card. Switch to mainAxisExtent: 168
with explicit 8dp main/cross spacing — cells now match the card and
sit on a clean grid.
Move the like + kebab buttons out of the title row and place them as
siblings to the title/artist column. Row's default crossAxisAlignment
centers them against the row's full 48dp height (set by the album
art), so visually they sit at the vertical center of the title+artist
block instead of being pinned to the title baseline.
Width stays 32dp each — horizontal footprint matches what we had.
Bumped icon size 18→20 since they have more vertical space to occupy.
Album detail screen rendered the cover slot as a bare slate Container
— no actual image. Wire ServerImage at /api/albums/<id>/cover (96dp,
rounded). Same for artist detail: ClipOval around ServerImage of the
artist's coverUrl, falling back to slate when empty.
Artist navigation hang: artistProvider goes through cacheFirst, which
had no logging — so we couldn't see whether the cold-cache fetch was
running. Add an optional `tag` parameter that, when set, debugPrints
each step (drift hit/miss, connectivity, fetch start/done/error).
Wire tags into artistProvider and artistAlbumsProvider, plus add the
3s connectivity timeout there too as belt-and-suspenders.
Next test pass should show cacheFirst[artist(<id>)]: lines that
pinpoint where the artist screen is sticking.
Two changes to surface where detail-screen spinners hang:
1. Connectivity().checkConnectivity() goes over a platform channel
that on some Android builds stalls. Wrap with a 2s timeout and
default to "online" if it times out — being wrong about
connectivity costs at most one failed fetch, but being stuck
spins the UI forever.
2. albumProvider's cold-cache flow now debugPrints at every step:
drift miss → connectivity result → API call → drift write → ack
the re-emit. Also adds a 10s timeout on the API fetch and a 3s
timeout on the connectivity await so a hang surfaces as an
error-yielded empty AlbumRef instead of an infinite spinner.
Once the operator's next test produces logs we can pinpoint which
step is hanging. The diagnostic prints stay until the issue's root
cause is confirmed; will be removed in a follow-up.
Root cause for "only the first tile loads, others spin forever":
Connectivity().onConnectivityChanged only emits on connectivity
*changes*, not on subscription. Cold-cache code paths in albumProvider,
artistProvider, playlistsProvider, likes etc. all gate their fetches
on `await ref.read(connectivityProvider.future)`. Until the OS
happened to report the first connectivity flip, that future never
resolved — so any detail screen for an album/artist/playlist not
already in the drift cache hung indefinitely at the connectivity
check.
The "first tile" appeared to work because by the time the user
tapped it, a connectivity event had landed (or that album was already
cached from a prior session). Subsequent tiles whose data wasn't yet
cached blocked.
Switch the provider to async* and seed it with an immediate
checkConnectivity() before forwarding the change stream. .future
now resolves on first read regardless of whether the OS has reported
a change yet.
Image with width+height but no fit paints the source at its intrinsic
resolution inside the box. The cover-cache thumbnails are smaller
than 48dp, so the art rendered as a tiny inset in the slate box
instead of filling it. Cover stretches/crops uniformly to fill.
Why the seek bar appeared frozen: it was reading
PlaybackState.updatePosition, which audio_service only updates on
event transitions (play/pause/buffer/seek). Between events it sits
unchanged, so the bar only jumped at intervals.
Expose just_audio's positionStream (~200ms cadence) from the audio
handler, wrap as positionProvider, and read that in both the mini bar
and the full player. Now the bar advances continuously while playing.
While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
Title (22pt parchment) → artist (14pt ash) → album (12pt ash). Album
is conditional — drops out if MediaItem.album is empty rather than
leaving a blank line. Tightened the gap to the seek bar (24→20) to
absorb the new line on small screens.
You correctly diagnosed: the mini bar was still visible underneath
the full player, and that's also why the bottom controls overflowed
by 45px (the shell's mini bar was eating the bottom 88dp).
Move /now-playing OUT of the ShellRoute and up to a top-level GoRoute.
Now pushing /now-playing unmounts the shell entirely — no mini bar
underneath, no fight for the bottom strip. The slide-up transition
still feels right because the shell stays painted for the duration of
the animation (so during the transition both are briefly visible, as
you noted is fine).
Drop the hideMini conditional in _ShellWithPlayerBar — no longer
needed since the shell isn't even built when the full player is on
top. Recovering the 88dp also resolves the bottom-controls overflow
without needing to shrink the layout.
Mini player simplifies to track-info + prev/play/next. The
shuffle/repeat/queue cluster moves to the full player, where it has
the room to breathe.
Full player (NowPlayingScreen) now has:
- Real album art (FileImage when artUri is file://, ServerImage from
/api/albums/<id>/cover otherwise — same auth-aware loader as the
rest of the app, with errorBuilder so a failed fetch falls back to
a slate placeholder instead of a broken-image glyph).
- Title row with inline like + kebab.
- Full-width seek with start/end timestamps below.
- 36/72/36 prev/play/next.
- Secondary row: shuffle, repeat, queue.
Mini ↔ Full transition mechanics:
- Tap mini → push /now-playing with custom slide-up transition
(CustomTransitionPage, 280ms easeOutCubic).
- Vertical drag-up flick on mini (>200 px/s) → same push.
- System back / leading expand-more icon → Navigator.pop, which the
CustomTransitionPage replays as a slide-down (240ms easeInCubic).
- Drag-down on full player past 80px OR a >500 px/s downward flick
→ Navigator.pop. Buttons and the seek slider stay tappable since
Flutter's gesture arena gives priority to the more-specific child
recognizers.
- Mini player hides while /now-playing is the active route — the
full player IS the player UI in that state, no need to fight over
visual space.
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.
Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:
- InkWell registers via the Material ripple system instead of the
raw gesture arena, sidestepping any subtle deferToChild edge cases
inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
visually — if the ripple shows but nothing navigates, the issue is
on the route side; if no ripple, the tap never landed.
Material color is transparent so the existing dark page background
shows through unchanged.
You correctly diagnosed the symptom: the AppBar gets taller when the
banner is present. Why: the shell is [Banner, Expanded(child),
PlayerBar] and the banner uses SafeArea(bottom:false) to clear the
status bar. But MediaQuery.padding is screen-relative — the child's
Scaffold/AppBar reads MediaQuery.padding.top and applies its own
status-bar inset on top of the banner, doubling the gap.
Watch shouldShowUpdateBannerProvider in the shell. When it returns
non-null (banner visible), wrap the child in MediaQuery.removePadding
removeTop: true so the AppBar mounts directly under the banner. When
the banner is hidden, child gets its normal top inset.
The 401s on /api/albums/<id>/cover were the root cause of "only the
first tile is navigable" — failed images leaving slate placeholders
that combined with the deferToChild hit-test (fixed in d703fc2) to
silently swallow taps.
ServerImage:
- sessionTokenProvider is a FutureProvider; .value is null after a
hot restart until secure-storage resolves. Old code fired
Image.network with headers:null at that moment, the network image
cache locked in the 401 response, and never retried. Switch to
AsyncValue.when so the widget waits for the token before mounting
Image.network with the auth header attached.
- Add errorBuilder so a single failed image renders the parent
fallback instead of leaking a stack trace + broken-icon glyph.
Player bar:
- media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping
it in NetworkImage attempts HTTP on a file:// URI and fails. Use
FileImage when the scheme is file, else NetworkImage.
Net effect: failed image loads no longer leak errors or block other
widgets from rendering / receiving taps.
Three issues from the screenshot:
1. Title invisible: LikeButton + TrackActionsButton were raw IconButtons
with the default 48dp tap target, eating ~96dp of the title row.
Wrap both in 32×32 SizedBox at the call site so the title actually
gets enough room to render (still ellipsizes if needed).
2. Layout per operator's revised spec: track info gets a 2:1 share of
the row vs the controls column. The controls column now stacks the
shuffle/repeat/queue sub-row above the prev/play/next sub-row, both
right-aligned.
3. Right-edge 2px overflow: previous play controls were 40+48+40=128dp
in a flex-1 slot of ~110dp. Resize to 32+40+32=104dp matching the
shuffle/repeat/queue row above (3×32=96dp), with the play button
slightly larger to keep visual hierarchy.
Drop the volume provider import since the slider is gone (handled by
phone hardware buttons).
Same root cause as the playlist card fix in f65650f — GestureDetector
defers to children by default, so taps over the cover image area
(ServerImage / SVG fallback) didn't reliably propagate. Mark the
detector opaque so the entire card surface is tappable, not just the
text below the cover.
Player bar: drop the volume slider — phone hardware controls volume.
Right cluster collapses to a single row of shuffle/repeat/queue.
Playlist card: GestureDetector defers to children by default, so taps
over the cover image area didn't always register. Add HitTestBehavior.opaque
so the whole card surface is tappable.
Update banner: was 3-4× the height of its text — IconButton's default
48dp tap target plus generous vertical padding dominated. Tighten
padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim
the action button to 28dp height with shrinkWrap tap target.
Home screen: ClampingScrollPhysics + 140dp bottom padding (already
applied in working tree) so scroll stops just above the mini-player.
The previous commit message said I added data-testid="player-bar-compact"
+ "player-bar-desktop" to PlayerBar.svelte but the edits actually
dropped silently — only the test file changes landed. Without the
testids, the test file's within(getByTestId(...)) calls all fail.
This adds the actual attributes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bundled fixes:
### Web: PlayerBar test failures
PlayerBar renders both compact (md:hidden) and desktop (hidden md:flex)
blocks; jsdom doesn't apply CSS media queries so both DOM trees are
present in tests. screen.getByRole/getByText found duplicates →
14 test failures.
Added data-testid="player-bar-compact" + "player-bar-desktop"; tests
scope queries via within(getByTestId(...)). Compact is the focus of
#358, so most tests scope there. The "Up next" subgroup explicitly
scopes to desktop since that copy was dropped from compact.
### Flutter: system playlists not loading
cacheFirst was too conservative — when drift had user-created
playlists from sync but no system playlists (For-You / Discover),
fetchAndPopulate never fired (drift wasn't empty). Result: home
tile row showed user playlists but never the system ones.
Added cacheFirst alwaysRefresh option = stale-while-revalidate.
playlistsListProvider opts in: yields cache immediately, then kicks
off REST refresh in background. Drift watch() picks up the new rows
and re-emits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the kebab-with-popover PlayerOverflowMenu approach with all
player controls visible inline. Operator's stated intent was "two-row
layout with all controls visible," not "kebab hides shuffle/repeat/
queue/volume" — restructured to match.
### Compact view (below md)
┌─────────┬───────────┬───────────────────┐
│ art │ ♥ ⋮ │ 🔀🔁 ☰ │ ← short sub-row
│ title │ │ │
│ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play row at full size
├─────────┴───────────┴───────────────────┤
│ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
└─────────────────────────────────────────┘
- Column 1: cover + title/artist
- Column 2: like+kebab on top sub-row, play controls (40/48/40) below
- Column 3: shuffle/repeat/queue on top, volume slider below
- Bottom: full-width seek slider with time labels
### Desktop view (md+)
Existing 3-column layout preserved; only change is the redundant
PlayerOverflowMenu kebab (which had been mounting at all widths)
is no longer there. Volume + shuffle + repeat + queue stay inline
in the right cluster as before.
### Cleanup
- Deleted PlayerOverflowMenu.svelte + its test (no callers remain)
- The TrackMenu kebab (per-track actions) stays — it's a varying
list of actions that doesn't fit inline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:
### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
"status" + "min_client_version". Backward-compat: existing clients
ignore unknown JSON fields. Endpoint stays unauthenticated.
### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
go build -ldflags so the binary's ServerVersion is stamped at
link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
output (the git tag for tag pushes, "main" for branch pushes);
build step passes it as `--build-arg MINSTREL_VERSION=...`.
### Web
- web/src/lib/components/ServerVersion.svelte: small understated
text ("Server v2026.05.10.2") that fetches /healthz on mount.
Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server's clientVersionResponse marshals to {version, apk_url,
size_bytes} but the component was reading body.apkUrl / body.sizeBytes
(camelCase). The !body.apkUrl guard was always true → early return →
download button never rendered even when /api/client/version returned
200 with valid data.
Verified directly: /api/client/version returns
{"version":"v2026.05.10.1","apk_url":"/api/client/apk","size_bytes":65301242}
on the deployed v2026.05.10.1 image; the UI just wasn't reading those
keys.
Map wire (snake_case) → component (camelCase) explicitly via a
WireInfo type so the boundary is auditable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When /api/client/version returns 404 (no APK in /app/client/, e.g.
v2026.05.10.0 which had a failed CI APK-attach step), the previous
shape rendered the section heading + intro paragraph with an empty
hole where the button should be. Confusing — looks like a broken
button.
Moved the section wrapper, heading, and intro paragraph INSIDE
MobileAppDownload so the whole block collapses together when info
is null. Settings page just mounts <MobileAppDownload />; nothing
visible if no APK is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both prior secrets did related work (one for registry push, one for
release asset I/O) and required separate Forgejo PATs to manage.
Replacing with a single CI_TOKEN keyed off a single PAT with the
union of scopes (write:repository + write:package).
- flutter.yml: APK attach step → CI_TOKEN
- release.yml: docker login → CI_TOKEN; APK fetch step → CI_TOKEN
No behavior change — same calls, single secret name. Operator needs
to set CI_TOKEN in repo settings (Actions → Secrets) with a PAT that
has write:repository + write:package; the prior REGISTRY_TOKEN /
RELEASE_TOKEN secrets can be removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.
### Server
- internal/api/client_assets.go:
- clientAPKAllowDownload(): in-memory map[userID]time.Time under
a mutex. Returns 0 (allow) or wait duration (block).
- handleClientAPK reads user from context, checks the limit,
returns 429 + Retry-After header if blocked.
- testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
different user gets a fresh slot). Existing tests updated to use
authedRequest() helper.
### Web
- MobileAppDownload.svelte: switched from bare fetch (no credentials)
to api.get<>() which carries the session cookie. 404 / 401 /
network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
never show this. Settings → Mobile app section keeps it for
logged-in users.
Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the discoverability gap on the in-app update flow — the
/api/client/apk endpoint exists but had no web UI surface to find
it. User asked to be able to "visit the site on my phone and
download the apk from there."
- web/src/lib/components/MobileAppDownload.svelte — fetches
/api/client/version once on mount; renders a download link with
version + size when 200; renders nothing on 404 (no APK bundled,
graceful degradation in dev environments and pre-CI-wiring images).
- Mounted on the login page (below the Register link) so the link
is discoverable without authentication. The /api/client/* endpoints
are themselves unauthed, so the flow works end-to-end for any
visitor on a phone.
- Also mounted in Settings → Mobile app section for logged-in users
who want to grab the matching APK for sideloading on a different
device.
Browser handles the download via the server's existing
Content-Disposition: attachment; filename="minstrel.apk" header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pub_semver is permissive with leading zeros — '2026.05.10' parses as
2026.5.10, so date-style tags get strict semver ordering, not the
unparseable-fallback path. The test was asserting "any difference =
newer" for date strings, which is incorrect; the actual behavior
(and the right behavior) is proper ordering.
Split the test group into two:
- date-style (parses as semver): newer/older/equal assertions
- truly unparseable (e.g. branch names like 'main', 'dev'): the
fallback's "any difference = newer" semantics still apply
Implementation in client_update_provider.dart unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix-forward intended this change but the Edit didn't
make it into the commit (only the Go side landed). Re-applying the
StateProvider → NotifierProvider conversion so flutter analyze stops
failing on the undefined-function for StateProvider.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
_dismissedVersionsProvider with a small Notifier<Set<String>>
+ NotifierProvider, mutating via an `add(version)` method.
Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
on test cleanup, no manual Unsetenv needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final phase of the in-app update flow. release.yml on tag pushes
fetches the APK that flutter.yml is attaching to the same release,
drops it into client/ in the build context, and the Dockerfile's
COPY client/ /app/client/ bakes it into the image.
### How it sequences
flutter.yml and release.yml both trigger on tag pushes and run in
parallel on different runners (flutter-ci vs go-ci). flutter.yml
typically finishes APK build + release attachment in 2-5 min.
release.yml polls the release page for up to 15 min for the APK to
appear, then proceeds. If the APK never lands (flutter.yml failure,
network hiccup), release.yml emits a warning and ships the image
without the bundled APK — server returns 404 from /api/client/version,
banner stays hidden, manual download from the release page still
works. Graceful degradation, not a blocker.
### Repo shape
- client/.gitkeep + client/README.md so the directory exists in git
and the COPY in the Dockerfile always finds something to copy
- .gitignore excludes client/minstrel.apk + .version so accidental
commits don't bloat the repo
- Dockerfile: COPY --chown=minstrel:minstrel client/ /app/client/
### Why polling vs cross-workflow trigger
Forgejo Actions' workflow_run support varies by runner version; the
polling approach is universally compatible. If/when we standardize on
a runner that handles workflow_run cleanly, the polling step can
become a `needs:` dependency.
Closes#397.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.
### Dart side
- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
polling on app start + every 24h. Compares semver via pub_semver
with non-semver string-equality fallback. Server 404 = "no update
channel" = silent. Companion shouldShowUpdateBannerProvider gates
on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
downloading (with progress) / error stages. Mounted in
_ShellWithPlayerBar above the route content; SizedBox.shrink()
when no update available so non-shell routes (login, server-url)
see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
semver, prerelease ordering, leading-v normalization, and
non-semver fallback (date-tag style).
### Android side
- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
downloaded APK can be served via content:// URI (file:// is
blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
so the system installer can read across the process boundary.
Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>