Both were rendering controls that silently did nothing — there's no
MetadataPrefetcher on Android, and no pin-on-like flow. A toggle
that flips persisted state but has no functional effect makes the
app look broken.
Removed the two rows from StorageCard; CacheSettings persistence
keeps the fields so the controls come back unchanged when the
underlying systems land.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs from the v2 parity audit:
* Search: tapping a track result built a single-track queue
(auto-advance died on track end). Now builds a queue from the full
visible Loaded tracks list starting at the tapped row, mirroring
Flutter.
* ArtistDetail: Play button played albums in tracklist order. Flutter
shuffles. .shuffled() on the fetched list.
* NowPlaying: when the session tore down (queue finished, queue
cleared from elsewhere) the screen stranded the user on an
EmptyState with no escape. Replaced with a 500ms-debounced
popBackStack so the brief null during MediaController IPC bind
doesn't bounce the user, but a genuine session-end pops them back
to wherever they came from.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LibraryViewModel gained a SyncController constructor parameter for
pull-to-refresh (cf07a2a). Tests use a relaxed MockK SyncController
since none of the four cases exercise refresh().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PullToRefreshScaffold addition in 4ca10e2 pushed the function 3
lines over. Extracted the inner Column body into a private
DiscoverBody helper composable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final wave of audit #8. Library tabs (Artists/Albums via LibraryVM
refreshing through SyncController; Liked via LikesRepository;
History/Hidden via their own VMs) and all four admin screens
(Landing/Requests/Quarantine/Users) now support swipe-down refresh.
Per-VM change is uniform: refresh() returns Job so the
PullToRefreshScaffold wrapper can await it before hiding the
indicator.
Audit #8 user-visible parity now complete across all screens that
benefit. Search/Queue/NowPlaying/Settings intentionally excluded —
Search is query-driven, Queue is local state, NowPlaying/Settings
are forms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second wave of audit #8. PlaylistsListScreen, PlaylistDetailScreen,
DiscoverScreen, RequestsScreen all wrap their body in
PullToRefreshScaffold. VM refresh methods updated to return Job for
the wrapper's await.
PlaylistsListViewModel gains a public refresh() (was init-only
fire-and-forget). DiscoverScreen's swipe re-fetches suggestions
(the most-useful refresh target on that screen — Lidarr search
results refresh on next query).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit #8 first wave. New shared widget wraps Material3's
PullToRefreshBox with isRefreshing state managed internally; consumers
pass a suspend onRefresh that the wrapper awaits before hiding the
indicator (no heuristic delays).
ViewModel pattern: refresh() now returns Job so the screen can
`.join()` it from the wrapper. Trivial change — adding `: Job =`
between the function signature and the existing viewModelScope.launch
body. Existing fire-and-forget callers continue to work since they
discard the return value.
Wired into HomeScreen, AlbumDetailScreen, ArtistDetailScreen.
Library tabs + detail / list / admin screens follow in next commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deferred MiniPlayer follow-up from the TrackActions slice.
MiniPlayer gains a TrackActionsButton next to play/pause with
hideQueueActions=true (the playing track is the queue entry itself).
Shell architecture: ShellScaffold now takes navController + owns a
shell-scoped SnackbarHost backed by a TrackActionsViewModel
hiltViewModel() at the shell level. Snackbars triggered by the
MiniPlayer's kebab surface there; per-screen kebabs continue to
flow through each screen's own Scaffold SnackbarHost (independent
collectors so the two never compete).
All 14 ShellScaffold call sites in MinstrelNavGraph updated to pass
navController; mechanical sweep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compile errors on 6ef08ed: ExposedDropdownMenu is an extension on
ExposedDropdownMenuBoxScope and can't be referenced by FQN from
outside a Composable receiver. Rewrote both dropdowns using the
plain OutlinedButton + DropdownMenu pattern wrapped in a small
LabeledDropdown<T> helper, which works without the Scope dance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CacheSettings persistence in b438772 pushed AuthStore from 12 to 14
functions. Same shape of fix as PlayerController (484ad6c-era):
@Suppress at the class with rationale — function count scales with
the pref count, splitting would scatter shared dao/scope/json
plumbing for no gain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit #5 user-visible parity. Storage card in SettingsScreen exposes
the four CacheSettings prefs (liked/rolling cap dropdowns, prefetch
window dropdown, cache-liked switch), live cache usage display, and
two action buttons:
- Sync now → SyncController.syncSafe()
- Clear cache → SimpleCache.removeResource for every cached key
(safe mid-flight; releasing the cache would crash live playback).
Confirmation dialog before delete.
Cap settings persist via AuthStore.setCacheSettings (from the prior
commit). The card surfaces the "limits take effect on next app
launch" caveat — SimpleCache is constructed once per process.
Prefetch window + cache-liked-tracks toggle persist but have no
effect yet — the prefetcher + pin-on-like flows are separate audit
follow-ups.
Per-bucket usage (Flutter shows Liked vs Rolling sizes separately)
is collapsed to a single "Used" stat on Android v1 since SimpleCache
doesn't expose per-bucket totals without custom indexing — separate
follow-up if user wants the breakdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>