Replaces the AdminQuarantine-route ComingSoon stub with the real
admin-side queue. Mirrors `flutter_client/lib/admin/admin_quarantine_screen.dart`
+ `AdminQuarantineController`.
New:
- models/wire/AdminQuarantineWire.kt — AdminQuarantineReportWire +
AdminQuarantineItemWire (per-user report and the aggregated row
that collapses reports into per-reason counts).
- models/AdminQuarantineItem.kt — domain refs.
`topReasonSummary` returns the most-cited reason with a "(+N more)"
suffix when other distinct reasons exist.
- api/endpoints/AdminQuarantineApi.kt — Retrofit list + the three
resolution endpoints (resolve / delete-file / delete-via-lidarr).
- admin/data/AdminQuarantineRepository.kt — list + three actions
with internal mappers; same point-and-shoot pattern as
AdminRequestsRepository (no offline queue).
- admin/ui/AdminQuarantineViewModel.kt — VM + UiState in sibling
file. `act()` collapses the three resolution paths into one
optimistic-remove-then-refetch-on-failure helper.
- admin/ui/AdminQuarantineScreen.kt — Scaffold + TopAppBar with
back button + MainAppBarActions. Each row shows track title +
"artist · album" subtitle + report-count pill + top-reason pill +
three action buttons (Resolve / Delete file / Delete + Lidarr).
Modified:
- nav/MinstrelNavGraph.kt — AdminQuarantine route now renders
`AdminQuarantineScreen(navController = navController)` inside
ShellScaffold.
AdminUsers + AdminLanding + Invites still ComingSoon — D3 lands them
together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the AdminRequests-route ComingSoon stub with the real
admin-side review queue. Mirrors `flutter_client/lib/admin/admin_requests_screen.dart`
+ `AdminRequestsController` from `admin_providers.dart`.
New:
- api/endpoints/AdminRequestsApi.kt — Retrofit GET
/api/admin/requests + POST {id}/approve + POST {id}/reject.
Reuses RequestWire (server returns the same requestView shape as
/api/requests; only the listing scope differs).
- admin/data/AdminRequestsRepository.kt — list + approve + reject
+ internal wire→domain mapper. No MutationQueue fallback —
operator confirmation is point-and-shoot; failures roll back via
refetch rather than queuing for later replay.
- admin/ui/AdminRequestsViewModel.kt — VM + UiState (in its own
file to preempt TooManyFunctions). `decide()` optimistically
drops the row from local state and refetches on failure so the
UI matches the server's view.
- admin/ui/AdminRequestsScreen.kt — Scaffold + TopAppBar with back
button + MainAppBarActions; LazyColumn of rows showing displayName
+ kind/status pills + Approve/Reject button pair.
Modified:
- nav/MinstrelNavGraph.kt — AdminRequests route now renders
`AdminRequestsScreen(navController = navController)` inside
ShellScaffold.
AdminQuarantine + AdminUsers + AdminLanding still ComingSoon — they
follow in D2/D3. No isAdmin gating on the kebab yet either; until
the AuthController lands (Phase 11), the admin entries are reachable
from any account, which is fine on a single-user dev install but
gets gated before any release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- models/Quarantine.kt → QuarantineRef.kt
- models/wire/QuarantineWire.kt → QuarantineMineWire.kt
Same fix as the earlier LikesWire → LikedIdsWire rename — detekt's
MatchingDeclarationName wants files with a single top-level
declaration to share its name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library Hidden-tab ComingSoonTab placeholder with the
real screen. Mirrors `flutter_client/lib/library/library_screen.dart`
`_HiddenTab` / `_QuarantineTile`.
New:
- models/wire/QuarantineWire.kt — @Serializable QuarantineMineWire
matching `/api/quarantine/mine`.
- models/Quarantine.kt — QuarantineRef domain; `reasonLabel` maps
the raw reason key ("bad_rip" / "wrong_file" / "wrong_tags" /
"duplicate" / other) to its display string.
- api/endpoints/QuarantineApi.kt — Retrofit listMine + flag + unflag
plus a FlagRequest @Serializable body for POST. The unflag
endpoint is idempotent server-side, which is what makes the
queued-replay path safe.
- quarantine/data/QuarantineRepository.kt — listMine + offline-first
unflag that returns UnflagOutcome (ACCEPTED vs. QUEUED).
`feedback_offline_first_for_server_writes` rationale identical
to LikesRepository.toggleLike.
- quarantine/ui/HiddenTabViewModel.kt — VM + UiState in its own
file (preempts TooManyFunctions). Optimistic-remove on unflag
with no rollback; the queue replays on failure.
- quarantine/ui/HiddenTab.kt — composable. List of rows showing
track title + "artist · album" subtitle + reason pill + Unhide
icon button (Lucide.ArchiveRestore).
Modified:
- cache/mutations/MutationQueue.kt — adds MutationKind.QUARANTINE_UNFLAG
+ QuarantineUnflagPayload + enqueueQuarantineUnflag helper.
- library/ui/LibraryScreen.kt — TAB_HIDDEN dispatch swaps from
ComingSoonTab to `HiddenTab()`. Drops the now-unreferenced
ComingSoonTab helper.
Admin slices (admin requests / quarantine / users) are still Commit D.
Flag-from-track-row UI lands once Album/Artist/Playlist detail screens
expose per-track action menus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Requests-route ComingSoon stub with the real screen.
Mirrors `flutter_client/lib/requests/requests_screen.dart`.
New:
- models/wire/RequestWire.kt — @Serializable matching
`internal/api/requests.go requestView`. Used by both
/api/requests and /api/admin/requests (admin reuse comes in
Commit D).
- models/Request.kt — RequestRef domain + RequestStatus enum
(PENDING / APPROVED / REJECTED / COMPLETED / FAILED / UNKNOWN).
`displayName` picks the kind-appropriate field (albumTitle for
album-kind etc).
- api/endpoints/RequestsApi.kt — Retrofit: GET /api/requests + DELETE
/api/requests/{id}. The cancel response returns the cancelled
row (not 204), so the wire return type is RequestWire.
- requests/data/RequestsRepository.kt — listMine + cancel with
internal wire→domain mapper. No Room cache — same rationale as
HistoryRepository.
- requests/ui/RequestsViewModel.kt — VM + UiState in a sibling file
(preempts the TooManyFunctions cap that Discover hit).
`cancel(id)` is optimistic: drops the row from local state
immediately, refetches on success (or rolls back via refetch on
failure).
- requests/ui/RequestsScreen.kt — composable: title bar with back
button + MainAppBarActions, LazyColumn of RequestRow tiles
(display name + status/kind pills + Cancel button visible only
on PENDING). Rejected requests surface the server-provided notes
line.
Modified:
- nav/MinstrelNavGraph.kt — Requests route now renders
`RequestsScreen(navController = navController)` inside ShellScaffold.
Hidden tab (quarantine) + admin slices come in Commits C and D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverTiles.kt held only one non-composable top-level decl
(`enum class AvatarShape`), so detekt wanted the file renamed. The
enum had two variants discriminating circle vs. rounded; a Boolean
`circular` arg is the same information with no extra cost — collapses
the `when` into a one-line `if`. Two call sites updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverScreen.kt still tripped TooManyFunctions (13/11) after the VM
split because the file kept all the per-row composables + helpers.
Moved SuggestionTile + ResultTile + Avatar + AvatarShape into a
sibling DiscoverTiles.kt and dropped the now-unused imports
(background, CircleShape, RoundedCornerShape, AssistChip, Disc3,
User, AsyncImage, size, clip, ImageVector, TextOverflow).
DiscoverScreen.kt now hosts 10 functions: the screen + 6 layout-only
helpers (SearchBar, KindChips, SuggestionsPane, SuggestionsList,
SuggestionsHeader, ResultsList) + LoadingCentered + CenteredMessage
+ snackbarFor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverScreen.kt hit detekt's TooManyFunctions limit (11) at 13.
Moved DiscoverState / SuggestionState / ResultsState / DiscoverViewModel
into DiscoverViewModel.kt — same package, same imports, no behavior
change. Screen file now hosts only composables + the snackbarFor helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Discover surface — Lidarr search bar over an out-of-library
artist suggestion feed, with offline-first request-creation through
the MutationQueue. Mirrors `flutter_client/lib/discover/discover_screen.dart`.
New:
- models/wire/DiscoverWire.kt — LidarrSearchResultWire,
ArtistSuggestionWire (with SeedContribution children), and the
CreateRequestBody POST shape.
- models/Discover.kt — domain refs + LidarrRequestKind enum.
ArtistSuggestionRef.attributionText preserves the Oxford-comma,
"Because you liked X / played Y" phrasing from Flutter.
- api/endpoints/DiscoverApi.kt — Retrofit: GET /api/discover/suggestions,
GET /api/lidarr/search (q + kind), POST /api/requests.
- discover/data/DiscoverRepository.kt — read-through search +
suggestion + offline-first createRequest. Returns
RequestOutcome.ACCEPTED on 2xx, RequestOutcome.QUEUED when the
call got buffered into the MutationQueue. Same swallowed-exception
rationale as LikesRepository.toggleLike.
- discover/ui/DiscoverScreen.kt — VM + state + composable. Filter
chips for Artists / Albums kind, search field with ImeAction.Search
submit, results list vs. suggestion feed swap based on whether
the query box is empty. Snackbar wording switches between
"Requested: X" and "Request queued: X" based on outcome.
Locally-just-requested MBIDs are tracked so the "Request" affordance
swaps to a "Requested" pill instantly without waiting for a server
refetch.
Modified:
- cache/mutations/MutationQueue.kt — adds MutationKind.REQUEST_CREATE
+ RequestCreatePayload + enqueueRequestCreate helper.
- nav/MinstrelNavGraph.kt — Discover route swaps from ComingSoon to
`DiscoverScreen(navController = navController)`.
Requests screen (your-own requests list with status/cancel) and the
admin/quarantine slices land in follow-up commits within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folded the early-return ladder in `relativeTime` into a single `when`
returning the result. Same four cutoff windows — `<1h` / `<24h` /
`<7d` / older — just bound to a single expression so detekt's
ReturnCount (limit 2) is satisfied. The opening "unparseable → return
raw" still uses an early return; that's only 2 total now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library History-tab ComingSoon placeholder with a real
listening-history view. Tapping a row plays that single track via
PlayerController, matching Flutter's `_HistoryTab` behavior.
New:
- models/wire/HistoryWire.kt — @Serializable HistoryEventWire +
HistoryPageWire (`{events, has_more}`, the non-`Page<T>` envelope
/api/me/history actually returns).
- api/endpoints/HistoryApi.kt — Retrofit `GET /api/me/history` with
limit/offset query params (50/0 default).
- history/data/HistoryRepository.kt — fetch-only (no Room cache for
v1; the offline snapshot mirror lands with Phase 13). Maps the
wire shape into a UI-friendly HistoryEntry that carries the
converted TrackRef so playback wiring stays consistent with the
other tabs.
- history/ui/HistoryTab.kt — VM + UiState + composable. List of
HistoryRow tiles (title + artist + relative-time stamp);
tap-to-play single track via PlayerController.setQueue. The
`relativeTime` formatter mirrors Flutter's `library_screen.dart`
`_relativeTime` four-window strategy:
< 1h → "Nm ago"
< 24h → "Nh ago"
< 7d → "Tue 14:32"
≥ 7d → "May 1" (or "May 1, 2025" cross-year)
Built on `java.time.OffsetDateTime` — fine on minSdk 26 without
core-library desugaring.
Modified:
- library/ui/LibraryScreen.kt — TAB_HISTORY dispatch swaps from
ComingSoonTab to `HistoryTab()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LikesRepository.kt: the catch around best-effort like REST is
intentional swallow (the queue replays later). Added
`@Suppress("SwallowedException")` alongside the existing
`TooGenericExceptionCaught`, plus a comment explaining the choice
so the next reader doesn't "fix" it.
- models/wire/LikesWire.kt → LikedIdsWire.kt: file held a single
top-level declaration (LikedIdsWire); detekt's
MatchingDeclarationName rule wants the filename to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library Liked-tab ComingSoon placeholder with a real
hydrated view of liked artists / albums / tracks, plus the
offline-first toggle-like write path.
New:
- cache/mutations/MutationQueue.kt — minimal write-side of the
offline mutation queue (Phase 8 v1 only enqueues; the
connectivity-aware drain lands with Phase 12.2 SyncController).
Defines MutationKind.LIKE_TOGGLE + LikeTogglePayload + an
enqueueLikeToggle helper.
- api/endpoints/LikesApi.kt — Retrofit (POST/DELETE
`api/likes/{kind}/{id}` + GET `/api/likes/ids`). Plural-segment
mapping ("artist" → "artists") lives in LikesRepository.
- models/wire/LikesWire.kt — @Serializable LikedIdsWire matching
`internal/api/likes.go likedIDsResponse`.
- likes/data/LikesRepository.kt — observe (hydrated artists /
albums / tracks via the existing CachedAlbumDao /
CachedArtistDao / CachedTrackDao) + observeIsLiked +
toggleLike + refreshIds. Local userId is hardcoded as "local"
behind LOCAL_USER_ID; TODO marker for the Phase-11 swap to
AuthStore.userId. Write path is optimistic Room mutation →
best-effort REST → enqueue-on-failure, per
`feedback_offline_first_for_server_writes`.
- likes/ui/LikedTab.kt — VM + UiState + composable. Three sections
(Artists horizontal row, Albums horizontal row, Tracks list) +
"No likes yet" empty state. Tile taps navigate to detail screens;
track-row taps are disabled until Phase 9 wires history play-from
-here (placeholder so the row reads correctly without an
unfinished playback affordance).
Modified:
- cache/db/DatabaseModule.kt — @Provides for CachedLikeDao +
CachedMutationDao (DAOs existed but had no consumer until now).
- library/ui/LibraryScreen.kt — TAB_LIKED dispatch swaps from
ComingSoonTab to `LikedTab(navController)`.
Like buttons on detail screens come with the AlbumDetail / ArtistDetail
real-screen build, which is a separate later commit (they're still
ComingSoon stubs in MinstrelNavGraph).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaylistDetailScreen takes navController for its TopAppBar back-button,
but the inShellDetail NavGraphBuilder extension was still scoped to
just expandPlayer. Added navController as the first parameter and
updated the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor of the combine chain dropped the closing `}` for the
HomeViewModel class, so the screen composable below ended up
nested inside the ViewModel and the file's brace count was off
by one. Cascaded into "Unresolved reference HomeScreen" downstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second slice of Playlists feature parity. PlaylistDetail route now
renders the real screen instead of the ComingSoon stub; Home's
Playlists row now shows actual PlaylistCards instead of the
placeholder header.
New:
- playlists/ui/PlaylistDetailScreen.kt — VM + UiState + Screen.
Header (cover + name + description + track count + Play / Shuffle
buttons) over a track list (numbered TrackRow with duration).
Unavailable tracks (trackId == null because the upstream library
row was removed) render at 0.4 alpha and don't accept taps, per
Flutter's `isAvailable` convention. Tap on a track plays the
playlist starting there via `PlayerController.setQueue(refs,
initialIndex, source = "playlist:$id")`. Shuffle reuses the same
path on a `shuffled()` copy — cheap for the page-sized list.
Modified:
- home/ui/HomeScreen.kt — HomeViewModel now also takes
PlaylistsRepository; calls refreshList() alongside refreshIndex()
on init. HomeSections gets a `playlists: List<PlaylistRef>` field
(factored into isAllEmpty). HomeSuccessContent shows a real
PlaylistsRow when non-empty (replacing the "Lands in Phase 7"
EmptySectionHeader). Tile tap navigates to PlaylistDetail.
Combine arity capped at 5 by kotlinx.coroutines, so the screen
splits into observeHomeSections() (the five HomeRepository flows)
chained against the PlaylistsRepository flow — avoids untyped
vararg-combine gymnastics across heterogeneous list types.
- nav/MinstrelNavGraph.kt — PlaylistDetail route swaps from
ComingSoon to `PlaylistDetailScreen(navController = navController)`.
The route id flows in via SavedStateHandle.toRoute() inside the
ViewModel rather than backStackEntry.toRoute(), so the composable
block is back to a one-liner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation slice of Playlists feature parity with Flutter v2026.05.21.0.
Replaces the ComingSoon stub on the Playlists route with a real
two-section list screen (System playlists / Your playlists), cache-first
against `cached_playlists`.
New:
- models/Playlist.kt — PlaylistRef + PlaylistTrackRef domain models.
Mirrors `flutter_client/lib/models/playlist.dart` (id + name +
isSystem + cover + ownerUsername etc.; PlaylistTrack carries the
full per-row display fields since cached_playlist_tracks only holds
ordered (playlistId, trackId, position) triples).
- models/wire/PlaylistWire.kt — @Serializable wire types
(PlaylistWire / PlaylistsListWire / PlaylistTrackWire /
PlaylistDetailWire), matching `internal/api/playlists.go`.
- api/endpoints/PlaylistsApi.kt — Retrofit interface (list + get).
Read-only for now; create/append/refreshSystem land with the
mutation-queue phase.
- playlists/data/PlaylistsRepository.kt — observe (all / user /
system) cache-first reads + refreshList / refreshDetail that
upsert Room and return a hydrated PlaylistDetailRef. Internal
entity↔wire↔domain mappers kept private.
- playlists/widgets/PlaylistCard.kt — 176dp tile sized to match
AlbumCard for visual consistency in the upcoming Home carousel.
Subtitle shows the system-variant label ("For You" / "Discover" /
"Songs like…" / etc.) or "N tracks" for user playlists.
- playlists/ui/PlaylistsListScreen.kt — Scaffold + TopAppBar +
MainAppBarActions; LazyVerticalGrid with adaptive 176dp cells.
Renders both `System playlists` and `Your playlists` sections via
full-width section headers (`GridItemSpan(maxLineSpan)`).
Modified:
- cache/db/DatabaseModule.kt — adds @Provides for CachedPlaylistDao
+ CachedPlaylistTrackDao (DAOs existed on AppDatabase since slice 1
but had no consumer until now).
- nav/MinstrelNavGraph.kt — swaps the ComingSoon stub for
`PlaylistsListScreen(navController = navController)`.
PlaylistDetail route still ComingSoon; lands in the next commit along
with Home-page Playlists row integration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detekt flagged the `3` and `4` branch labels in the `when (selectedTab)`
block. Promoted all five indices to TAB_ARTISTS … TAB_HIDDEN constants
— same shape detekt would have suggested.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the transitional two-LazyRow shape with the proper 5-tab
Library matching `flutter_client/lib/library/library_screen.dart`:
[Artists] [Albums] [History] [Liked] [Hidden]
- Artists / Albums — real adaptive 3+-up LazyVerticalGrid backed by
the existing LibraryViewModel (cache-first reads of cached_artists
/ cached_albums). GridCells.Adaptive sizes from card width
(ArtistCard 144dp, AlbumCard 176dp), so phones get 3 cols and
tablets pack more.
- History / Liked / Hidden — EmptyState placeholders with phase
pointers (Phase 9 / 8 / 10 respectively). The data-layer plumbing
for each lands with its umbrella phase, not as a Home/Library
polish patch.
TabBar is `PrimaryScrollableTabRow` (Material3) so on narrower screens
the row scrolls horizontally without truncating labels — same
behavior as Flutter's `TabBar(isScrollable: true)`.
The Library tab is now reachable both as the in-shell route AND
through MainAppBarActions; the icon row suppresses the Library icon
when this screen is current (per `currentRouteName = Library::class.qualifiedName`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HomeRepository's CachedHomeIndexDao constructor injection failed with
"cannot be provided without an @Provides-annotated method" — the DAO
existed on AppDatabase but the per-DAO bridge in DatabaseModule was
never added (no consumer until HomeRepository landed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ComingSoon stub with the actual Home matching the Flutter
client's `home_screen.dart` shape — vertical Column of named horizontal
sections, each row a labeled `HorizontalScrollRow`:
Playlists (placeholder header — Phase 7)
Recently added → AlbumCards
Rediscover → AlbumCards (and/or ArtistCards)
Most played → CompactTrackTile
Last played → ArtistCards
Wire path: `GET /api/home/index` → `HomeRepository.refreshIndex()`
upserts five sections into `cached_home_index`. Per-section Flows
observe that table and hydrate each ID against the existing
album/artist/track DAOs — rows that haven't been pulled into Room yet
are silently dropped from emission (the per-tile hydration queue from
Phase 7+ will fill those gaps).
New files:
- models/wire/HomeIndexWire.kt — @Serializable wire shape
- api/endpoints/HomeApi.kt — Retrofit `GET /api/home/index`
- home/data/HomeRepository.kt — observe/refresh + section constants
- home/ui/HomeScreen.kt — composable + HomeViewModel + HomeUiState
+ inline CompactTrackTile (proper CompactTrackCard lands in Phase 7
alongside player play-by-ID and per-track cover hydration)
- shared/widgets/HorizontalScrollRow.kt — labeled LazyRow wrapper
that takes a LazyListScope block (keeps row items lazy)
Modified:
- nav/MinstrelNavGraph.kt — startDestination = Home (was Library;
matches Flutter's `redirect '/' → '/home'`); Home composable now
renders the real `HomeScreen(navController)` inside `ShellScaffold`
CompactTrackTile uses a Lucide.Music placeholder for the cover because
TrackRef doesn't carry coverUrl — matching the Flutter tile-provider
hydration is a Phase-7 follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream Lucide renamed `more-vertical` → `ellipsis-vertical`; the
icons-lucide-cmp 2.2.1 bundle only exposes the new name, so the
import was unresolved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detekt LongMethod (97/60 lines). Body had no actual logic — just 13
sequential composable<T>() route declarations. Split by route category
(matches the existing comment headers):
- inShellTopLevel(navController, expandPlayer) — 8 in-shell tabs
- inShellDetail(expandPlayer) — 6 push-on-top detail screens
- outsideShell() — NowPlaying (with slide-up transitions), Queue,
ServerUrl, Login
`MinstrelNavGraph` itself is now 15 lines (NavHost shell that delegates
to the three groups), and each helper sits well under the 60-line cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with Flutter — there is no bottom nav, drawer, or rail. Every
in-shell screen carries `MainAppBarActions` in its AppBar:
[Home] [Library] [Search] [ ⋮ Playlists / Discover / Settings / Admin ]
The icon for the current screen is suppressed so the action set looks
contextual.
- shared/widgets/MainAppBarActions.kt — NEW. The icon row + kebab.
Takes navController + currentRouteName (FQN) so each screen tells
it which icon to hide. Admin kebab entry gated on `isAdmin` for
later; defaults false.
- shared/widgets/ShellScaffold.kt — NEW. Wraps any in-shell screen
with the MiniPlayer pinned at the bottom (auto-hides on no-track,
matches Flutter `_ShellWithPlayerBar`). Banners slot reserved for
later (VersionTooOld / UpdateBanner).
- nav/Routes.kt — added Discover, Playlists, Requests, Admin,
AdminRequests, AdminQuarantine, AdminUsers, ServerUrl. Grouped
by shell-vs-full-screen comments.
- nav/MinstrelNavGraph.kt — every in-shell route wrapped in
ShellScaffold. NowPlaying becomes a full-screen route with
slideInVertically/slideOutVertically transitions (mirrors
Flutter's CustomTransitionPage modal). Queue / ServerUrl / Login
are also outside the shell.
- MainActivity.kt — drops Scaffold + NavigationBar + the BottomBarTab
list. Just hosts the NavHost now. The MiniPlayer moves into
ShellScaffold per the Flutter pattern.
- library/ui/LibraryScreen.kt — wraps itself in Scaffold + TopAppBar
+ MainAppBarActions (currentRouteName = Library FQN). Accepts
navController; album/artist taps navigate via the controller
instead of via callbacks. Body content still transitional — the
proper 5-tab restructure lands in sub-task 4.
Routes-without-real-screens (Home, Search, Discover, Playlists,
Settings, Admin, Requests, AdminRequests/Quarantine/Users, Queue,
ServerUrl, Login) all render ComingSoon stubs via EmptyState so the
nav graph is fully reachable end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-task 1 of the design-drift correction. Adds light theme support
and adopts the Flutter pattern of consuming tokens directly via a
theme-extension equivalent (CompositionLocal data class) rather than
fitting everything into Material's ColorScheme roles.
Files:
- theme/FabledSwordTokens.kt — split the monolith into three
objects: FabledSwordDarkTokens (#14171A obsidian, etc.),
FabledSwordLightTokens (#F8F5EE obsidian, #14171A parchment — note
the semantic inversion, names are roles not literal colors),
FabledSwordFlatTokens (moss/bronze/oxblood/warning/error/info/
accent/onAction + radii). The old FabledSwordTokens object is
kept as a @Deprecated alias re-exporting dark+flat — existing call
sites compile with a warning until migrated.
- theme/FabledSwordTheme.kt — NEW. Data class holding the full
14-color set + radii, with Dark/Light companion factories.
LocalFabledSwordTheme CompositionLocal exposes the active variant.
- theme/MinstrelTheme.kt — both darkColorScheme and lightColorScheme
defined; MinstrelTheme composable accepts darkOverride and falls
back to isSystemInDarkTheme(). Provides LocalFabledSwordTheme +
keeps LocalActionColors for back-compat.
- widget files (AlbumCard, MiniPlayer, NowPlayingScreen,
ActionColors) migrated to import FabledSwordFlatTokens directly
for radii / action colors (mode-independent).
Sub-tasks 2-4 (shell, Home, Library tabs) will follow, each its own
commit. Detail screens stay as stubs until their feature phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding `<ResumePayload>` to encodeToString made it bind to the wrong
overload — the compiler picked `encodeToString(SerializationStrategy<T>,
T)` and tried to treat `payload` as the serializer. Switched to the
explicit form:
json.encodeToString(ResumePayload.serializer(), payload)
Always unambiguous. (Decode is fine — `decodeFromString<T>(String)`
isn't overloaded the same way.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kotlin 2.3 + kotlinx.serialization 1.7 — the reified overload of
`Json.encodeToString(value: T)` failed to infer T from the argument's
type at the call site (the compiler resolved to the
SerializationStrategy + value overload and complained both about
the type mismatch and the missing 'value' arg). Specifying
`<ResumePayload>` explicitly resolves to the correct overload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
restore() had 3 explicit returns (row null + decode-failure + empty
tracks) — over detekt's ReturnCount limit of 2. Folded the decode
+ empty-check into a single runCatching chain:
runCatching { decode }
.onFailure { dao.clear() side-effect }
.getOrNull()
?.takeIf { tracks.isNotEmpty() }
?: return
Two returns now (row missing + the chain result null). Cleaner read
too — the side effect of dropping a corrupt row is right next to the
decode it guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 closes. A torn-down player session now resumes the last queue
on next app launch — the equivalent of the Flutter ResumeController's
job, but plumbed via PlayerController's StateFlow rather than the
audio_service idle-stop dance.
Files:
- models/TrackRef.kt: add @Serializable so List<TrackRef> can be
JSON-encoded by the persistence path (mild leak of persistence
concern into the domain type; alternative duplicate-DTO approach
not worth the boilerplate yet).
- player/ResumePayload.kt: @Serializable persisted shape
(schema version + tracks + queueIndex + positionMs + source).
`schema` field lets future schema drift drop unreadable rows
gracefully rather than crash.
- player/ResumeController.kt: collects PlayerController.uiState;
persists when (currentTrack id, queueIndex, queue.size) changes —
captures real session transitions without churning on the 1Hz
position tick. restore() decodes the row and calls
PlayerController.setQueue. Catches SerializationException +
drops the row on schema drift.
- cache/db/DatabaseModule.kt: @Provides CachedResumeStateDao bridge.
- MinstrelApplication: @Inject ResumeController + ApplicationScope
CoroutineScope; onCreate launches resumeController.restore().
Injecting forces Hilt to construct the singleton so its
observe-and-persist init block runs.
No circular DI — ResumeController depends on PlayerController, not
the other way around.
This closes Phase 6 of the M8 native rewrite. The player layer is
feature-complete enough to demo on a device once playback wiring
arrives (Phase 11 settings → server URL, Phase 12 sync controller →
library data, and a "Play this album" affordance — none of which
exist yet).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NowPlayingScreen was 74 lines vs detekt's 60 threshold. Split into
three logical pieces: CoverPlaceholder, TrackHeader, and the
top-level Column orchestrator (which now stays under threshold and
reads more clearly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First visible player surfaces.
- player/ui/PlayerViewModel.kt — thin HiltViewModel wrapping the
singleton PlayerController. Both MiniPlayer and NowPlayingScreen
hiltViewModel() one of these; the underlying state is shared by
construction (controller is process-singleton).
- player/ui/MiniPlayer.kt — collapsed bar above the bottom nav.
Returns nothing when no track is loaded (zero footprint on fresh
install). Tap body → navigate(NowPlaying). Cover-art slot is a
Lucide placeholder for now; covers wire up when AlbumRef joins
land in a later 5.x slice.
- player/ui/NowPlayingScreen.kt — full-screen player. Square cover
(placeholder), title + artist + album, scrubber (Slider with
seek-on-release), transport row (prev / play-pause / next).
EmptyState fallback when no track. Play/pause button uses
LocalActionColors.primary (Moss) per design-system rule.
- MainActivity: Scaffold bottomBar slot now wraps MiniPlayer +
MinstrelBottomBar in a Column so the mini sits above the nav.
- MinstrelNavGraph: NowPlaying composable now renders the real
screen instead of the "Coming soon" stub.
scrubber-position-while-playing is event-driven for now (Media3
batches via Player.Listener.onEvents). A periodic 1Hz refresh for
smooth scrubber animation can come later if it's wanted; functional
seeking + position display work without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Two TooGenericExceptionCaught: connectAndObserve + connectController
both catch Exception over Media3 IPC boundaries where specific-
exception handling buys nothing (ListenableFuture.get() throws
ExecutionException / InterruptedException / CancellationException —
all forwarded uniformly). Widened to Throwable and @Suppressed with
a one-line rationale each.
- MaxLineLength: refactored TrackRef.toMediaItem's nested
`.apply { source?.let { setExtras(Bundle()...) } }` chain into a
pair of expression-bodied helpers (metadata builder + sourceExtras).
Reads cleaner; under 120 chars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hilt-singleton facade over Media3 MediaController (the IPC client to
MinstrelPlayerService's MediaSession). One process-wide controller +
one StateFlow projection means ViewModels don't each attach their
own Player.Listener.
PlayerUiState: data class with currentTrack / queue / queueIndex /
isPlaying / isBuffering / positionMs / durationMs / bufferedPositionMs
/ playbackError. The mini-player + NowPlayingScreen (Phase 6.4) read
this; the rest of the app sees one consistent player snapshot.
PlayerController:
- init: async connectAndObserve via suspendCancellableCoroutine
bridging Media3's ListenableFuture<MediaController>.buildAsync().
Skips the kotlinx-coroutines-guava dep (Runnable::run is a direct
executor; the listener just unparks our continuation).
- Transport methods (play/pause/seekTo/skipToNext/skipToPrevious)
are no-ops until the controller connects; safe to call early.
- setQueue(tracks, initialIndex, source) — TrackRef -> MediaItem
with mediaId + uri + metadata; source tag goes in extras for the
server-side rotation reporter (#415 parity).
- Player.Listener.onEvents drives uiState snapshot — Media3 batches
related events so we don't churn the StateFlow per-event.
- queueRefs kept as our own list so the UiState projection has
domain TrackRefs (Media3 has MediaItems internally).
No tests yet — PlayerController's main behavior is IPC-mediated and
benefits from an instrumented test (Robolectric or device). JVM
unit tests for it would mostly mock the MediaController and verify
trivial method-forwarding. Deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The actual replacement for everything audio_service plugin wrapped.
Media3 owns the foreground-service lifecycle, MediaSession token,
notification card, lock-screen surface, Bluetooth/AVRCP routing,
Pixel Watch tile, and Android Auto adapter natively — no plugin
layer between us and the platform.
Service shape (~25 LOC):
- @AndroidEntryPoint MediaSessionService
- @Inject PlayerFactory builds ExoPlayer in onCreate
- onGetSession returns the live MediaSession to any binding
controller (system UI, Wear OS companion, MediaController3 clients)
- onTaskRemoved keeps playing while audio is active (standard
media-app behavior); otherwise stopSelf so notification clears
- onDestroy releases session + player
Compare with flutter_client/lib/player/audio_handler.dart's 1000+ LOC
across MinstrelAudioHandler + the soft-teardown / stall-watchdog /
recovery machinery. Media3 owns most of that natively; we'll get to
the small portions we still need (queue management, position
reporting facade) in 6.3.
Manifest registration: foregroundServiceType="mediaPlayback" +
MediaSessionService intent-filter. MediaButtonReceiver is registered
by the Media3 library; no manual receiver class needed (Flutter's
manifest had to declare audio_service's receiver explicitly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detekt's PackageNaming rule rejects underscores in package names
(Kotlin/Java convention is lowercase, no separator). Renamed
com.fabledsword.minstrel.cache.audio_cache -> .audiocache.
Pattern for future multi-word subpackages: smush rather than _
separator (e.g. mutationqueue, synccontroller, when those land).
The on-disk audio_cache/ dir path inside the app cache (PlayerFactory.kt:41)
is unaffected — that's a filename string, not a package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Media3 wiring. PlayerFactory builds the process-singleton
ExoPlayer with our shared OkHttp + SimpleCache chain; the
MinstrelPlayerService (Phase 6.2) calls build() in onCreate.
Chain shape:
ExoPlayer
.setMediaSourceFactory(DefaultMediaSourceFactory + CacheDataSource)
.setAudioAttributes(USAGE_MEDIA + CONTENT_TYPE_MUSIC, focus=true)
.setHandleAudioBecomingNoisy(true)
CacheDataSource
.setCache(SimpleCache(audio_cache dir, LRU evictor, Room standalone DB))
.setUpstreamDataSourceFactory(OkHttpDataSource over shared OkHttp)
.setCacheWriteDataSinkFactory(CacheDataSink full-fragment)
Built-in audio focus + becoming-noisy handling — Media3 owns these so
no audio_session-equivalent code path is needed (the Flutter app had
~40 LOC for the same; here it's three lines of config).
simpleCache exposed as a PlayerFactory val so the AudioCacheEviction
Worker (Phase 12.3) can call removeSpan() during 2-bucket eviction.
CacheConfig (defaults 200MiB liked + 150MiB rolling) — Phase 11
Settings will let users override.
Bumped Media3 1.4.1 -> 1.10.1 (current stable, AGP 9 + Kotlin 2.3
friendly, MediaSessionService now extends LifecycleService which
makes 6.2's lifecycle-aware patterns cleaner). Breaking changes
between 1.4 and 1.10 don't affect our usage (DRM, FrameExtractor,
ChannelMixingMatrix — we use none).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last task of Phase 5. The app now has the full bottom-bar shell with
NavHost wiring.
- nav/Routes.kt — @Serializable destinations: top-level tabs
(Home/Library/Search/Settings), detail screens with id args
(AlbumDetail, ArtistDetail, PlaylistDetail), overlays
(NowPlaying, Queue, Login).
- nav/MinstrelNavGraph.kt — NavHost with composable<RouteType>()
destinations. Library wires to the real LibraryScreen; everything
else uses the shared EmptyState as a "Coming soon" placeholder.
- MainActivity — Scaffold with NavigationBar bottom bar.
Selected-tab tracking via currentBackStackEntryAsState +
NavDestination.hasRoute(KClass) (type-safe routes API in
nav-compose 2.8+).
- Library cards' onArtistClick / onAlbumClick now navigate to
ArtistDetail(id) / AlbumDetail(id) — stubs for now, lit up when
those detail screens land.
Bottom-bar icons (Lucide CMP):
- House, LibraryBig, Search, Settings
startDestination = Library so the new UI is the cold-start landing
spot until the Home screen lands in Phase 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First user-visible UI. LibraryScreen renders the LibraryViewModel
UiState into horizontally-scrolling Artist / Album rows; Loading
shows a centered spinner, Empty / Error fall back to shared widgets.
Files:
- library/ui/LibraryScreen.kt — top-level screen, hiltViewModel
+ collectAsStateWithLifecycle, exhaustive when(state)
- library/widgets/ArtistCard.kt — circular cover + name beneath
- library/widgets/AlbumCard.kt — 144dp square cover + title +
artist beneath, matches Flutter spec (~176dp tile width)
- shared/widgets/EmptyState.kt — generic empty-state widget
(Lucide Inbox by default), used by Library + reusable for
Quarantine / search etc.
- shared/widgets/ErrorRetry.kt — error message + retry button
(uses LocalActionColors.primary = Moss per design system rule)
Audit-deferred items now triggered:
- MinstrelApplication implements SingletonImageLoader.Factory and
wires OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })
so Coil cover-art requests reuse the shared auth-bearing OkHttp
- Lucide icons via com.composables:icons-lucide-android:2.2.1 for
placeholder / decorative iconography
MainActivity now renders LibraryScreen inside a Scaffold (not the
"phase 1" text placeholder). Nav-graph wiring deferred to Phase 5.5
— onArtistClick / onAlbumClick are no-op for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three failing assertions tested an implementation detail. Under
UnconfinedTestDispatcher (MainDispatcherExtension's default), stateIn's
upstream Flow runs synchronously when the first subscriber attaches,
so the `Loading` initialValue gets replaced by the upstream emission
before Turbine's .test{} sees it. The observable behavior we care
about is the resolved state — Empty/Success/Error — not the
intermediate Loading.
Tests now collect the resolved state as the first awaitItem(), which
is what users actually see. The Loading state still exists in
production (StateFlow initialValue is preserved across the brief
window before stateIn collects the first upstream value when the
real dispatcher isn't unconfined).
Also cleared two compile warnings the run surfaced:
- AuthCookieInterceptorTest: added @OptIn(ExperimentalCoroutinesApi)
for UnconfinedTestDispatcher
- LibraryRepositoryTest: hoisted the Json instance into a companion
object (detekt warned about per-call creation)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LibraryRepository @Inject-constructs with CachedArtistDao, CachedAlbumDao,
CachedTrackDao. Hilt errored at hiltJavaCompileDebug with "MissingBinding"
for all three — Phase 4 only added the @Provides bridge for
AuthSessionDao in slice 10 (its single consumer).
Same one-line bridge per DAO: `db.<dao>()` from the AppDatabase
accessor. Future DAOs land in DatabaseModule when their first
@Inject-constructed consumer appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Hilt-injected ViewModel + sealed UiState pattern.
LibraryUiState (sealed interface): Loading / Empty / Success / Error.
The cases are exhaustive so Compose `when` blocks the compiler checks.
LibraryViewModel:
- combine(observeArtists, observeAlbums) → Success/Empty decision
- .catch translates upstream Flow exceptions to UiState.Error
- .stateIn(viewModelScope, WhileSubscribed(5_000), Loading) — the
standard Compose-friendly pattern; subscriptions tear down 5s after
the last collector to ride out config changes without hanging the
DAO Flow forever.
MainDispatcherExtension — JUnit 5 equivalent of the JUnit 4
MainDispatcherRule pattern (audit-deferred item; trigger met). Swaps
Dispatchers.Main for UnconfinedTestDispatcher in beforeEach +
resetMain in afterEach. Apply with `@ExtendWith`.
LibraryViewModelTest covers all four UiState cases — initial Loading,
empty cache (Empty), populated cache (Success), and an upstream Flow
exception (Error). MockK for the repo, Turbine for the Flow assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cache-first reads of artists/albums/tracks. The Room DAOs are the source
of truth ViewModels observe; refreshArtistDetail / refreshAlbumDetail
pull from the server and upsert into Room — Flow emissions propagate
automatically.
- models/TrackRef.kt, models/ArtistRef.kt, models/AlbumRef.kt — domain
types mirroring flutter_client/lib/models/. `Ref` suffix matches
Flutter convention (lightweight reference, not full per-row metadata).
- library/data/LibraryMappers.kt — wire->entity (for sync writes),
entity->domain (for cache reads in ViewModels), wire->domain (for
fresh server responses bypassing cache), detail-wire->entity (drops
embedded array, repository upserts those separately).
- library/data/LibraryRepository.kt — Hilt-injected, observe* Flow
methods + suspend refresh* methods that upsert through the relevant
DAOs. Constructs its own LibraryApi via `retrofit.create()` per the
"repos own their interfaces" pattern adopted in NetworkModule.
- LibraryRepositoryTest.kt — MockK + Turbine + MockWebServer.
Verifies the Flow mapping, the wire->entity upsert split, and the
null-on-miss case for getArtist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kotlin (unlike Java) supports nested block comments. The doc-comment
on LibraryApi contained the string `/api/*` and `/api/home`-style
paths, which the lexer parsed as opening nested comments:
/**
* Retrofit interface for the server's native /api/* library surface. ← lexer: nested /* opens
...
*/ ← closes the nested one
// outer comment now unclosed; "Unclosed comment" reported at EOF
This compile error is what caused all the "ModuleProcessingStep was
unable to process NetworkModule because LibraryApi could not be
resolved" failures over the last four commits — KSP runs before
compileDebugKotlin and reports the downstream symptom (unresolvable
symbol) before the actual source-level error gets to print.
Rewrote the doc-comment to use `/api/...` and to wrap concrete paths
in backticks; no `/*` substring remains.
The "repos construct their Retrofit interface from shared Retrofit"
pattern from the previous commit stays; it's a sound pattern arrived
at via the wrong reasoning, but defensible on its own merits (fewer
Hilt bindings, locality of reference, easier test override).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "ModuleProcessingStep was unable to process NetworkModule because
LibraryApi could not be resolved" failure under KSP2 + Hilt 2.59.2
turns out to be specific to @Provides returning a hand-written Kotlin
interface that carries no KSP-processed annotations. Hilt's
ModuleProcessingStep resolves the return type through KSP2's API and
gets an ERROR type for source-only interfaces in some configurations
(google/dagger#4303 cluster).
Two source-of-truth interfaces I tested side-by-side:
- AuthSessionDao (@Dao, Room-processed) — @Provides works
- LibraryApi (only @GET Retrofit annotations, no KSP processor) — fails
Workaround that's actually a better pattern: feature repositories
construct their Retrofit interface from the Hilt-injected shared
Retrofit instance. Fewer bindings in the Hilt graph; one Retrofit
interface lives next to its sole consumer.
LibraryApi.kt + wire types remain; LibraryRepository (Phase 5.2) will
hold the `retrofit.create<LibraryApi>()` call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hypothesis for the KSP2 "LibraryApi could not be resolved" failure:
ArtistWire.kt and AlbumWire.kt each declared TWO @Serializable
classes (the Ref and the Detail variant). LibraryApi imports the
Detail variants but the file names match the Ref variants. KSP2's
symbol indexing may key on `className.kt` and fail to surface the
second declaration in a multi-class file.
Splitting per the MatchingDeclarationName convention:
- ArtistDetailWire.kt (new)
- AlbumDetailWire.kt (new)
- ArtistWire.kt / AlbumWire.kt now contain only their namesake type
If this fixes it, the LibraryApi resolution will work without
changing the @Provides signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI hit a KSP/Hilt resolution error on the prior commit:
ModuleProcessingStep was unable to process 'NetworkModule' because
'LibraryApi' could not be resolved.
Switching from `retrofit.create(LibraryApi::class.java)` to the Kotlin
extension `retrofit.create()` (with explicit `LibraryApi` return type
annotation). The extension is reified and may sidestep whatever
type-resolution path the previous form tripped under KSP2 + Hilt.
If this also fails, the next step is to split AlbumWire.kt and
ArtistWire.kt so each file has a single top-level declaration —
investigating cross-file symbol-resolution order in KSP2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/api/endpoints/library.dart 1:1.
Wire types (snake_case @SerialName per server JSON):
- TrackWire — id/title/album/artist/duration/streamUrl + nullable
track/disc numbers (fields verified against TrackRef.fromJson in
flutter_client/lib/models/track.dart)
- ArtistWire / ArtistDetailWire — the detail shape embeds "albums"
- AlbumWire / AlbumDetailWire — the detail shape embeds "tracks"
The Detail variants are explicit data classes (rather than a generic
envelope) because the server returns ArtistRef fields PLUS the
embedded array in the same object, which kotlinx.serialization can't
deserialize through a polymorphic envelope.
LibraryApi endpoints:
- getTrack(id)
- getArtistDetail(id) — ArtistDetailWire
- getArtistTracks(id) — bare List<TrackWire> (server emits a bare
array, NOT enveloped; Retrofit handles it via List return type)
- getAlbumDetail(id) — AlbumDetailWire
- shuffleLibrary(limit) — bare List<TrackWire>
Home endpoints (/api/home and /api/home/index) deferred to a future
HomeApi file because they have their own (larger) wire types that
only the Home screen consumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last slice. Promotes the Phase 3.1 in-memory AuthStore placeholder to a
Room-backed single-row auth_session table so session cookie + base URL
survive process death.
Design — hybrid storage:
- MutableStateFlow is the primary read source so interceptor-thread
reads stay synchronous (no awaiting a DAO call from inside an
OkHttp interceptor)
- Writes update the in-memory state synchronously AND launch a
write-through coroutine that persists to the DAO
- init() collects dao.observe() to keep in-memory in sync with
persisted state on app start + any external DB writes
AuthSessionDao gets partial-update queries (`setSessionCookie` /
`setBaseUrl`) so we don't have to round-trip the full row on every
mutation. First write does an upsert to seed the row.
DatabaseModule grows a @Provides for AuthSessionDao — Hilt can't inject
AppDatabase's abstract DAO accessors directly; each consumer-needed DAO
gets a thin bridge.
AuthCookieInterceptorTest updated: AuthStore now takes (dao, scope)
constructor args. Test uses mockk for the DAO and TestScope with
UnconfinedTestDispatcher so the in-memory state mutations the test
asserts on aren't affected by the asynchronous DAO writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedHomeIndex — per-item
rows that drive the Home screen sections (Recently Added Albums,
Rediscover Albums/Artists, Most Played Tracks, Last Played Artists).
Composite PK (section, position) — exactly one row per slot per
section; sync replaces in-place via upsert. entityType ("album" /
"artist" / "track") dispatches per-tile hydration to the right
per-entity endpoint when the Home screen renders.
DAO surface fits the sync flow:
- observeBySection (Flow) for the Home composables
- getBySection (suspend) for one-shot sync reads
- upsertAll for sync writes
- deleteBySection for replace-all on a section sync
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes from re-reading the Drift source for slice 8:
1. The plan called this slice "last_played" but the Drift table is
`cached_resume_state` — kept Drift's name for cross-reference
during the port. Single-row JSON-blob pattern (queue, currentIndex,
positionMs, source) — ResumeController (Phase 6.5) handles the
Kotlin-side encode/decode so the schema stays stable across
resume-shape evolution.
2. CacheSource enum was incomplete: Task 4.1 ported only 3 of the 5
Drift variants. Added AUTO_LIKED + AUTO_PLAYLIST (used by the
auto-cache prefetcher to tag cached files by reason — drives the
bucket eviction priority order INCIDENTAL > AUTO_PREFETCH >
AUTO_PLAYLIST > AUTO_LIKED > MANUAL).
No data migration needed — schema version is still 1 and we have no
real users yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>