Foundation for audit #5 Storage settings. CacheSettings carries the
four user-tunable cache prefs (liked/rolling caps, prefetch window,
cache-liked toggle) mirroring Flutter's cache_settings_provider.dart
field-for-field. Defaults match Flutter (5 GiB per bucket, prefetch
window = 5, cache-liked = true).
Persistence rides AuthSessionEntity (the de-facto single-row prefs
table) as a JSON blob in a new cacheSettingsJson column. DB version
bump 4→5; destructive migration per the pre-release policy.
PlayerModule.provideCacheConfig now snapshots AuthStore.cacheSettings
at injection time. SimpleCache is constructed once per process, so
limit changes from the Settings UI (next commit) take effect on next
app launch. Documented in the @Provides KDoc.
Storage section UI lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous per-screen SSE wiring (525873f) updated the constructor +
init block of AdminRequestsViewModel but the matching imports +
RELEVANT_EVENT_KINDS const were silently dropped from the edit,
so KSP couldn't resolve the EventsStream type. The other four VMs
got the imports correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five ViewModels gain EventsStream collectors:
- PlaylistsListViewModel — playlist.* → repo.refreshList()
- PlaylistDetailViewModel — filter on this screen's playlistId:
- playlist.updated / playlist.tracks_changed → refresh()
- playlist.deleted → emit on a Channel<Unit>; screen collects and
pops back so the user isn't stranded on a 404 detail.
- RequestsViewModel — request.status_changed → refresh()
- AdminRequestsViewModel — request.status_changed → refresh()
- AdminQuarantineViewModel — quarantine.* → refresh()
Combined with the central LiveEventsDispatcher (like-family events),
audit #4 cross-device reactivity is now wired across every screen
that has stale-from-other-device exposure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscribes to EventsStream and maps like-family events to
LikesRepository.refreshIds(). Cross-device likes (web flips a heart,
phone reflects it) now propagate without a manual refresh.
ProcessLifecycleOwner foreground hook re-runs the same refresh as
defensive cold-start cleanup — matches Flutter's resume-handler.
Screen-scoped events (playlist.deleted for a specific id, single-
request status_changed) intentionally NOT in the dispatcher; those
ride EventsStream.events directly from per-screen ViewModels in the
next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for audit #4 (cross-device reactivity). Long-lived SSE
subscription exposed as a process-wide SharedFlow<LiveEvent>; gated
on having a session cookie (opens on sign-in, closes on sign-out).
Mirrors flutter_client/lib/shared/live_events_provider.dart in
behavior — no client-side timeout (server heartbeats every 15s),
no explicit reconnect-with-backoff in v1 (auth transitions re-open).
LiveEvent carries kind / userId / data (JsonObject); consumers
deserialize the payload per event kind they handle.
Force-injected into MinstrelApplication via the same pattern as
MutationReplayer / SyncController / ResumeController /
PlayEventsReporter so the singleton constructs at app start.
okhttp-sse was already on the classpath; no new deps.
No consumers yet — LiveEventsDispatcher + per-screen subscribers
land in follow-up commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding the four shuffle/repeat parameters to the BottomActionsRow
call pushed the function 1 line over the 60-line cap. Extracted
the Column body into a private NowPlayingBody helper composable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit #3 — PlayerController gains toggleShuffle / cycleRepeat
methods backed by Media3's Player.shuffleModeEnabled +
Player.repeatMode. PlayerUiState surfaces both via a new
shuffleEnabled flag and a RepeatMode enum (OFF/ALL/ONE) mapped
from Media3's int constants.
NowPlaying's BottomActionsRow grows two IconButtons: shuffle
toggles (accent when on, muted when off) and repeat cycles
off → all → one → off (Lucide.Repeat ↔ Lucide.Repeat1 swap;
accent when not-off).
PlayerViewModel exposes the two new methods as thin pass-throughs
matching the existing transport pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observes PlayerController.uiState and runs the (current track,
playing) state machine. Fires play_started on track-begin, then
on close emits play_ended (within 3s of duration) or play_skipped
through the live EventsApi. Failures and offline-start plays fall
through to the MutationQueue PLAY_OFFLINE kind for durable replay.
App-background (ProcessLifecycleOwner onStop) closes the current
play via the offline path so a process kill mid-listen still
records a play.
Wires into MinstrelApplication via @Inject so the singleton
constructs at app start (same pattern as MutationReplayer /
SyncController / ResumeController). client_id is a stable
device-install UUID resolved through AuthStore (added in 415200d).
Adds androidx.lifecycle:lifecycle-process dependency for the
ProcessLifecycleOwner background-event hook.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the offline mutation queue with the play_offline kind so the
upcoming PlayEventsReporter can durably capture plays that complete
without a successful live play_started, or whose live ended/skipped
close failed. Replay re-fires POST /api/events with type=play_offline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads MINSTREL_SOURCE_KEY from the current MediaItem's extras and
projects it as PlayerUiState.currentSource. PlayEventsReporter
needs this to tag plays with their originating system playlist
('for_you'/'discover'/'radio:<id>') so the server advances the
right rotation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retrofit interface + the four request body variants
(play_started / play_ended / play_skipped / play_offline)
matching flutter_client/lib/api/endpoints/events.dart.
play_started's response carries play_event_id (nullable).
Not wired into any caller yet — PlayEventsReporter lands later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a stable client_id column to auth_session for the upcoming
PlayEventsReporter. Lazily generated on first read by the reporter;
deliberately survives sign-out since it's a device install identity,
not a session value. DB version bump 3→4 (destructive migration per
the pre-release policy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NowPlaying gains the 7-item TrackActions menu in its bottom action
row alongside the View Queue button. hideQueueActions=true suppresses
Play next / Add to queue since the menu's track IS the playing one.
Go to album / artist pops NowPlaying first (it's a full-screen
overlay) before navigating, mirroring Flutter's shell-route hook.
Adds a thin Scaffold around the screen body so the TrackActions
transient messages have a SnackbarHost to surface in.
MiniPlayer kebab still deferred — needs shell-level snackbar
plumbing that lives outside this slice's scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spreads TrackActionsButton across the second wave of surfaces.
PlaylistDetail track rows, LikedTab tracks, HistoryTab rows, and
Search results now expose the full 7-item menu. Each screen-level
Scaffold gains a SnackbarHost that collects TrackActionsViewModel
transient messages.
Search tracks section upgraded from text-only TextRow to TrackRow
with cover thumb + secondary line (artist or album) + kebab.
MiniPlayer + NowPlaying kebabs follow in a separate commit so the
shell-level snackbar plumbing can land independently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composes the prior commits (player APIs, mutation kinds, repository
methods, sub-sheets) into the 7-item TrackActions sheet and its
kebab trigger. TrackActionsViewModel owns state observations + action
callbacks + transient snackbar messages. AlbumDetail track rows get
the kebab next to LikeButton; the screen-level Scaffold gains a
SnackbarHost that surfaces queue/playlist/error messages.
Hidden-state observation holds its own Set<String> snapshot since
QuarantineRepository doesn't expose a Flow surface yet; refreshes
on init and after every flag/unflag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the hide-track capability. Repository wraps QuarantineApi.flag
with the QUARANTINE_FLAG mutation-queue fallback mirroring the
existing unflag pattern. Sheet collects reason (FilterChip row) +
optional notes (OutlinedTextField); reason vocabulary matches the
server wire values (bad_rip / wrong_file / wrong_tags / duplicate /
other) exactly.
Not yet reachable from a user surface — TrackActionsSheet wires it
in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the playlist-append capability end-to-end. Repository does
optimistic Room write at MAX(position)+1 + REST + MutationQueue
fallback (PLAYLIST_APPEND kind from the previous commit). Sheet
lists user-owned playlists for the menu's "Add to playlist…" item;
not yet reachable from a user surface — TrackActionsSheet wires it
in a later commit.
Also adds maxPosition + insertOrIgnore to CachedPlaylistTrackDao
to support the optimistic write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the offline mutation queue with the two new write kinds the
TrackActions menu produces — appending a track to a playlist, and
flagging a track for quarantine. Replayer re-fires with idempotent
server semantics; payload data classes mirror Flutter's wire shapes.
Also adds PlaylistsApi.appendTracks since the replayer needs the
Retrofit surface in the same change set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the three menu-driven player actions that back the TrackActions
overflow sheet. playNext/enqueue insert without disturbing playback
state; startRadio replaces the queue from GET /api/radio?seed_track=.
RadioController owns the API call so PlayerController stays Retrofit-free.
Endpoint is GET /api/radio?seed_track= (verified against
flutter_client/lib/api/endpoints/radio.dart), not the POST-with-path-
param shape originally drafted in the spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track rows in the Liked tab had .clickable(enabled = false) with a
"Phase 9" deferral comment, silently breaking the most common
interaction on the list. Mirror the HistoryTab pattern: inject
PlayerController, expose playTracks(list, index), and route taps
through to PlayerController.setQueue with source = "liked". Row
clickability is gated on streamUrl so rows that can't be played
stay inert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The isAdmin parameter on MainAppBarActions defaulted to false and no
call site passed it, so the Admin overflow item never appeared for
actual admins — leaving the admin section unreachable from normal UI.
Move the lookup into a tiny AppBarActionsViewModel that reads
AuthController.currentUser, and drop the parameter from the public
signature so future call sites can't recreate the dead-param hazard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MiniPlayer body hit 66 lines after the AsyncImage branch added in
f3ee182. Pulled the cover Box into its own MiniCover composable —
takes coverUrl + contentDescription, identical surfaceVariant
background + Lucide.Music fallback as before. MiniPlayer body
drops back to ~45 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more "shows the placeholder icon when a cover exists" spots
fixed. Same patterns as Phase 124 (AlbumCard) and earlier track-row
covers — uses the existing displayCoverUrl / TrackRef.coverUrl
properties that route through BaseUrlInterceptor + Coil.
Modified:
- library/ui/AlbumDetailScreen.kt — AlbumCover() branches on
`album.id.isEmpty()` instead of `coverUrl.isEmpty()`, paints
via album.displayCoverUrl. Same cached-only-album story as
AlbumCard.
- player/ui/MiniPlayer.kt — drops the "placeholder until 5.x"
comment, renders track.coverUrl via AsyncImage with the
Lucide.Music fallback. Cover box gets a surfaceVariant
background so failed loads degrade to a tinted square. Picks
up cover for the now-playing track in the persistent mini bar.
- player/ui/NowPlayingScreen.kt — renames CoverPlaceholder() →
NowPlayingCover(coverUrl, contentDescription). The full-screen
player's large 320dp cover now shows actual art instead of a
96dp music glyph. Same surfaceVariant + fallback pattern.
Search track results stay the lone remaining cover-less surface;
splitting Search's generic TextRow for per-track covers is the
biggest remaining polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cached_albums → AlbumRef mapper drops coverUrl (only the
coverPath column is stored; the regular API's cover_url isn't
mirrored). Result: AlbumCards rendered from cache — Library Albums
tab, ArtistDetail album grid, Home Recently-added/Rediscover rows
on cold start — showed only the Lucide.Disc3 placeholder.
Same placeholder-URL trick as TrackRef.coverUrl:
- models/AlbumRef.kt — adds `displayCoverUrl` computed property
that returns the server-given coverUrl when populated, falls
back to `http://placeholder.invalid/api/albums/{id}/cover`
otherwise. BaseUrlInterceptor rewrites the host; Coil's shared
OkHttp picks up the auth cookie. The original `coverUrl` field
is preserved so callers that need to distinguish
"server-provided" from "derived" can.
- library/widgets/AlbumCard.kt — switches the branch from
`album.coverUrl.isEmpty()` to `album.id.isEmpty()`. The cover
Box gets a surfaceVariant background so failed image loads
(e.g. album server-side without art) degrade to a tinted
square rather than transparent. A proper error-slot fallback
icon is a future refinement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Factors the cover-thumbnail composable out of LikedTab into a shared
widget so PlaylistDetail tracks and HistoryTab rows can reuse it.
Same placeholder-URL trick — BaseUrlInterceptor rewrites the host
on every request and Coil shares the OkHttp client.
New:
- shared/widgets/TrackCoverThumb.kt — composable with size +
coverUrl + contentDescription params. Defaults to 48dp; passes
the size through to both the clip and the fallback icon so
callers can scale up/down without re-implementing.
Modified:
- models/Playlist.kt — adds `coverUrl` derived prop on
PlaylistTrackRef. Same `/api/albums/{id}/cover` pattern as
TrackRef.coverUrl; empty when albumId is null (the
track-removed-from-library case).
- likes/ui/LikedTab.kt — drops the local TrackCoverThumb copy,
uses the shared one. Removes 8 now-unused imports.
- playlists/ui/PlaylistDetailScreen.kt — adds cover thumb to track
rows. Drops the leading position number (1, 2, 3...) since row
order already conveys position and the cover fills that visual
slot.
- history/ui/HistoryTab.kt — adds cover thumb leading each
history row. Vertical padding tightened 10dp → 8dp to match the
other thumb-bearing rows.
Search track results stay deferred — its TextRow handles
artists/albums/tracks generically, splitting it for per-track
covers is a bigger change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Lucide.Music placeholder with real album art on the two
highest-visibility "track without cover" surfaces. Adds the Room v3
schema export that Phase 20's schema bump generated.
New:
- models/TrackRef.kt — adds a derived `coverUrl` extension that
points at `/api/albums/{albumId}/cover` via the
`http://placeholder.invalid` host. BaseUrlInterceptor rewrites
it to the live server URL on every request; Coil shares the
same OkHttp client as Retrofit, so the rewrite + auth-cookie
flow applies identically. Empty `albumId` yields an empty
string; callers branch to show the placeholder icon instead.
- app/schemas/.../AppDatabase/3.json — Room schema artifact from
the Phase 20 v2→v3 bump (themeMode column on auth_session).
Modified:
- home/ui/HomeScreen.kt — CompactTrackTile (Home Most-Played
section) renders AsyncImage when track.coverUrl is non-empty,
falls back to the existing Lucide.Music icon when blank.
Background tinted with surfaceVariant so the placeholder reads
as an empty cover slot.
- likes/ui/LikedTab.kt — LikedTrackRow restructured from Column to
Row with a 48dp TrackCoverThumb leading the title/artist column.
Same AsyncImage-with-fallback pattern.
Album/Playlist/Search/History track rows defer for now — those are
dense and the 56dp cover would push row heights significantly.
Want to see the cover-on-tracks pattern on the simpler screens
first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shuffle button on AlbumDetail was wired to the same code path
as Play — both called `play(startTrackId = null)` which started the
queue in track order. PlaylistDetail already does inline `.shuffled()`
for shuffle; mirror that on AlbumDetail via a new `shuffle()` VM
method that pre-shuffles the track list before handing it to the
player.
A future refinement could use Media3's `setShuffleModeEnabled` for
play-then-shuffle without disturbing the original queue ordering,
but pre-shuffling matches the existing PlaylistDetail behavior and
keeps both screens consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed out they were being forced to clear "http://localhost:8080"
before typing their actual server URL. That default came from
AuthStore.DEFAULT_BASE_URL — an internal HTTP-client fallback for
when nothing's been configured, never something a user typed.
Now: pre-fill only when the stored URL is something the user
actually saved (anything other than the default). Otherwise leave
the field empty so the placeholder ("https://minstrel.example.com")
shows through and they can just start typing.
Edit case still works: if a user already saved e.g.
"http://192.168.1.10:8080" and re-opens ServerUrl after sign-out,
the field is pre-filled with that real value for them to edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit (45e2248) committed only the two new theme files; the
six modified files (MainActivity, AuthStore, AppDatabase, AuthSessionDao,
AuthSessionEntity, SettingsScreen) silently didn't get staged. This
commit lands the actual integration so the theme picker works end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-controllable theme override. Persists across cold restart;
default is SYSTEM (follows the device setting via isSystemInDarkTheme).
Schema bump: AppDatabase v2→v3 to add themeMode column on
auth_session. fallbackToDestructiveMigration is still in place so
the upgrade wipes local cache + cookie + user JSON on first launch
after update — destructive but acceptable pre-v1, since the sync
controller refills the cache from the server on next sign-in.
New:
- theme/ThemeMode.kt — SYSTEM / LIGHT / DARK enum with wire
(string) + toDarkOverride() (Boolean?) conversions.
Stored as the wire string; null persisted = SYSTEM.
- theme/ThemePreferenceViewModel.kt — surfaces AuthStore.themeMode
as a typed StateFlow + setter. Lives in the theme package so
MainActivity and SettingsScreen can both share it.
Modified:
- cache/db/entities/AuthSessionEntity.kt — adds themeMode column.
Comment updated to call out that the auth_session table is the
de-facto app-prefs row at this point, not strictly auth-only.
- cache/db/AppDatabase.kt — version 2 → 3.
- cache/db/dao/AuthSessionDao.kt — adds setThemeMode partial-update.
- auth/AuthStore.kt — adds themeMode StateFlow + setter +
persistThemeMode following the existing per-field pattern.
- MainActivity.kt — moves MinstrelTheme wrap from setContent into
the App() composable so it can read the theme preference.
BootSplash also wrapped in Surface(background) so the boot
flash uses the right background color.
- settings/ui/SettingsScreen.kt — Appearance ElevatedCard between
Account and About with a SingleChoiceSegmentedButtonRow of the
three options. Picks fire ThemePreferenceViewModel.setThemeMode
and the whole tree recomposes against the new MinstrelTheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit (0b72827) accidentally swept the local
android/.gradle, android/build, android/app/build, and
android/local.properties into the index via `git add android/`.
Root .gitignore only had entries for flutter_client/android/
paths, not the new native android/ tree.
Removes the cached files via `git rm --cached` and extends
.gitignore to cover the native Android Studio output dirs
(.gradle, .kotlin, .idea, build, app/build, local.properties,
*.iml). Source code is unaffected — only build-output and IDE
artifacts get untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions surfaced on the first real device run.
1. Contrast on ServerUrl + Login screens: both wrapped content in a
bare Box(fillMaxSize), no Surface. The obsidian background never
painted (rendered against the system root view's default),
LocalContentColor cascade fell through to Material's default
contentColor — the screens rendered as near-invisible dark text
on dark grey. Wrap both in Surface(color = background, contentColor
= onBackground) so the bg paints AND the M3 contentColor pipeline
flows correctly through OutlinedTextField labels / cursor /
placeholders + the Button content tint.
2. The bigger bug: NetworkModule.provideRetrofit read
authStore.baseUrl.value ONCE at Retrofit creation. AuthStore loads
from Room async, so at injection time the value was still the
localhost:8080 placeholder. Result: even after the user typed
their real server URL on the ServerUrl screen, every API call
kept hitting localhost:8080 ("Failed to connect to
localhost/127.0.0.1:8080" on the login attempt). The pre-fix
NetworkModule comment even acknowledged it — *"Server-URL
changes require an app relaunch"*.
Fix: per-request rewrite. New BaseUrlInterceptor reads the live
AuthStore.baseUrl.value on every request and rewrites
scheme/host/port of the outgoing URL. Retrofit now keeps a
placeholder baseUrl ("http://placeholder.invalid/") solely to
satisfy its parser; the actual target host is dynamic. Order in
OkHttp chain: BaseUrl first → Auth → logging, so the cookie
interceptor sees the final URL.
New:
- api/BaseUrlInterceptor.kt — per-request scheme/host/port rewrite
from AuthStore.baseUrl. Falls through to the original request
when the stored URL is unparseable.
Modified:
- api/NetworkModule.kt — adds BaseUrlInterceptor to the OkHttp
chain. Drops the AuthStore dependency from provideRetrofit;
swaps baseUrl for the placeholder.
- auth/ui/ServerUrlScreen.kt — Box → Surface wrap.
- auth/ui/LoginScreen.kt — Box → Surface wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as LikesRepository.toggleLike and friends. The catch
returns false → the row stays in the queue; that's the whole point
of the replayer. Added a comment explaining future diagnostic
logging plans.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last MVP infrastructure gap. Queued like-toggles,
Lidarr requests, and quarantine-unflags now actually reach the
server instead of accumulating in cached_mutations forever.
New:
- cache/mutations/MutationReplayer.kt — @Singleton. On
construction subscribes to AuthStore.sessionCookie and runs a
drain pass on every signed-in transition (cold start with
persisted cookie OR fresh sign-in). Reads pending rows in
FIFO order via CachedMutationDao.getAll, dispatches each by
kind to the raw Retrofit API:
LIKE_TOGGLE → LikesApi.like / unlike
REQUEST_CREATE → DiscoverApi.createRequest
QUARANTINE_UNFLAG → QuarantineApi.unflag
Crucially, uses the raw API interfaces — going through the
Repository wrappers would re-enqueue on failure, creating an
infinite-loop. Successful rows are deleted; failed rows stay in
place with attempts + lastAttemptAt updated. Unknown kinds are
dropped (claim success) so a stale schema entry can't wedge the
queue. Single in-flight via Mutex so back-to-back cookie events
coalesce.
Modified:
- MinstrelApplication.kt — adds @Inject lateinit var
mutationReplayer (same construct-the-singleton trick used for
ResumeController and SyncController). Without the @Inject Hilt
never instantiates the replayer and its init {} cookie observer
never subscribes.
Closes Phase 18 + every known MVP infrastructure gap. Remaining
known follow-ups (NOT MVP blockers):
- WorkManager-driven connectivity-listener replayer so queued
writes drain even with the app backgrounded. Current trigger
set (app open + sign-in) covers the common path.
- Exponential backoff + max-attempts cap so permanently-failing
rows eventually fail visibly rather than silently retrying
forever. Retry-forever is cheap given small queue sizes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trailing-underscore name tripped detekt's `(_)?[a-z][A-Za-z0-9]*`
pattern. `internal` matches the naming convention every other VM
in the codebase uses for the private MutableStateFlow shadowed by
a public StateFlow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last known MVP gap: after a fresh app launch with a
persisted session cookie, the Settings screen now shows the actual
username instead of going blank until the next sign-in.
Schema bump: AppDatabase version 1 → 2 to add userJson column on
auth_session. fallbackToDestructiveMigration already in
DatabaseModule handles the upgrade — users lose the cookie + cached
content on first launch after update, the sync controller refills,
and the next sign-in repopulates the user row. Acceptable pre-v1.
New / Modified:
- cache/db/entities/AuthSessionEntity.kt — adds `userJson: String?`.
- cache/db/AppDatabase.kt — version 1 → 2.
- cache/db/dao/AuthSessionDao.kt — adds setUserJson partial-update.
- auth/AuthStore.kt — `userJson: StateFlow<String?>` + setter +
persistUserJson. Refactored persist* methods to share a single
currentEntity() builder so adding the third field didn't triple
the boilerplate.
- models/UserRef.kt — @Serializable so AuthController can encode
it for storage.
- auth/AuthController.kt — injects ApplicationScope + Json. On init,
collects authStore.userJson and reflects decoded UserRef into
currentUser. signIn() now writes the user JSON through to
AuthStore; signOut() clears it. Decode failures collapse to null
rather than crash (worst case: blank username until next
sign-in).
- settings/ui/SettingsViewModel.kt — combine() over
authStore.baseUrl + authController.currentUser + a local
transient state flow, so the username updates the moment the
rehydrated UserRef arrives rather than being a one-shot snapshot
at VM construction time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the per-track-likes gap on PlaylistDetail (Album + Artist
details already had it via Phase 14). Same VM-owned-state pattern:
LikesRepository observeLikedTracks → mutableSet<String> Flow,
toggleLikeTrack via the optimistic-write + MutationQueue path.
Modified:
- playlists/ui/PlaylistDetailScreen.kt — VM gets LikesRepository
injection + `likedTrackIds: StateFlow<Set<String>>` +
`toggleLikeTrack(trackId)`. PlaylistDetailBody threads the set
+ onToggleTrackLike down to each row. TrackRow renders LikeButton
only when the upstream track is still available (greyed-out
rows for removed tracks omit the heart entirely — can't like
something that no longer exists).
Row vertical padding tightened 10dp → 8dp to match the album
track-row sizing now that the heart icon is present.
Cross-restart user persistence is the next commit within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last ComingSoon stub in the nav graph. Queue route now
renders the live PlayerController queue with the active row
highlighted; tap a row to jump to that position via the new
seekToIndex transport method.
New:
- player/ui/QueueScreen.kt — Scaffold + back-button AppBar; reads
the same PlayerViewModel that powers MiniPlayer / NowPlaying so
queue state stays in sync across all three. Active row gets a
12% primary-tinted background + Volume2 leading icon so the user
sees where they are. Empty queue shows "Queue is empty" hint.
Modified:
- player/PlayerController.kt — adds `seekToIndex(index: Int)`:
bounds-checked jump to a queue position via
MediaController.seekTo(mediaItemIndex, 0L) + auto-play.
- player/ui/PlayerViewModel.kt — exposes seekToIndex pass-through.
- player/ui/NowPlayingScreen.kt — takes navController now; adds a
ListMusic icon button below the transport row that navigates to
Queue.
- nav/MinstrelNavGraph.kt — Queue route renders QueueScreen;
NowPlaying composable threads navController. Drops the
ComingSoon helper + its EmptyState import — every route now has
a real screen, no stub fallback needed.
Closes Phase 16. Every named v2026.05.21.0 route has a working
native screen now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DatabaseModule's whole job is to host one @Provides per DAO; the
12/11 trip is structural, not a smell. Adding a second module file
to split DAO providers arbitrarily by family would be busier work,
not cleaner. Suppress with a comment that explains why.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SyncController constructor injection failed — SyncMetadataDao
existed on AppDatabase but had no per-DAO bridge in DatabaseModule
(no consumer until SyncController landed). Same fix as the earlier
CachedHomeIndexDao + CachedLikeDao + CachedMutationDao pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Library-tabs-start-empty gap. Mirrors
`flutter_client/lib/cache/sync_controller.dart`, scoped to artist /
album / track only (likes refresh via /api/likes/ids; playlists pull
on screen visit). The full multi-entity sync is overkill for v1
native.
New:
- models/wire/SyncResponseWire.kt — SyncArtistWire +
SyncAlbumWire + SyncTrackWire (raw DB-row shape returned by
/api/library/sync; distinct from the regular API's display
shapes which include derived album_count / cover_url etc.).
Plus SyncUpsertsWire / SyncDeletesWire / SyncResponseWire
envelopes.
- api/endpoints/SyncApi.kt — Retrofit GET /api/library/sync.
Returns Response<...> so the controller can branch on 200 /
204 (no changes since cursor) / 410 (cursor too old, wipe +
retry) without HttpException catches.
- cache/sync/SyncController.kt — @Singleton. Self-starting: on
construction subscribes to AuthStore.sessionCookie and fires
syncSafe() whenever it transitions to a non-null value (fresh
sign-in OR cold start with persisted cookie). Applies upserts
via the existing upsertAll DAO methods, applies deletes via
deleteByIds. Cursor + lastSyncAt persisted in sync_metadata so
the next sync resumes from the new watermark.
On 410 (server compaction window exceeded), resets the cursor
to 0 and recurses; the next response carries the full entity
set, which upsertAll overwrites with. Stale rows for entities
the server no longer knows about linger until a later 410 or
app-data-clear — acceptable for v1.
Modified:
- MinstrelApplication.kt — adds @Inject lateinit var
syncController (same construct-the-singleton trick used for
ResumeController). Without the @Inject the Hilt graph would
never instantiate the controller and its init {} cookie
observer wouldn't subscribe.
Likes / playlists / playlist_tracks deltas from the same endpoint
are deferred. Their dedicated refresh paths already populate the
local cache; folding them into the sync flow is an opportunistic
optimization, not an MVP gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the silent gap where there was no UI to like anything.
Now the Liked tab can actually grow from in-app actions, not just
from the /api/likes/ids sync seed.
New:
- shared/widgets/LikeButton.kt — heart toggle composable.
Caller-owned state: (liked: Boolean, onToggle: () -> Unit).
Tinted with M3 primary slot on liked, onSurfaceVariant on
unliked — tracks the light/dark theme without per-mode branches.
Modified:
- library/ui/AlbumDetailViewModel.kt — injects LikesRepository;
exposes `albumLiked: StateFlow<Boolean>` (observeIsLiked for the
album) + `likedTrackIds: StateFlow<Set<String>>` (observeLikedTracks
mapped to a Set for O(1) row-level lookup). `toggleLikeAlbum()` +
`toggleLikeTrack(id)` route through the repo's optimistic-write +
MutationQueue path.
- library/ui/AlbumDetailScreen.kt — LikeButton on the header next
to the cover/title block, LikeButton on every track row after
the duration. Track row vertical padding tightened from 12dp →
8dp to give the heart breathing room.
- library/ui/ArtistDetailViewModel.kt — injects LikesRepository;
exposes `artistLiked: StateFlow<Boolean>` + `toggleLikeArtist()`.
- library/ui/ArtistDetailScreen.kt — LikeButton on the header
between the name column and Play button.
PlaylistDetail per-track likes follows in a small follow-up — same
pattern, just hadn't been integrated yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Search-route ComingSoon stub with the real
three-facet search. Mirrors `flutter_client/lib/search/search_screen.dart`.
New:
- models/wire/SearchWire.kt — three concrete PagedXxxWire types
(Artists / Albums / Tracks) wrapping the server's Page[T] envelope,
plus SearchResponseWire. Going non-generic on the paged wrapper is
fine — three call sites and the explicit types read clearer than
a generic with manual type-arg gymnastics at decode time.
- models/SearchResponseRef.kt — domain envelope with `isEmpty` for
the screen's "no matches" branch.
- api/endpoints/SearchApi.kt — Retrofit GET /api/search with q +
limit + offset.
- search/data/SearchRepository.kt — trivial wire→domain mapper;
debouncing intentionally lives in the VM.
- search/ui/SearchViewModel.kt — 250ms debounce on the query Flow
via debounce() + distinctUntilChanged(). Empty query collapses
to Idle without firing a request. `playTrack(track)` queues a
single track via PlayerController for the same single-row-play
pattern as HistoryTab.
- search/ui/SearchScreen.kt — auto-focus the field on entry (the
user typed Search to start typing), inline X button to clear.
Results render as three sections (Artists / Albums / Tracks),
each section omitted when its list is empty. Artist taps →
ArtistDetail, album taps → AlbumDetail, track taps → play.
Modified:
- nav/MinstrelNavGraph.kt — Search route renders the real screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ArtistDetail-route ComingSoon stub with the real artist
view. Mirrors `flutter_client/lib/library/artist_detail_screen.dart`.
New:
- models/ArtistDetailRef.kt — ArtistRef + albums pair.
- library/ui/ArtistDetailViewModel.kt — VM + UiState. `playArtist()`
fires GET /api/artists/{id}/tracks and dumps the whole list into
the player queue (`source = "artist:$id"`). Errors are silent for
now — the play button has no inline error affordance.
- library/ui/ArtistDetailScreen.kt — Scaffold + back-button AppBar.
Header is a full-width LazyVerticalGrid spanning row with
circular avatar + name + album count + Play button; below it,
the albums tile out as an adaptive 176dp grid using the existing
AlbumCard.
Modified:
- library/data/LibraryRepository.kt — refreshArtistDetail now
returns ArtistDetailRef (mirroring AlbumDetail). Adds
fetchArtistTracks() for the Play button.
- nav/MinstrelNavGraph.kt — ArtistDetail route renders the real
screen; drops the now-unused androidx.navigation.toRoute import
(all detail routes use SavedStateHandle.toRoute via the VM now).
Closes Phase 12. Like buttons on detail headers + per-track + shuffle
mode on the player are open follow-ups but don't block MVP nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the AlbumDetail-route ComingSoon stub with the real album
view. Mirrors `flutter_client/lib/library/album_detail_screen.dart`.
New:
- models/AlbumDetailRef.kt — AlbumRef + tracks pair returned by
refreshAlbumDetail.
- library/ui/AlbumDetailViewModel.kt — VM + UiState. Pulls album
detail on init via LibraryRepository.refreshAlbumDetail (now
returning AlbumDetailRef directly so we can render from the wire
response without waiting for Room emission). `play(startTrackId)`
builds the player queue with `source = "album:$id"` so the
server's rotation reporter can advance it.
- library/ui/AlbumDetailScreen.kt — Scaffold + TopAppBar with back
button; cover + title + artist + Play/Shuffle action row; LazyColumn
of TrackRow tiles (#, title, artist, duration; tap plays from
that position). Shuffle currently fires the same play path —
player-level shuffle is a follow-up once PlayerController.setQueue
grows a shuffle flag.
Modified:
- library/data/LibraryRepository.kt — refreshAlbumDetail now
returns AlbumDetailRef. Existing callers (the LibraryRepositoryTest)
ignore the return value, no breakage.
- nav/MinstrelNavGraph.kt — AlbumDetail route renders the real
screen; id flows via SavedStateHandle.toRoute() inside the VM,
so the composable block is a one-liner.
ArtistDetail follows in the next commit within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the auth loop. Settings shows the signed-in username and
server URL, and the sign-out button drops the cookie + clears
currentUser + best-effort POSTs /api/auth/logout.
New:
- settings/ui/SettingsViewModel.kt — VM + SettingsState (server URL,
username, signing-out spinner, signed-out latch).
- settings/ui/SettingsScreen.kt — three cards: Account (signed-in
username + server URL), About (Minstrel + version from
BuildConfig.VERSION_NAME + VERSION_CODE), Sign out (error-tinted
button that spins while in-flight). On signed-out, navigates to
ServerUrl with `popUpTo(0) { inclusive = true }` so the back
stack is empty.
Modified:
- nav/MinstrelNavGraph.kt — Settings route renders the real screen.
Closes Phase 11 modulo cross-restart user-identity persistence —
the cookie survives but `currentUser` is in-memory only, so a fresh
launch with an existing cookie leaves the username blank until the
next sign-in. That's the only known gap; queued as a small
AuthSessionEntity schema add when it surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold launch now lands on the right screen based on persisted auth
state instead of always starting at Home.
New:
- auth/ui/AuthGateViewModel.kt — one-shot StateFlow that resolves
the initial NavHost destination from AuthSessionDao directly
(not AuthStore.sessionCookie, which is null until Room's first
async emission). Three outcomes:
no row at all → ServerUrl
row with default URL and no cookie → ServerUrl
row with cookie absent / empty → Login
row with cookie present → Home
- auth/ui/ServerUrlScreen.kt — VM + Screen in one file (still under
the function-count cap). Validates `http(s)://` prefix +
non-empty, trims trailing slash before persisting. On success
navigates to Login and pops ServerUrl off the back stack.
Modified:
- MainActivity.kt — wraps the NavGraph in an App() composable that
reads the AuthGateViewModel. Renders a centered spinner
(BootSplash) while the gate resolves; once resolved, hands the
decision to MinstrelNavGraph as `startDestination`.
- nav/MinstrelNavGraph.kt — startDestination is now a parameter
(was hardcoded to Home). ServerUrl route renders the real screen
instead of ComingSoon.
Settings (with sign-out) is Commit C; cross-restart user identity
persistence + auth-error 401 redirect handling are later refinements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoginScreen body was 76 lines vs the 60 cap. Pulled the centered
Column into LoginForm() and the spin-aware Button into SubmitButton().
LoginScreen is back to ~20 lines (LaunchedEffect + Box around LoginForm).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation of the auth flow. POST /api/auth/login lands the user with
their session cookie captured by the existing AuthCookieInterceptor;
LoginScreen drives it, AuthController owns the in-memory currentUser.
New:
- models/wire/AuthWire.kt — LoginRequestBody + LoginResponseWire +
UserWire. The response `token` is duplicated for header-auth
clients; we use the Set-Cookie capture path instead.
- models/UserRef.kt — caller-facing identity (no password hash, no
api token, no Subsonic password). Matches the server's UserView
envelope.
- api/endpoints/AuthApi.kt — Retrofit POST /api/auth/login +
/api/auth/logout.
- auth/AuthController.kt — @Singleton facade. `currentUser`
StateFlow + `isSignedIn` (read straight off AuthStore.sessionCookie).
`signIn(username, password)` posts the login, surfaces the typed
UserRef. `signOut()` clears the cookie locally and best-effort
POSTs /logout (swallowed network failure — local state already
cleared, the server session times out on its own).
Cross-restart caveat: cookie persists in AuthStore but currentUser
is in-memory only — UI stays signed in across restarts but can't
render the username until a follow-up sign-in. Persisting user
JSON on AuthSessionEntity is a follow-up if it matters.
- auth/ui/LoginViewModel.kt — VM + LoginFormState (username +
password fields + submitting + error + signedIn flag). `submit()`
is a no-op while in-flight or with empty fields.
- auth/ui/LoginScreen.kt — centered form with two OutlinedTextFields
+ a Sign-in button that spins while in-flight. Error message
surfaces under the password field. On `signedIn = true` flips,
navigates to Home and pops Login off the back stack.
Modified:
- nav/MinstrelNavGraph.kt — Login route renders the real screen;
outsideShell extension now takes navController so it can be
threaded down (LoginScreen needs it for the post-sign-in nav).
ServerUrl screen + Settings + auth-gated redirect logic come in
Commits B / C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AdminUsersScreen body was 65 lines vs the 60 cap because both
dialogs were rendered inline. Pulled them into a single
PendingDialogs helper that takes the two nullable selections + the
clear/confirm callbacks. AdminUsersScreen is back to ~45 lines and
PendingDialogs is ~30.
File function count is still 8 (Screen + UserList + UserRow +
ToggleRow + PendingDialogs + ResetPasswordDialog + DeleteConfirmDialog
+ LoadingCentered), under the 11 cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the final two admin slices, closing Phase 10. Mirrors
`flutter_client/lib/admin/admin_users_screen.dart`,
`admin_landing_screen.dart`, and the `AdminUsersController` +
`adminCountsProvider` pieces of `admin_providers.dart`.
New:
- models/wire/AdminUserWire.kt — AdminUserWire + AdminUsersListWire
(server wraps the list in `{"users": [...]}`).
- models/AdminUserRef.kt — domain.
- api/endpoints/AdminUsersApi.kt — Retrofit list + setAdmin +
setAutoApprove + resetPassword + delete. PUT-auto-approve body
field is `auto_approve` (NOT `auto_approve_requests`) — matches
`internal/api/admin_users.go adminAutoApproveReq` and Flutter's
same-name workaround.
- admin/data/AdminUsersRepository.kt — list + four mutation methods.
- admin/ui/AdminUsersViewModel.kt — VM + UiState. Optimistic patch()
helper for the toggles (last-admin-guard rollback via refresh).
Delete is optimistic-remove with same rollback path. resetPassword
is fire-and-forget — server has no rollback for this anyway.
- admin/ui/AdminUsersScreen.kt — list of rows, each with two toggle
rows (Admin / Auto-approve) and Reset-password + Delete affordances.
Both are dialog-gated (typed-new-password / typed-confirm) so a
tap can't blow up an account by accident.
- admin/ui/AdminLandingScreen.kt — VM + counts + Screen. Fans out
to all three admin repositories in parallel via async/await inside
a coroutineScope; surfaces counts in three ElevatedCard tiles
(Requests / Quarantine / Users) that navigate to the slice screens.
Modified:
- nav/MinstrelNavGraph.kt — Admin + AdminUsers routes now render
their real screens; AdminLanding ties the slice together.
isAdmin gating on the kebab still pending Phase 11 AuthController.
Until then the admin entries are reachable from any account, fine
on a single-user dev install but gates before any release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The icons-lucide-android variant ships icons as XML Vector Drawables
accessed via painterResource(R.drawable.lucide_x). My Compose code
uses the ImageVector API (`Icon(Lucide.Disc3, ...)`) which is provided
by the icons-lucide-cmp variant. "CMP" (Compose Multiplatform) works
fine in pure-Android Compose — the variant name describes the icon
representation, not a multiplatform-required runtime.
Switched the catalog entry; consuming code (AlbumCard, ArtistCard,
EmptyState, ErrorRetry) is unchanged.
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>