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>
§4.7 — The Most-played tiles on Home navigated to the track's album
(a leftover TODO from before the player API existed). Now tapping a
tile replaces the queue with the whole Most-played section and starts
at the tapped index, via HomeViewModel.playMostPlayed →
PlayerController.setQueue(source = "home:most_played"). Mirrors
Flutter's _resolveSectionTracks where Home sections are playable units.
§4.15 — NowPlaying had no inline like affordance — users had to open
the kebab to like the current track. Added a LikeButton at the head of
BottomActionsRow, wired to TrackActionsViewModel.isLikedFlow /
toggleLike (same pattern as MiniPlayer). NowPlayingBody now collects
the like state and threads it down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The audit called this the worst kind of bug — when a track fails
to load (404, decoder failure, premature EOS, network drop)
ExoPlayer just silently skips to the next, hiding exactly the
signal that distinguishes "this file is broken" from "the app is
flaky".
* PlayerController.onPlayerError now emits the failing track's
title onto a CONFLATED Channel exposed as playbackErrorEvents:
Flow<String>. Conflated rather than buffered so a stuck loop
can't pile up; the reporter's debounce coalesces anyway.
* PlaybackErrorReporter @Singleton collects the per-error stream,
buffers in a 2-second debounce window, and emits coalesced
user-facing strings:
1 error → "Couldn't play \"X\" — skipping"
N errors → "Skipped N unplayable tracks"
Matches the Flutter reporter shape so users see one toast on a
network blip that kills N tracks instead of a stack of N toasts.
* PlaybackErrorViewModel bridges the reporter's Flow into Hilt's
ViewModel layer so ShellScaffold can collectAsState via
hiltViewModel().
* ShellScaffold adds a second LaunchedEffect that pipes the
reporter messages into the existing shared snackbar — no new
UI surface needed.
* MinstrelApplication uses the construct-the-singleton trick to
start the reporter at app launch so it's collecting from the
first Media3 connect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compile error from the LikedTab inline-heart commit (05c7d922) —
toggleLike's third param is named desiredState, I called it 'liked'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SettingsScreen was still 75/60 after the dialog extraction. Extracted
the Column-of-cards body into SettingsList so the main composable
only holds state collection, LaunchedEffect, and the Scaffold shell.
Also fixed the helper's state-param type — SettingsViewModel uses
SettingsState not SettingsUiState.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign-out confirm dialog landed inline in SettingsScreen and pushed
it past the 60-line cap (93/60). Same pattern as other dialog
extractions in the project.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the eager fan-out hydration from commit 040217ca (the
band-aid that fetched every missing ID in a 4-wide chunked loop
on refreshIndex) with the cleaner per-tile on-miss pattern:
* hydrateAlbums / hydrateArtists / hydrateTracks now call
metadataProvider.refreshAlbum/Artist/Track(id) for any ID the
cache doesn't have yet — same dedup as elsewhere, so multiple
Home re-collections / SSE index updates / Library cross-traffic
all share in-flight fetches.
* refreshIndex no longer launches a background hydration pass —
the Flow-side on-miss handles it declaratively as each tile
materialises.
* Removed @ApplicationScope, LibraryRepository, async/awaitAll,
coroutineScope, launch, HYDRATE_CONCURRENCY constant — all
dead now that the eager loop is gone.
Net: same user-visible behaviour (Rediscover populates) but the
plumbing is more honest, dedup'd across surfaces, and the
FreshnessSweeper keeps these rows fresh going forward.
Closes audit v3 #24.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds idsStaleBefore(before, limit) DAO query to all three cached
entity DAOs (album / artist / track) — uses the existing
fetchedAt Long column, no schema change.
FreshnessSweeper @Singleton runs on appScope: every 30 minutes
walks each entity bucket, fetches up to 100 IDs whose fetchedAt
is older than 24h, refreshes each via MetadataProvider.refreshX(id).
Bounded concurrency=4 via Semaphore so the sweep doesn't saturate
the connection pool. Reuses MetadataProvider's per-ID dedup so a
sweep that overlaps with a user-driven on-miss fetch is a no-op
on the duplicated ID.
Wired in MinstrelApplication via the construct-the-singleton trick.
Next slice (3/3): wire MetadataProvider.observeAlbum/Artist/Track
into Home tiles + Library Albums/Artists + Search results so the
on-miss fetch actually fires, and drop the band-aid Home hydration
from commit 040217ca.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the Flutter tile-provider pattern.
Adds CachedAlbumDao / CachedArtistDao / CachedTrackDao observeById(id)
Flow methods (query-only change; no schema migration).
MetadataProvider exposes observeAlbum(id) / observeArtist(id) /
observeTrack(id) as Flow<X?> that:
- emits the Room row immediately if present,
- fires a single background fetch when the cache emits null,
- re-emits the populated row once the fetch upserts.
Fetch dedup is per-ID via ConcurrentHashMap<String, Job> — five
tiles asking for the same missing album = one network call. Fetch
failures are swallowed (row stays null, tile keeps skeleton);
FreshnessSweeper (next slice) retries.
Also exposes refreshAlbum/Artist/Track(id) for the upcoming sweeper
to force-refresh stale-but-cached rows; same dedup applies.
Next slices:
2/3 - FreshnessSweeper periodic walk + EventsStream reconnect hook.
3/3 - Wire MetadataProvider into Home / Library / Search tiles
and remove the band-aid Home hydration from commit 040217ca.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more audit v3 cleanup items:
§4.11 — LikedTab track rows lacked an inline LikeButton. Users had
to open the kebab menu to unlike a track — two taps for what should
be one. Added LikeButton(liked = true) next to the TrackActionsButton;
tap unlikes via LikedTabViewModel.unlikeTrack which routes through
LikesRepository.toggleLike (and therefore the MutationQueue, so
offline taps replay).
§4.23 — SettingsScreen Sign Out tapped immediately wiped local
state with no confirmation. Now: AlertDialog with "Sign out of this
server? Your downloaded music and settings on this device will be
removed." and an errorContainer-tinted confirm button. Cancel by
default for accidental taps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
playArtist() previously swallowed all exceptions with a comment
about a 'future refinement' — if the tracks fetch failed or the
artist had no playable tracks, the button looked broken with no
feedback.
Now: ArtistDetailViewModel exposes a transientMessages Flow backed
by a buffered Channel. playArtist sends "Couldn't start playback:
<reason>" on Throwable and "No tracks to play for this artist."
when the result is empty. ArtistDetailScreen collects the Flow into
a Scaffold-level SnackbarHost so users see the failure inline
instead of tapping a dead button repeatedly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§4.8: SearchScreen's TopAppBar had no MainAppBarActions kebab —
users in Search couldn't reach Settings / Discover / Requests /
Admin / Sign-out without first hitting Back. Added the standard
kebab next to the existing clear-search button.
Bug-3: Storage card's "Liked cache limit" dropdown persisted a
value that was never enforced (PlayerFactory.kt:43-47 only consumes
rollingCapBytes). Per the established pattern for the prefetch +
cache-liked toggles (hidden in 4d7a4312 for the same reason), hide
the Liked cap dropdown until per-bucket eviction lands. Renamed
the remaining one to just "Cache limit" so it reads honestly. The
CacheSettings field stays persisted so the control returns once
per-bucket SimpleCache eviction is wired.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v3 §4.5: Flutter renders explanatory copy for each empty Home
section ("No forgotten favourites yet. Like albums or artists to
fill this in." for Rediscover, "Play some music..." for Most played,
etc). Android was hiding empty sections entirely, which made the
home view look sparse even when 2-3 sections had data and contributed
to the user's emulator complaint about missing rows.
Now: every section slot renders either the populated HorizontalScrollRow
OR an EmptySection card (sentence-case title + understated body copy)
so the page structure is always visible. Playlists row keeps its
"hide-when-empty" behaviour for now since it leads the page — empty
copy at the top would feel like an error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small audit v3 cleanups bundled:
* Bug-6 / §4.19 — AdminLanding Users card subtitle was stale
("Manage accounts") even though Invites shipped weeks ago.
Now: "Manage accounts and invites" to match Flutter.
* §4.22 — Account card never showed the admin badge. Flutter
appends " · admin" next to the username when isAdmin. Threaded
isAdmin through AccountCard and added the suffix.
* Bug-4 — NowPlayingBody used Column.Center with no scroll, so on
small-portrait or any landscape phone the cover + scrubber +
transport + actions row clip. Added verticalScroll so users on
small screens can scroll the body instead of losing affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v3 Bug-1 + Bug-2 — cleanup sweep, two cold-launch recovery
fixes:
Bug-2: LoadingCentered() was duplicated as a private composable
across 10 screens (LikedTab, HistoryTab, RequestsScreen,
PlaylistsListScreen, DiscoverScreen, AdminQuarantine/Requests/Landing,
HiddenTab, SearchScreen), each rendering a non-scrollable
Box(fillMaxSize). PullToRefreshBox needs a scrollable child to
bubble overscroll — the bare Box silently swallowed the
pull-to-refresh gesture, so a cold-launch error couldn't be
recovered without killing the process. New shared widget at
shared/widgets/LoadingCentered.kt mirrors EmptyState's shape
(single-item LazyColumn + fillParentMaxSize Box) so pull-down
fires on the Loading branch. Drops the 10 duplicates.
Bug-1: LibraryScreen's Artists and Albums tabs both rendered
ErrorRetry(onRetry = {}) — the Retry button looked tappable but
did nothing. Wired both to viewModel.refresh().
Net: cold-launch recovery now works on every shell screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v3 §2 + §4.5: HomeRepository's hydrateAlbums / hydrateArtists /
hydrateTracks use mapNotNull on the per-entity DAO, so any ID in
/api/home/index that the local cache hasn't seen gets silently
dropped from the emitted list. Combined with HomeSuccessContent
hiding empty sections, the user sees the home view missing entire
rows — Rediscover is the worst hit because by definition those are
albums you HAVEN'T played recently, least likely to be in cache.
User reported on emulator: 'we're missing a lot of rows and
formatting from the home view, the rediscover section is completely
missing and a number of the shown sections are rendered with a
different number of rows than the flutter app has.'
Fix: after refreshIndex() pulls the section ID lists, fire-and-forget
a hydration pass on the app scope. For each section, find IDs the
cache doesn't have, fetch via LibraryRepository's existing per-entity
endpoints (refreshAlbumDetail / refreshArtistDetail / refreshTrack —
the last already commented 'for the hydration queue'), chunked
HYDRATE_CONCURRENCY=4 wide so we don't fire 50 parallel requests.
runCatching per-call so one broken ID doesn't fail the batch.
This is the slim version of audit #24 MetadataPrefetcher scoped to
Home — the full background hydration queue with tile-providers is
still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding the new Profile / Password / ListenBrainz / Update-check
cards pushed AppearanceCard / StorageCard / AboutCard / Sign-out
button off the bottom of the viewport on any normal phone. The
column was fillMaxSize without verticalScroll, so they were
unreachable.
User reported: 'I can't find the light/dark theme controls
anymore. The settings menu looks like it should scroll but
doesn't.' AppearanceCard is at position ~9 in the column, well
past the fold on a typical device.
Single-line fix: add verticalScroll(rememberScrollState()) to
the column's modifier chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* HealthzApi — GET /healthz unauthenticated endpoint returning
{status, version, min_client_version}.
* VersionCheckController — Hilt singleton; 5-minute poll loop on
appScope, soft-fails on network errors (keeps last-known
VersionResult: OK / TOO_OLD / SKIPPED). Reuses isVersionNewer()
from the About card's UpdateRepository. Exposes recheck() for
the banner's "Check now" button.
* VersionTooOldBanner — shell-level Compose banner with
AnimatedVisibility shrink/expand, triangle-alert icon, copy
matching Flutter, "Check now" trailing button. Tiny
VersionTooOldViewModel lifts the StateFlow through Hilt.
* ShellScaffold adds VersionTooOldBanner() above ConnectionErrorBanner()
in the existing banner slot.
* MinstrelApplication uses the construct-the-singleton trick to
start the poll loop at app launch.
Closes audit v2 #23. Locally cached content keeps working when
the banner is shown — the message nudges the user toward an
update without blocking the rest of the UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit v2 #20.
* ClientVersionApi — GET /api/client/version returning
UpdateInfo(version, apkUrl, sizeBytes).
* UpdateRepository.getLatest() + isVersionNewer() free function
ported from Flutter's component-wise integer compare (handles
our date-style 2026.05.10.1 versions correctly, with a
branch-name fallback for non-numeric builds like dev/main).
* AboutCardViewModel surfaces a four-state UpdateCheckResult
(Idle/Latest/UpdateAvailable/Error).
* About card grows a "Check for updates" button + inline status
line. "Install vX.Y.Z" wiring is #25 (post-v1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* MeApi gains getListenBrainz + setListenBrainz (single PUT shape
with optional token / enabled fields, server treats untouched).
* MeRepository facade methods setListenBrainzToken / setListenBrainzEnabled.
* ListenBrainzStatus domain + ListenBrainzStatusWire (server never
echoes the token back; tokenSet is the visible signal).
* ListenBrainzViewModel — load on init, separate flows for save-token
and toggle-enabled, inline status message.
* ListenBrainzCard — descriptive copy, masked token field with
"Replace token" / "Token saved" placeholder, Save button, Switch
for "Send my plays" (disabled until a token is stored), "Last
scrobble: …" timestamp once present.
Slotted between PasswordCard and AppearanceCard.
About-panel update check is the second half of #20; lands in slice 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit v2 #17.
App-scoped singleton observes PlayerController.uiState, plucks
queue[queueIndex + 1].coverUrl, dedups, and fires Coil's enqueue
to warm the memory + disk cache. Fire-and-forget — the Disposable
is discarded since the cache write happens on the loader's
background thread regardless.
Wired via the existing construct-the-singleton trick in
MinstrelApplication.
Track changes now hit a cache (cover snaps in, dominant-color
gradient transitions cleanly) instead of the network.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the foundational network-state plumbing the audit called out:
* ConnectivityObserver — Hilt singleton wrapping ConnectivityManager.
Exposes a Flow<Boolean> sourced from registerNetworkCallback; emits
false when the active network lacks INTERNET+VALIDATED capabilities
(airplane mode, no carrier, captive portal) and true once a usable
network appears. Seeded with the initial value so the banner doesn't
flash before the first capability callback.
* ConnectionErrorBanner — shell-level Compose banner that AnimatedVisibility-
shrinks/expands based on the observer. Red errorContainer surface,
CloudOff icon, "No connection — check Wi-Fi or mobile data." copy.
Owns a tiny ConnectivityBannerViewModel that lifts the singleton's
Flow into a lifecycle-scoped StateFlow.
* ShellScaffold now invokes ConnectionErrorBanner() in the banner
slot above the routed content. VersionTooOld / UpdateBanner will
join the same slot in follow-up commits.
ACCESS_NETWORK_STATE permission was already in the manifest. Downstream
repositories can also collect ConnectivityObserver.online to gate
retry loops once that wiring is needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the NavHost in a SharedTransitionLayout and applies
Modifier.sharedElement to both the MiniPlayer cover and the
NowPlayingCover, keyed on a single HERO_KEY_NOW_PLAYING_COVER.
Tapping the MiniPlayer cover now morphs into the full NowPlaying
cover instead of cross-fading.
Plumbing:
* HeroScopes.kt — staticCompositionLocalOf holders for both the
SharedTransitionScope (set once at NavHost root) and the
AnimatedContentScope (re-set per composable<>, since each route
has its own).
* MinstrelNavGraph.kt — private WithAnimatedScope helper wraps
each composable<> lambda so its AnimatedContentScope reaches
the nested cover.
* MiniPlayer.kt MiniCover + NowPlayingScreen.kt NowPlayingCover
each read both scopes and prepend Modifier.sharedElement when
both are present; degrade gracefully (no hero, still renders)
outside the layout for previews / tests.
Cover preload still pending — that's slice 3 if you want it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds androidx.palette dependency and a rememberDominantColor()
helper that pulls the cover bitmap via Coil's singleton loader
(shares the cache with the on-screen AsyncImage), runs Palette
extraction on Dispatchers.Default, and animates the resulting
color with animateColorAsState so track changes tween smoothly.
NowPlayingScreen wraps the body in a Box with a vertical gradient
(0% dominant @ 55%α → 45% dominant @ 18%α → 100% scheme.background)
and lets the Scaffold's containerColor go transparent so the
gradient shows through. Falls back to a near-transparent gradient
on bitmap-load failure so the screen never sits on flat black.
Hero transition (MiniPlayer → NowPlaying cover) and cover
preload still pending — those land in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Editing artefact from extracting LoadingCentered → SkeletonPlaylistTrackList;
the @Composable from LoadingCentered landed on the new const declaration
and Kotlin rejected it as not applicable to top-level properties.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crossfade wrapper pushed AlbumDetailScreen over the 60-line cap (62).
Extracted the inner state-machine + Crossfade + success-body
composition into AlbumDetailStateContent so the main composable
keeps only Scaffold/TopAppBar/PullToRefreshScaffold wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps each cold-load branch in a Crossfade and replaces the
centered spinner with skeleton tile grids/lists that match the
real layout's geometry so the reveal doesn't reflow:
* LibraryScreen Artists tab → SkeletonArtistsGrid (12 SkeletonArtistTile
in adaptive 144dp grid).
* LibraryScreen Albums tab → SkeletonAlbumsGrid (12 SkeletonAlbumTile
in adaptive 176dp grid).
* AlbumDetail → SkeletonTrackList (8 SkeletonTrackRow).
* ArtistDetail → SkeletonArtistAlbumsGrid (9 SkeletonAlbumTile,
176dp adaptive grid mirroring ArtistBody's albums grid).
* PlaylistDetail → SkeletonPlaylistTrackList (8 SkeletonTrackRow).
Drops now-unused LoadingCentered helpers from Library / AlbumDetail /
ArtistDetail / PlaylistDetail per the no-dead-code rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Skeletons.kt with five primitives: SkeletonBox (pulsing
surfaceVariant fill, the building block), SkeletonAlbumTile,
SkeletonArtistTile, SkeletonTrackRow, SkeletonSectionHeader. Each
matches the geometry of the real card it stands in for so the
reveal doesn't reflow.
Wires HomeScreen as the first consumer: replaces the cold-load
CircularProgressIndicator with a HomeSkeletonContent LazyColumn
(section header + horizontal album row × 2 + section header +
horizontal artist row), and wraps the state machine in a Crossfade
so Loading → Success cross-fades instead of snapping.
Library + AlbumDetail / ArtistDetail / PlaylistDetail still use
the centered spinner; those land in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppBar headers on AlbumDetail / ArtistDetail / PlaylistDetail now
render the real title during the Loading state instead of the
"Album" / "Artist" / "Playlist" placeholder. Mirrors Flutter's
go_router extra: seed pattern.
Implementation:
* DetailSeedCache — process-singleton with three LRU buckets
(albums / artists / playlists, 32 entries each). Hilt-injected
into MainActivity and exposed via LocalDetailSeedCache so any
composable can stash before navigating.
* AlbumCard / ArtistCard / PlaylistCard stash their Ref on click;
every existing nav call site automatically benefits — no
callback shape change needed.
* Each detail VM peeks the cache in refresh() and emits
Loading(seed) so the AppBar reads from the carried seed. State
carries across pull-to-refresh too (refresh keeps the seed of
the previous Success / Loading rather than blanking to "Album").
* AlbumDetailUiState.Loading, ArtistDetailUiState.Loading, and
PlaylistDetailUiState.Loading evolved from data object to
data class Loading(val seed: T?) — backwards-compatible
pattern-matching with is checks.
Track-level "Go to album" / "Go to artist" call sites (TrackRow
and NowPlaying TrackActions) only have an id, no Ref, so the
AppBar still shows the placeholder for those — matches Flutter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous push tried to fix detekt but missed two things:
* regenerate() in PlaylistDetailViewModel had its 3 returns "fixed"
in name only — the if(!refreshable)return was still there. Now
folded into the variant Elvis chain with takeIf{refreshable}.
* The helper-composable extractions I just shipped pushed three
screens over detekt's 11-function-per-file cap. Compose screens
naturally produce many small private composables; per the
established pattern, suppress TooManyFunctions at the file level
with a one-line rationale rather than fight the cap.
* AdminUsersScreen main body was 94 lines (cap 60) because I
rebuilt the Scaffold inline with the new Invites section. Extracted
AdminUsersScaffold helper so the main function only owns state
hoisting and dialog dispatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two slices in one push because the detekt failures from the previous
slices block landing the Invites work cleanly:
Audit v2 #19 — Admin Users Invites section:
* New domain Invite model + InviteWire envelope.
* AdminInvitesApi (list / create / revoke).
* AdminInvitesRepository.
* AdminInvitesViewModel with optimistic add/remove + one-shot
Channel for the generated-token dialog.
* AdminUsersScreen restructured: single LazyColumn with two sections
(Users / Invites). Generate button in Invites header opens a
dialog for the optional note; after creation a second dialog
surfaces the token with a Copy-to-clipboard button.
Detekt fixes on already-pushed code:
* PlaylistDetailViewModel.regenerate: 3 returns → single early-bail
via combined condition.
* PlaylistDetailScreen function (63/60): extracted
PlaylistDetailContent helper for the when block.
* PlaylistHeader function (65/60): extracted PlaylistHeaderActions.
* RequestsScreen RequestRow (79/60): extracted RequestRowBody +
RequestRowAction + CancelConfirmDialog helpers.
* RequestRef.listenRoute: 4 returns → single when expression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #15. System-playlist detail screens now expose a
"Regenerate" OutlinedButton next to Play + Shuffle when the playlist
is refreshable (every system variant except songs_like_artist —
mirrors Flutter's PlaylistRef.refreshable).
Plumbing:
* PlaylistsApi.refreshSystem(variant) → RefreshSystemResponse with
optional playlist_id (server rotates the uuid; null when library
can't seed a build).
* PlaylistsRepository.refreshSystemPlaylist returns the new id and
invalidates the list cache so home-row tiles point at the new uuid.
* PlaylistDetailViewModel.regenerate calls into the repo and emits
the new id on a Channel.
* Screen collects the channel and navController.navigate-with-popUpTo
replaces the current detail route, so the back stack doesn't hold
the now-404'd old uuid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>