§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>
Audit v2 #13. Four touches to RequestsScreen rows:
* Kind avatar leading icon: Lucide.Disc3 for artist, LibraryBig for
album, Music for track (matches Flutter's mapping).
* Cancel confirmation AlertDialog — single tap on Cancel used to be
irreversible; now shows "Cancel '<name>'? Lidarr will stop searching
for it." with Cancel / Keep buttons.
* Ingest progress text below the status pill when importedAlbumCount
or importedTrackCount > 0: "2 albums · 14 tracks ingested".
* Listen OutlinedButton on completed rows when matchedAlbumId or
matchedArtistId resolves; routes to AlbumDetail (preferred) or
ArtistDetail. Track matches route through AlbumDetail since the
client has no TrackDetail screen.
navController.navigate takes the route object directly. Because the
listenRoute can be AlbumDetail or ArtistDetail (both @Serializable
route types from nav/Routes.kt), the callback signature is (Any) ->
Unit and the screen passes it straight to navigate().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #14. Artists + Albums search sections were rendering as
plain vertical TextRows (name-only for artists, title+artist for
albums). Now match Flutter:
* Artists: horizontal LazyRow of ArtistCard (cover + name)
* Albums: horizontal LazyRow of AlbumCard (cover + title + artist)
* Section headers gain a count suffix ("Artists 12") matching
Flutter's _SectionHeader pattern; also applied to the Tracks header
for consistency.
Removed the now-unused TextRow helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups around the noise at the top of every CI log:
* CI workflow: JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED
at the workflow env level so both build + release jobs apply it to
the launcher JVM (not just the daemon). The launcher is the one
loading native-platform.jar via System.load.
* Kotlin compiler: -Xannotation-default-target=param-property in
kotlin.compilerOptions.freeCompilerArgs. Opts every @Inject /
@ApplicationContext / @ApplicationScope constructor-parameter
annotation into the future Kotlin 2.3 behavior (apply to both
param AND property), clearing the 11 warnings on AuthController /
AuthStore / MutationReplayer / SyncController / EventsStream /
LiveEventsDispatcher / PlayEventsReporter / PlayerController /
PlayerFactory / ResumeController.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92a9b55 added PlayerController to LibraryViewModel for the Library
"Shuffle all" button. Tests need a relaxed mock; none of the four
cases exercise shuffleAll() so the mock just satisfies the
constructor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92a9b55 used the FQN com.composables.icons.lucide.Lucide.Shuffle
inline, but Shuffle is an extension property on the Lucide companion
declared at com.composables.icons.lucide.Shuffle — it has to be
imported into scope. Other files (AlbumDetailScreen,
PlaylistDetailScreen) follow the same pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues:
* AdminRequestsScreen line 105 MagicNumber on UUID prefix length 8.
Extracted to USER_ID_PREFIX_LEN with rationale.
* LibraryRepository TooManyFunctions (12/11) after shuffleLibrary
addition. Same pattern as PlayerController / AuthStore: @Suppress
at the class with rationale (function count scales with entity-
family count, splitting would scatter plumbing).
* JDK 22+ "restricted method java.lang.System::load" warning from
Gradle's bundled native-platform jar. Add
--enable-native-access=ALL-UNNAMED to org.gradle.jvmargs so the
daemon opts the native loader in. Future-compat: Gradle will
declare this in the jar's manifest eventually and the flag becomes
redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #22. LibraryRepository.shuffleLibrary wraps the existing
LibraryApi.shuffleLibrary endpoint; LibraryViewModel.shuffleAll
fires PlayerController.setQueue with the response. LibraryScreen's
TopAppBar gains a Lucide.Shuffle IconButton to the left of the
existing MainAppBarActions row.
Offline-fallback (client-side shuffle over the local cache index)
is part of the larger Connectivity slice (audit #21) and not wired
in this commit — pressing Shuffle when offline just fails silently
for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #18. AdminRequestsScreen was showing only the request's
display name, not which user asked for it. Now fetches the admin
users list in parallel with the requests list, builds a
userId → username map, and renders "Requested by <username>"
beneath each row. Falls back to an 8-char userId prefix when the
users list fetch fails (e.g., admin without users-list access on
some future server-side ACL change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #12. Hidden rows were text-only ("title", "artist · album",
reason chip); Flutter shows the album cover thumbnail and a "3h ago"
relative-time stamp next to the reason. Both added to QuarantineRef
(coverUrl computed from albumId) and HiddenRow renders them.
Time helper is duplicated from HistoryTab inline rather than
hoisted — both copies are small and screen-private; a shared
RelativeTime widget can land later if a third caller surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 priority #9. Track rows on AlbumDetail, PlaylistDetail,
LikedTab, HistoryTab, and SearchScreen now render the currently-
playing track's title in accent (primary) color so the user can
spot "this is what's playing" while scrolling a list.
Pattern: each screen pulls a PlayerViewModel via hiltViewModel(),
reads state.currentTrack?.id, threads it through to its track row
composable as nowPlaying: Boolean. Row picks titleColor based on
the flag. No new infrastructure — just a thread-through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a606267 inlined three IconButton blocks (Prev/Play-Pause/Next)
that pushed MiniRow 2 lines over. Extracted to a private
TransportButton helper — same surface, half the lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 priority #8 — daily-touch player polish. MiniPlayer was
just cover + title + play-pause + kebab; now matches Flutter:
* Slim 4dp Slider at the top of the bar (Material3 thumb + active
primary, no labels — those live on NowPlaying).
* Row: cover | title/artist | LikeButton | Prev | Play/Pause | Next | kebab.
* The cover-and-title region is the only part that expands to
NowPlaying on tap; each IconButton handles its own click so a
prev/next tap doesn't accidentally open the full player.
Bar height bumped 64 → 80dp to fit the slider above the row.
LikeButton sources its state from the shell-level TrackActionsViewModel
already plumbed through ShellScaffold (same hiltViewModel() instance).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c74fa41 fixed MeApi.kt but the MeRepository.kt edit failed silently
(stale Read). MeRepository's KDoc still had the wildcard-path
backtick block that closes the doc comment prematurely. Replacing
it now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per [feedback_ksp_could_not_be_resolved_is_downstream]: KSP's
"X could not be resolved" usually masks a syntax error in X's
source. Both MeRepository.kt and MeApi.kt had \`/api/me/*\` in
their class KDoc — the `*/` inside the doc comment terminates the
comment prematurely, leaving the class body unparseable. KSP then
fails to resolve the type while reporting the symptom at the
caller's constructor.
Replaced the wildcard path with plain prose ("the /api/me
endpoints"). Same fix shape as the Kotlin nested-comment trap
documented in the memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>