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>
The offline-pool ShuffleSource (2a87a50b) injects AudioCacheIndexDao,
but DatabaseModule had no @Provides bridge for it — nothing had
injected that DAO via Hilt before, so the graph was missing the
binding and hiltJavaCompileDebug failed. Added the provider.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports flutter_client/lib/library/artist_detail_screen.dart:177-208
_ArtistAvatar: server coverUrl wins; when empty, use the first loaded
album's /api/albums/{id}/cover; only show the bare Lucide.User icon
when the artist has neither. Threads detail.albums.firstOrNull()?.id
through ArtistHeader → ArtistAvatar. (Seeded-loading header passes
null since the seed carries no album list.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter's _LiveEventsDispatcher invalidates myQuarantineProvider on
any quarantine.* event, and the Hidden tab watches that provider — so
a hide/unhide/delete from another device updates the open Hidden tab
live, not just on re-open. Android's HiddenTab is fetch-on-visit with
no shared reactive cache, so per the Android LiveEventsDispatcher's
own documented pattern (screen-scoped state subscribes to EventsStream
directly), HiddenTabViewModel now collects quarantine.* and refreshes.
Same user-visible behavior as Flutter; same shape as the
TrackActionsViewModel SSE wiring.
(Paint-from-disk offline cache for the Hidden tab — Flutter's drift
cached_quarantine_mine — remains the separate deferred Phase-13 work.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports flutter_client/lib/auth/login_screen.dart:86-89 — a TextButton
under the Sign-in button that routes to the ServerUrl screen, so a
wrong server URL is recoverable pre-login without reinstalling or
signing out from Settings. Disabled while a submit is in flight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Faithful port of Flutter's _OfflinePoolCard + shuffle_source.dart.
I was wrong earlier that Android lacked the source — the
audio_cache_index table already carries lastPlayedAt, exactly what
Flutter reads.
* ShuffleSource @Singleton over the local cache: recentlyPlayed()
= audio_cache_index ordered by lastPlayedAt desc, materialized
from cached_tracks; liked() = same ∩ the liked track-id set.
Both are unions over the cache regardless of storage bucket,
matching Flutter's note that the bucket split is eviction-only.
* OfflinePoolCard widget (sized to match PlaylistCard).
* AudioCacheIndexDao.trackIdsByRecency(); LikesRepository.likedTrackIds().
* HomeViewModel: offline StateFlow from ConnectivityObserver,
playPool(kind) shuffles + plays (snackbar "No cached X tracks yet"
when empty), transientMessages channel.
* buildPlaylistsRow gains an `offline` flag; when offline the two
pool cards (Recently played, Liked) LEAD the Playlists row, before
the system slots — the rest of the row (placeholders + user
playlists) renders regardless, exactly as Flutter does.
* HomeScreen gains a SnackbarHost for the empty-pool message.
Together with edbc8053 (placeholders) this closes audit v3 #28.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Home Playlists row now always renders the For You + Discover +
3× Songs-like slots: a real PlaylistCard when the system playlist
has generated, otherwise a PlaylistPlaceholderCard showing its
build state — building / failed / seed-needed / pending — derived
from GET /api/me/system-playlists-status. Mirrors Flutter's
_buildPlaylistsRow + PlaylistPlaceholderCard.
* SystemPlaylistsStatus domain + wire; MeApi.getSystemPlaylistsStatus;
HomeRepository.getSystemPlaylistsStatus.
* HomeViewModel fetches the status in refresh() and exposes it as a
StateFlow; HomeScreen collects it and threads it to the row builder.
* PlaylistPlaceholderCard widget (sized to match PlaylistCard) with
a status chip + variant subtitle.
* buildPlaylistsRow / variantFor port the slot logic; user playlists
follow the fixed system slots.
The offline-pool cards (§4.3) are NOT in this commit — Android has
no local recently-played cache (history is fetch-on-visit), so the
"Recently played" pool has no offline source. Deferring that half
until the history snapshot cache lands; see #28 notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the "Install vX.Y.Z" action the About card's update-check
teed up.
* ApkInstaller @Singleton — downloads the server APK via the shared
OkHttpClient (inherits auth cookie + BaseUrlInterceptor host
rewrite; apkUrl is server-relative) into cacheDir, then launches
the system installer via a FileProvider content:// URI. canInstall()
gates on PackageManager.canRequestPackageInstalls() on O+, and
requestInstallPermission() opens the "install unknown apps" settings
page when not yet granted.
* Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
(${applicationId}.fileprovider) + res/xml/file_paths.xml exposing
the cache dir.
* AboutCardViewModel.install(info): permission check → download →
launch, with isInstalling + installMessage state. Errors routed
through ErrorCopy.
* About card shows an "Install vX.Y.Z" button under the check button
when an update is available, "Downloading…" while in flight, and
the install message line. Extracted UpdateControls / InstallButton /
ButtonSpinner helpers to keep AboutCard under the length cap;
added @file:Suppress(TooManyFunctions) for the settings-card density.
Closes audit v3 #25 — the last open parity item from the v3 sweep
aside from the offline-pool Home cards (#28).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The kebab "Go to album/artist" did popBackStack() then navigate() —
two transactions, leaving a visible frame on whichever shell tab sat
under NowPlaying before the detail pushed. Replaced with a single
navigate(...) { popUpTo(NowPlaying) { inclusive = true } } so the
modal is removed and the detail pushed in one atomic transaction,
no intermediate frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 25-row chunking + per-section empty messages pushed
HomeSuccessContent to 69 lines (cap 60). Extracted each section
into a LazyListScope extension (recentlyAddedSection /
rediscoverSection / mostPlayedSection / lastPlayedSection) so the
main composable is just the LazyColumn skeleton calling them. File
already carries @file:Suppress(TooManyFunctions) for the Compose
helper density.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delete-user was a plain Delete/Cancel AlertDialog — too permissive
for an irreversible action. Now mirrors Flutter's TypedConfirmSheet:
an OutlinedTextField where the admin must type "DELETE" before the
Delete button enables (case-insensitive). The button stays disabled
and muted until the word matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TrackActionsViewModel fetched the hidden-track snapshot only at init
and after local flag/unflag, so a hide/unhide from another device
left the sheet's Hide/Unhide label stale until the VM was recreated.
Now it subscribes to EventsStream and refreshes on any quarantine.*
event (flagged / unflagged / resolved / file_deleted /
deleted_via_lidarr).
Also routed the radio / append-to-playlist / hide / unhide failure
snackbars through ErrorCopy.fromThrowable instead of raw it.message,
keeping the action-context prefix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A large library rendered all recently-added albums in one horizontal
LazyRow that scrolled forever. Now the list is chunked into stacked
carousels of 25 (RECENTLY_ADDED_CHUNK); the first carries the
"Recently added" title and the rest are untitled continuation rows,
matching Flutter's shared-ScrollController layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AlbumDetail / ArtistDetail / PlaylistDetail previously rendered only
the AppBar title from the navigation seed during Loading, with a bare
skeleton body. Now each Loading branch renders the full header from
the seed when present — cover + title + artist/description + count —
above the skeleton track rows / album grid, so the screen reads as
itself instantly instead of skeleton-then-pop. Falls back to the
plain skeleton when no seed (e.g. deep links, track-row navigations
that only carry an id).
Action buttons (Play / Shuffle / Like / Regenerate) are intentionally
omitted from the seeded header — they need the loaded track list.
Added @file:Suppress(TooManyFunctions) to AlbumDetailScreen (now at
the 11-function cap with the new SeededAlbumLoading helper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§4.10/§4.18 — PlaylistCard now overlays a small primary-tinted pill
on system-playlist covers showing the variant ("For You" / "Discover"
/ "Today's mix" / …) instead of relying on the subtitle line. The
subtitle now prefers track count, so system tiles show variant pill
+ track count rather than just the label.
§4.16 — MiniPlayer cover wraps its content in a Crossfade keyed on
coverUrl so artwork dissolves into the next track instead of hard-
swapping. Pairs with the CoverPrefetcher cache-warming so the next
cover is usually ready to fade in.
Cleanup — migrated the AdminUsers invite Copy-token button off the
deprecated LocalClipboardManager.setText to LocalClipboard.setClipEntry
(suspend, via rememberCoroutineScope + ClipData/ClipEntry), clearing
the build warning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a mid-session SSE drop (server restart / network blip)
left the stream closed until the next sign-in — cross-device
reactivity (likes, playlist updates, request status, quarantine
changes) silently died until app relaunch.
Now onClosed / onFailure schedule a reconnect with exponential
backoff (1s → 2s → … → 30s cap), reset to 1s on a successful
onOpen. Reconnect only fires while signed in; sign-out's
disconnect() cancels any pending retry. State swaps
(connect/disconnect/scheduleReconnect) are @Synchronized since the
OkHttp listener thread and the auth-cookie collector both touch
currentSource + backoff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Queue rows only showed title + artist. Now the subtitle reads
"Artist · Album" (collapsing gracefully when either is empty) and a
trailing m:ss duration appears when known — matches Flutter's queue
row density so users can tell tracks apart by album + length.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The error test asserted the raw exception text leaked into the UI
state — exactly the behaviour ErrorCopy removes. An
IllegalStateException is neither HttpException nor IOException, so
it maps to the generic "Something went wrong." Updated the
assertion to the new contract and dropped the now-unused
assertTrue import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Flutter's error-copy.json (45 server codes → sentence-case
copy) as a Kotlin ErrorCopy object. fromThrowable(t):
- Retrofit HttpException → parse {"error":{"code":...}} body,
map code (e.g. "wrong_password" → "Current password is
incorrect.", "playlist_not_found" → "That playlist no longer
exists.")
- IOException → connection_refused → "Couldn't reach the server.
Check the URL and try again."
- anything else → "Something went wrong."
Wired into 22 ViewModels / screens, replacing the raw
`e.message ?: "<generic>"` fallbacks that leaked exception text
(e.g. "HTTP 404", "Unable to resolve host") into the UI. Load-state
errors now read as actionable copy; settings form messages dropped
their "Couldn't save:" prefixes since the friendly strings stand
alone. ArtistDetail's playback path keeps its "Couldn't start
playback: " prefix (local-action context). PlayerController's
controller-connect failure and the About update-check are left on
their own copy (not server-code errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>