HomeViewModel.playPlaylist (the Home play-button overlay path) and
PlaylistDetailViewModel.play (the detail-screen path) both converted
PlaylistTrackRef -> TrackRef before player.setQueue, but only the
detail-screen path filtered out unplayable rows (missing trackId or
empty streamUrl). Home's path passed them through; Media3 then
silently no-op'd on setUri("") and the queue appeared loaded but
nothing started.
Operator reported "Songs-like playlists queue music that never
plays" via the Home play-overlay 2026-06-01. The same conversion in
two places with different rules is exactly the foot-gun the §1-§3
DRY pass was supposed to head off; this commit adds it as a follow-up
since the playlist-play helper wasn't covered by that sweep.
Extracts `List<PlaylistTrackRef>.toPlayableTrackRefs()` in
playlists/data/PlaylistsRepository.kt. Filters (isAvailable &&
streamUrl non-empty) THEN maps to TrackRef, in one pass. Both call
sites now use it.
The remaining private `toTrackRef()` in PlaylistDetailScreen is kept
for the per-row TrackActionsButton wiring - the actions menu only
needs id+display fields and doesn't queue anything itself.
Cover + LikeButton + 3 transport buttons + kebab consumed almost all
of the 360-410dp viewport on typical phones, leaving the title /
artist column with only ~20-80dp - enough for a truncated title but
nothing for the artist line, which was rendering but effectively
unreadable.
Drops TrackActionsButton from MiniRow and the now-unused
onNavigateToAlbum / onNavigateToArtist callbacks from the MiniPlayer
signature + ShellScaffold call site. Full kebab surface still lives
on NowPlayingScreen (one screen swipe away).
Operator request 2026-06-01. Diverges from Flutter's player_bar
which still ships the kebab; parity-map row updated to reflect the
deliberate divergence. ShellScaffold's navController parameter is
kept on the signature for future shell-level navigation needs.
Operator device check 2026-06-01: the multi-row grid landed, but the
square 140dp tiles (web-matching) waste vertical space at the per-
tile level. Flutter's CompactTrackCard
(flutter_client/lib/library/widgets/compact_track_card.dart) uses a
horizontal-row pattern - 176dp wide, 56dp tall, 48dp cover thumb +
title/artist column to the right. Much denser; fits ~3x more tiles
in the same screen area.
CompactTrackTile rewritten to that shape:
- Row layout instead of Column
- 48dp square cover (down from 140dp)
- Title + artist column to the right, vertically centered
- 176dp x 56dp outer
MOST_PLAYED_GRID_HEIGHT_DP cut from 600 to 200dp (3 * 56 + 2 * 8
spacing). Skeleton matches the new dimensions.
Diverges from web (which uses square tiles same as Android's prior
shape) - intentional, operator preference. TrackActionsButton from
Flutter's card is omitted for now (would need nav callbacks threaded
through MostPlayedRow); follow-up if kebab-on-tile is wanted.
PlayerController only ran the UI-state copy on Media3 Player.Events
callbacks (track change, pause/play, buffer state) - none of which
fire on normal position advancement during playback. The seek bar
appeared to update every 20-30 seconds, whenever a buffer event
happened to fire, instead of ticking smoothly.
Adds a startPositionPolling() coroutine launched on
Dispatchers.Main.immediate after the controller is connected.
Samples controller.currentPosition + bufferedPosition every 500ms
while isPlaying; skips state updates while paused (positionMs stays
at the last value, which is the correct paused-state behavior).
The poll patches only the position fields via .copy() so stale
event-driven snapshots can't clobber the latest state.
Cadence reference: Flutter via just_audio positionStream runs at
~200ms (audio_handler.dart:75); Spotify / Apple Music sit around
250-500ms in-app. 500ms is the battery-friendly middle ground that
still reads as smooth on a slider. Lock-screen / notification
surfaces are driven by Media3's MediaSession separately and don't
need this poll.
Liked tracks now render a filled heart (HeartFilled, defined inline
in LikeButton.kt) instead of just recoloring the outlined heart.
Matches the cross-app convention (Spotify, Apple Music, etc.) where
fill is the affordance change rather than tint alone.
Lucide ships outlined-only by design (jar inspection: heart.kt,
heart_crack.kt, heart_off.kt, etc., none filled). Built the filled
variant locally from Lucide's exact path data so the toggle reads as
a fill swap, not a different shape. No new dep - keeps the project's
Lucide-everywhere icon system consistent.
Operator authorized the divergence 2026-06-01 ("changing to material
for this one symbol... is acceptable"); inline ImageVector route
preserves Lucide visual harmony better than mixing material-icons
just for one glyph.
CachedTrackEntity.toDomain() was emitting TrackRef.streamUrl="" (the
default). Tracks routed through MetadataProvider (the Home cache
hydration path used by Most Played) inherited the empty string;
player.setQueue() then got a queue of unplayable tracks.
The server's stream_url is deterministic per track id
(internal/api/convert.go:75 streamURL builder), so the cache mapper
can reconstruct it from the row's id without storing it in Room.
TrackWire.toDomain() continues to use the wire-provided value
(identical content).
Operator reported "tap to play wiring in Most Played queues content
that can't play" on 2026-06-01. The Most Played multi-row layout
itself isn't the cause - the same gap affected the single-row
layout - but the denser display made the dead taps more obvious.
CI on 58213779 caught the missing import: the previous code used
Arrangement only inside HorizontalScrollRow (which imports it
internally), but the inline LazyHorizontalGrid for Most Played
now needs it at the HomeScreen.kt scope. ktlint passed because
it doesn't follow type resolution; the kotlinc compile step
flagged it.
Web Home (web/src/routes/+page.svelte:196) stacks the 75 most-played
tracks into 3 rows of 25 inside a single horizontal scroller; Android
was rendering them as one long LazyRow which makes the section feel
sparse and forces a lot of horizontal scrolling to reach mid-rank
tracks. Operator request 2026-06-01.
Replaces MostPlayedRow's LazyRow with a LazyHorizontalGrid where
rows=Fixed(MOST_PLAYED_ROWS=3). Cards stay 140dp (matching web's
w-36); density gain comes purely from stacking.
Web is row-major (ranks 0..24 across the top row); LazyHorizontalGrid
fills column-major. To match web's visual order, the input list is
pre-chunked into 3 row-major rows then re-flattened column-by-column
before being handed to the grid. With 75 tracks and 3 rows that's
25 columns, ranks 0/25/50 in column 0, 1/26/51 in column 1, etc.
MOST_PLAYED_GRID_HEIGHT_DP=600 budgets ~188dp per row (140 cover +
8 spacer + 2 lines of text), plus 2 * 8dp inter-row spacing.
Diverges from Flutter (still single-row); intentional, parity-map
updated. Backflow to Flutter not tracked yet because the Flutter
client is being retired.
Server's playlists registry has 8 system kinds (internal/playlists/
system.go:281-290) but Flutter Home only ever showed 5 slots:
For You + Discover + 3 Songs-like. Operator request 2026-06-01: have
the Android Home view also surface the 5 secondary kinds so the
user can find them without digging into the Library > Playlists list.
Android buildPlaylistsRow now appends deep_cuts, rediscover,
new_for_you, on_this_day, first_listens in server-registry order
after the existing Songs-like slots, when those playlists actually
exist. No placeholders for the new 5 since they depend on library
shape (deep albums for Deep cuts, prior history for On this day,
etc.) and an empty placeholder would imply they're still building
when they may just have no candidates.
PlaylistCard.kt systemLabelFor gains explicit labels for the new
kinds and drops the dead "todays_mix" branch (no such variant in
the server registry).
This diverges from Flutter Home which never surfaces the secondary
kinds; web UI catch-up tracked as task #53. Naming note: the
"Rediscover" playlist tile and the unrelated "Rediscover"
recommendations section on the same Home both read "Rediscover" -
operator follow-up to disambiguate.
Adding the play-button overlay in the previous commit pushed
PlaylistCard from 49 to 62 lines, two over detekt's 60-line cap.
Splitting the cover stack into its own private composable
(PlaylistCardCover) brings the outer function back under the limit
while keeping the call site readable.
Behavior identical to f17610ec; pure refactor for lint compliance.
Mirrors Flutter's PlayCircleButton (library/widgets/play_circle_button.dart)
on the Home AlbumCard / ArtistCard / PlaylistCard surfaces. 44dp accent
disc bottom-right of the cover, parchment Play icon, self-managed
spinner during the fetch-and-queue setup, drop shadow.
New: shared/widgets/PlayCircleButton.kt. Cards gain an opt-in
onPlay: (suspend () -> Unit)? parameter; when null the overlay is
omitted, preserving the Library / Discover / Search / detail surfaces
unchanged. HomeScreen wires three new HomeViewModel suspend methods:
playAlbum GET /api/albums/{id}, play tracks from 0
playArtistShuffled GET /api/artists/{id}/tracks, FY shuffle, play 0
playPlaylist systemShuffle for refreshable system playlists,
refreshDetail otherwise, 8s timeout
PlaylistsApi gains systemShuffle for the rotation-aware shuffle path
(GET /api/playlists/system/{kind}/shuffle, mirrors playlists.dart).
PlaylistCard play is disabled offline + refreshable (server endpoint
unreachable) and for empty playlists. Album / artist errors surface
via the existing transientMessages snackbar channel, matching the
ArtistDetailViewModel improvement over Flutter's silent fail.
Scope: Home tiles only this slice. Flutter applies the overlay to
the cards everywhere they render; revisit Library / Discover / Search
/ detail surfaces in a follow-up.
Five cache-first ViewModels all ended their flow pipeline with:
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
UiState.Loading)
plus a per-file private const for the 5_000L share-stop timeout.
Extract to a single Flow<UiState<T>> -> StateFlow<UiState<T>> extension
in shared/CacheFirstStateFlow.kt; migrate Library, PlaylistsList,
LikedTab, AddToPlaylist, and Home VMs. Each VM drops the catch +
stateIn + ErrorCopy + SharingStarted imports + the local constant.
AddToPlaylistVM keeps its onStart { refresh; emit Loading } block
in front of the helper -- that re-emit is intentional UX for sheet
re-opens past the 5s subscriber timeout.
ShellScaffold already collects TrackActionsViewModel.transientMessages
into its own SnackbarHost. Per-route, the shell's hiltViewModel<
TrackActionsViewModel>() and the screen's hiltViewModel<TrackActions
ViewModel>() resolve to the same NavBackStackEntry-scoped instance,
so any kebab action fires the snackbar twice today (once in the
screen's own SnackbarHost, once in the shell's).
Strip the per-screen wiring from the four shell-wrapped screens that
had it (Library, Search, AlbumDetail, PlaylistDetail): VM param,
SnackbarHostState, LaunchedEffect, and the Scaffold's snackbarHost.
The shell now owns the snackbar exclusively.
NowPlayingScreen is intentionally left untouched -- it's outside the
shell (full-screen route), so its own snackbar wiring is the only
surface that shows the message.
Ten screens duplicated the same TopAppBar + Text(title) +
optional-back-arrow + MainAppBarActions sandwich. Extract into
MinstrelTopAppBar(title, navController, currentRouteName, onBack,
actions) at shared/widgets/. Library's tab row still composes
above it inside a Column; Library's Shuffle button slots into the
extra actions trailing-lambda; drill-down screens pass onBack for
the back arrow; root tabs leave onBack null.
Net: -48 lines across 10 screens, +50 lines for the helper.
SearchScreen keeps its own TopAppBar (title is a SearchField
composable, not a string).
Per-screen TrackActionsViewModel snackbar wiring (Library/Search/
detail screens) was intentionally NOT consolidated here: each
hiltViewModel() returns a different VM instance than the shell-
scoped one already in ShellScaffold, so dedup belongs in 3c
(shell-scope the VM via CompositionLocal) rather than freezing the
current duplicate into a helper.
The original AddToPlaylistUiState only had Loading/Success/Error, with
the empty-list message branched inside Success. After migrating to
shared UiState<T> (which adds Empty), the sheet's when over the state
was non-exhaustive. Route empty through UiState.Empty at the VM, drop
the inner branch in the sheet, and add the explicit Empty arm in the
when block to satisfy the compiler.
History/Hidden/Requests/PlaylistsList/AddToPlaylist/Home/Liked/Library
each had its own sealed interface with Loading/Empty/Success(payload)/
Error variants. Collapsed to the generic shared/UiState<T> introduced
in the prior commit. Library carries two lists (artists + albums) so
its payload is wrapped in a new LibraryData record; the test was
updated to assert against UiState.Success<LibraryData>.
The 3 detail screens (Artist/Album/Playlist Detail) keep their own
sealed interfaces for now since they include a Loading(seed: ...)
variant that does not fit UiState<T>.
Flutter's _SecondaryControls (like, shuffle, repeat, queue, kebab) sit just above the seek bar (now_playing_screen.dart:464); Android had them at the bottom under the transport row. Reorder the NowPlayingBody column so it reads cover -> title -> actions -> scrubber -> transport, matching Flutter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three cards shared an identical Box + clip + background + ServerImage + fallback structure around their cover (only size, shape, fallback icon, and overlay differ). Extract a single CoverTile composable; each card now passes its own size, shape, background, fallback icon, and optional BoxScope overlay (used by PlaylistCard for the VariantPill). Pure DRY consolidation; no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Material3 changed the Slider's default inactiveTrackColor to colorScheme.secondaryContainer, which MinstrelTheme does not override -- so M3's baseline purple leaked into both the mini scrubber and the NowPlaying ScrubberRow. Flutter explicitly sets inactiveColor = fs.slate; mirror that by pinning inactiveTrackColor to surfaceVariant (which the theme already maps to slate).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
It was never image-specific -- it resolves any /api/* relative URL to the placeholder.invalid form that BaseUrlInterceptor rewrites. Both covers (ServerImage) and stream URLs (PlayerController) call it. Move the helper to shared/ServerUrls.kt with the right name; ServerImage and PlayerController import from there. Pure rename + relocate, no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server emits stream_url as a relative path (/api/tracks/{id}/stream, internal/api/convert.go:75). PlayerController.toMediaItem passed it raw to setUri, so OkHttpDataSource saw a host-less URI and silently failed -- queue UI loaded but no audio played. Route streamUrl through the same resolver used for covers (resolveServerImageUrl), which prepends the placeholder.invalid host that BaseUrlInterceptor rewrites to the live server with the auth cookie. The setCustomCacheKey(id) still keys the SimpleCache by trackId, so cache residency is unaffected by the URL form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping certain playlists showed 'That playlist no longer exists' because the server's BuildSystemPlaylists rotates system-playlist UUIDs every rebuild, and the LIST refresh only upserted -- stale UUIDs lingered in the cache, tapping them triggered a 404. Mirrors playlists_provider.dart: PlaylistsRepository.refreshList now deletes the user's cached rows not in the fresh owned set (catches both deleted user playlists AND old system UUIDs since system playlists are user-scoped) before upserting the fresh all, atomically via a new replaceList @Transaction on the DAO. Also deletes the cached row when refreshDetail 404s so a stale tap self-heals the list. Inject AuthController for the current user id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library + Home artist tiles were blank: cached_artists stores no cover and the sync payload (SyncArtistWire) carries none. Mirror Flutter's artistTileProvider JOIN — ArtistRef gains coverAlbumId + a displayCoverUrl getter; LibraryRepository.observeArtists and MetadataProvider.observeArtist combine artists with albums to supply the first album (by sort title) as the cover source. ArtistCard renders displayCoverUrl through ServerImage. Updated LibraryRepositoryTest to stub albumDao.observeAll so combine emits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server returns relative cover_url (/api/albums/{id}/cover, /api/playlists/{id}/cover). Android only resolved the empty-fallback placeholder trick, so any non-empty relative URL from a fresh fetch went to Coil host-less and silently failed (album detail, artist-detail album grid, playlist cards, artist avatars). Add a shared ServerImage composable + resolveServerImageUrl that prefixes the placeholder host (rewritten by BaseUrlInterceptor) for relative paths, passes http(s) through, and falls back when blank. Route AlbumCard, PlaylistCard, AlbumDetail header, and ArtistAvatar through it. Mirrors Flutter's ServerImage._resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShellScaffold's banner column did not consume the status-bar inset while each screen's app bar did, so the update/connection banners drew under the status bar. Consume the inset once at the shell (statusBarsPadding) so banners sit below it; descendant screen app bars see the consumed inset and stop re-padding, matching Flutter's SafeArea(bottom:false).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rememberDominantColor was keyed on coverUrl, so each track change reset the extracted color to Transparent and the gradient dipped toward the fallback before tweening to the new color. Drop the key so the held color stays on the previous track's dominant until the new palette resolves -- the gradient now tweens directly to the new color. Combined with CoverPrefetcher warming the next cover, this matches Flutter's preload-then-atomic-swap UX (no flash on track change) via native Compose mechanisms rather than a literal preload pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Flutter's UpdateBanner. UpdateBannerController polls
/api/client/version at launch + every 24h, compares the bundled APK
against this build via isVersionNewer, and exposes the available
UpdateInfo (minus in-memory per-version dismissals). The shell banner
nudges "Update Minstrel · {version} available" with Install (reusing
ApkInstaller's download + system-install handoff, routing to the
install-permission settings first when needed) and a dismiss X. Uses an
understated surfaceVariant tone, distinct from the error-colored
VersionTooOldBanner hard gate.
Divergence noted: ApkInstaller exposes no byte progress, so the
downloading state shows an indeterminate bar rather than Flutter's
determinate one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports player_bar.dart's onVerticalDragEnd — an upward flick anywhere on
the bar expands into NowPlaying, alongside the existing tap-to-expand. A
vertical draggable claims only vertical-dominant drags, so the inline
seek slider keeps its horizontal scrub gesture. The 200 px/s Flutter
threshold is expressed in dp and density-converted so the flick feels
the same across screen densities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShuffleSource intersects the play-recency list with the Media3
SimpleCache key set, so the offline Recently-played / Liked pools only
offer tracks whose audio is actually on disk. Flutter's index holds
fully-cached tracks only; Android's records plays, so this intersection
restores parity — a played-then-evicted track no longer surfaces in a
pool where it would fail to play offline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The composables lucide artifact names the property CircleCheckBig
(camelCase), not the snake-case filename — the prior import did not
resolve and broke compileDebugKotlin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Flutter's CachedIndicator (circle_check_big, size 14, accent, 4px
left pad) to all 5 track-row screens (Album, History, Liked, Playlist,
Search). CachedTrackIds exposes a reactive Set of trackIds with bytes in
Media3's SimpleCache — true on-disk residency (keyed per track via the
custom cache key from slice 1), so an evicted track loses its dot. Read
through a LocalCachedTrackIds CompositionLocal provided at the app root,
mirroring the existing LocalDetailSeedCache pattern, so leaf rows need no
per-screen plumbing. The persisted cache is read eagerly, so dots paint
for previously-cached tracks even when the library is opened offline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CacheIndexer observes PlayerController.uiState and records each played
track into audio_cache_index (source=INCIDENTAL), bumping lastPlayedAt
on re-play. This gives the offline ShuffleSource pools their data
source — previously the index was never written, so the pools were
inert. MediaItems now set a custom cache key of trackId so Media3's
SimpleCache is keyed per track (sets up slice 2's residency queries).
Intentional divergence from Flutter's file-per-track index: SimpleCache
(span-based) is the real byte store, so the index is a lightweight
play-recency record. Slice 2 (#33) queries SimpleCache for true
residency + size + eviction-sync to drive the cached indicator dot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#37. The Hidden tab was fetch-on-visit (spinner every open);
now it paints instantly from the cached_quarantine_mine Room table
and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController
(drift watch + transaction full-replace + optimistic flag/unflag).
The Room table + DAO already existed (CachedQuarantineEntity /
CachedQuarantineDao with observeAll + insert/delete) — they were just
never wired up. This commit connects them:
* DatabaseModule: provideCachedQuarantineDao (was missing from the
graph — same gap as AudioCacheIndexDao).
* CachedQuarantineDao: atomic replaceAll(rows) (@Transaction
clear+upsertAll, no flicker).
* QuarantineRepository: observeMine() Flow + refresh() full-replace;
flag(track) / unflag(id) now mutate the cache optimistically (Flow
re-emits instantly) then call the server, enqueueing on failure
(intent persists, no rollback — matches Flutter). flag() takes a
TrackRef to build the optimistic row; TrackActionsViewModel updated.
listMine() kept for the TrackActions hidden-check.
* HiddenTabViewModel: uiState = combine(observeMine, refreshError),
cache-first; SWR on init + on quarantine.* SSE; unflag via repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).
Native implementation (idiomatic, not a drift transliteration):
* CachedHistorySnapshotEntity — single-row table (id=1) holding the
raw /api/me/history wire JSON + updatedAt. AppDatabase v5→6
(fallbackToDestructiveMigration rebuilds; cache refills from server).
* CachedHistorySnapshotDao.observe() Flow + upsert; DatabaseModule
@Provides bridge (the AudioCacheIndexDao lesson — every @Inject dep
needs a provider).
* HistoryRepository.observeHistory(): Flow<HistoryPage?> decodes the
blob; refresh() fetches + overwrites the snapshot. Whole page as one
JSON blob (read-only timestamp-keyed snapshot — no per-row bookkeeping
for no UX gain), matching Flutter's snapshot shape.
* HistoryTabViewModel: uiState = combine(observeHistory, refreshError)
— cache-first, SWR, and Error only when there's no cache to show.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>