Release: web UX overhaul + Android native port + server polish #59
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Batch release covering M7 web sweep, M8 native-Android port, and this session's web UX tiers A/B/C. CI green on the tip (run 89 on
c466e6c3).This-session highlights
Tier A — biggest gaps
/now-playing, opened by tapping the PlayerBar cover/titleTier B — surface polish
Tier C — small wins
Earlier in this batch
REGISTRY_TOKENAfter merge: tag
v2026.06.01.0to publish:latest(push to main alone builds:mainonly).Original 8.x toolchain choked on the ci-android image's JDK 25 with an opaque "25.0.3" error in `ktlintCheck`; Gradle 8.10's JDK compat matrix caps at 23. Modern chain: - Gradle 9.1.0 (first to support JDK 25) - AGP 9.0.1 (requires Gradle 9.1+, requires Kotlin 2.2.10+) - Kotlin 2.2.21 / KSP 2.2.21-2.0.5 (latest 2.2.x line) - Compose BOM 2026.05.01 (current; pulls ui-text-google-fonts at the BOM-managed version, so the explicit pin was dropped) AGP 9.0 breaking changes that affected us: - `kotlin-android` plugin no longer needed — AGP 9 auto-enables via `android.builtInKotlin=true` default. Removed alias from both the root build.gradle.kts and :app/build.gradle.kts. - `applicationVariants` API removed; we don't use it. - Other defaults flipped (useAndroidx, uniquePackageNames, etc.) but we already set them explicitly or weren't relying on the old defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`kotlinOptions { jvmTarget = "17" }` inside the `android { }` block was removed in newer Kotlin tooling; replaced with the modern top-level `kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }` form. Required after the Kotlin 2.2 + AGP 9 bump; the old DSL was tolerated through AGP 8.7 + Kotlin 2.0 but not through AGP 9's built-in Kotlin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>AGP 9 enables built-in Kotlin by default (`android.builtInKotlin=true`), which we initially adopted by dropping the `kotlin-android` plugin alias. But KSP isn't compatible with built-in Kotlin yet — Gradle errors out with: > KSP is not compatible with Android Gradle Plugin's built-in Kotlin. > Please disable by adding android.builtInKotlin=false to gradle.properties > and apply kotlin("android") plugin Restored the explicit kotlin-android plugin (root + :app) and added `android.builtInKotlin=false` to gradle.properties. Revisit when KSP gains built-in-Kotlin support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The previous attempt opted out of AGP 9's built-in Kotlin (via android.builtInKotlin=false + explicit kotlin-android plugin) because the message from Gradle suggested it. But Kotlin 2.2.21's kotlin-android plugin can't cast AGP 9's new ApplicationExtension to the removed BaseExtension: class ApplicationExtensionImpl$AgpDecorated_Decorated cannot be cast to class com.android.build.gradle.BaseExtension That suggestion is for projects with an older Kotlin toolchain. The real fix: - Kotlin 2.3.21 (latest stable; first line where kotlin-android also supports AGP 9, but more importantly the built-in path works) - KSP 2.3.8 — KSP PR #2674 (merged Oct 2025) added AGP 9 built-in Kotlin support. KSP 1.x and pre-2.3 don't work with built-in Kotlin. - Re-drop the kotlin-android plugin from both build.gradle.kts files; AGP 9 enables built-in Kotlin by default and KSP 2.3 cooperates. - Remove android.builtInKotlin=false from gradle.properties. compose-compiler plugin tracks the Kotlin version via version.ref, so no separate bump there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>detekt 1.23.7 choked on JDK 25 with an opaque "25.0.3" error (same shape as the original Gradle 8.10 failure). Per the detekt 1.23.8 release notes (Feb 2025), it's the first 1.23.x version tested with JDK 25. Still built against Kotlin 2.0.21 — fine for our small Phase 1 sources, no exotic Kotlin 2.3 syntax in use yet. The `tasks.withType<Detekt> { jvmTarget = "17" }` pin from the previous commit stays as belt-and-braces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three breaking changes I missed when bumping to 2.0.0-alpha.3: 1. Gradle plugin id changed: io.gitlab.arturbosch.detekt -> dev.detekt 2. Task FQN changed: io.gitlab.arturbosch.detekt.Detekt -> dev.detekt.gradle.Detekt (same for DetektCreateBaselineTask) 3. jvmTarget is now a Property API (.set("17")) instead of var assignment Also dropped `autoCorrect` from the detekt {} block — it's not in the 2.0 options list per the official getting-started docs. Per the 2.0 release notes: "the workaround of disabling the new DSL and built-in Kotlin via gradle.properties for AGP 9.x projects is no longer required" — so our existing AGP 9 + built-in-Kotlin setup is expected to work cleanly with detekt 2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>build:section from detekt config 1d9204897cSeven findings from the first real detekt run: - LocalActionColors.kt / Tokens.kt: MatchingDeclarationName flagged that the file names don't match the single top-level declaration. Renamed to ActionColors.kt and FabledSwordTokens.kt (`git mv`). - Typography.kt: three Font(...) calls exceeded the default 120-char line length. Wrapped each named-arg list onto its own line. - MainActivity.kt + MinstrelTheme.kt: FunctionNaming flagged App() / MinstrelTheme() for not starting lowercase — these are @Composable functions and PascalCase is the Compose convention. Added a config override to the detekt YAML: naming: FunctionNaming: ignoreAnnotated: ['Composable'] Matches every mainstream Compose codebase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>M8 phase 3.1. Wires the single shared OkHttp + Retrofit instance the whole app uses (per-endpoint Retrofit interfaces land in feature modules). Audio HTTP via ExoPlayer's OkHttpDataSource.Factory will reuse this same client — single auth/connection-pool surface. - AuthStore: in-memory MutableStateFlow placeholder. Task 4.2 promotes it to a Room-backed single-row table for process-death persistence; public API stays identical. - AuthCookieInterceptor: attaches Cookie on outbound, captures Set-Cookie on successful responses (login flow), clears the store on 401 (logout signal). - ServerBaseUrl: value class to type-safely DI the base URL. - NetworkModule: Hilt-provided OkHttp + Retrofit + HttpLogging. First task with unit tests — AuthCookieInterceptorTest uses MockWebServer to verify all three interceptor branches plus a no-Set-Cookie-no-overwrite case. Will tell us if the JUnit 5 + okhttp-mockwebserver test stack is plumbed correctly through Gradle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>AuthCookieInterceptorTest imports `kotlin.test.assertEquals` / `kotlin.test.assertNull` which weren't resolving without the explicit kotlin-test dep. `kotlin("test")` is sourced from the applied Kotlin plugin (built-in via AGP 9), so no version pin needed. Hilt + KSP code generation worked correctly in the prior run — hiltAggregateDepsDebug succeeded; the failure was purely test-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Fixes uncovered by the 2026-05-22 build-config audit + the Gradle 9 + JUnit Platform launcher requirement that just surfaced in CI: - testRuntimeOnly junit-platform-launcher: Gradle 9 no longer auto- injects it; tests fail with "Failed to load JUnit Platform" without an explicit dep. - compileSdk + targetSdk 35 -> 36: AGP 9 defaults to 36 and warns on lower values; CI was also auto-downloading build-tools 36 at runtime (now pre-installed in ci-android:36). - container.image bumped to ci-android:36 to match. - configuration-cache.problems=warn in gradle.properties: detekt 2.0- alpha + ktlint Gradle plugin have CC compat holes; warn rather than fail. - androidTest dep parity: kotlin("test") + kotlinx-coroutines-test added (was on testImplementation only). - CI: actions/cache@v4 for ~/.gradle/{caches,wrapper} + ~/.kotlin, keyed on gradle-wrapper.properties + libs.versions.toml + the *.gradle.kts files. Saves ~3 min per run after warm-up. Deferred (with trigger conditions, will land when needed): Hilt testing artifacts + HiltTestRunner (first @HiltAndroidTest), Room Gradle plugin + schemaDirectory (Phase 4.2), Coil 3 ImageLoader factory sharing OkHttp (Phase 5.4), MainDispatcherRule test utility (Phase 5.3), JUnit-5/4 split for instrumented (Phase 5+), NetworkSecurityConfig (pre-cutover Phase 14). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>M8 phase 4.1. Pre-flight research (per the feedback_m8_preflight_research rule) found two needed adjustments to the original plan: 1. Room 2.6.1 -> 2.8.4. Room 2.6.1 (Oct 2023) predates Kotlin 2.0 mainstream; Room 2.7+ explicitly supports Kotlin 2.0+ and KSP2. Room 2.8.4 is current stable; minSdk 23 (we're at 26) and AGP 8.4+ (we're on 9), both compatible. 2. Use the androidx.room Gradle plugin's `room { schemaDirectory(...) }` block instead of the legacy `ksp { arg("room.schemaLocation", ...) }` pattern. Cleaner schema-export plumbing in Room 2.7+. Audit-deferred item; trigger condition (first Room entity) met here. Files: - cache/db/AppDatabase.kt — @Database stub, schemaVersion 1 - cache/db/TypeConverters.kt — Instant <-> Long, CacheSource enum - cache/db/DatabaseModule.kt — Hilt-provided AppDatabase singleton - cache/db/entities/SyncMetadataEntity.kt — pulled forward from Task 4.2 slice 7 to satisfy Room's "needs >=1 entity" compile check; its consumer (SyncController) lands in Phase 12.4 - cache/db/dao/SyncMetadataDao.kt — minimal observe/get/upsert - libs.versions.toml — Room 2.8.4, androidx-room plugin alias - app/build.gradle.kts — apply androidx.room plugin, add room {} block, drop ksp room.schemaLocation arg Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Mirrors flutter_client/lib/cache/db.dart's CachedLikes Drift table — composite PK (userId, entityType, entityId) so the same user can like a track and its album and its artist independently. entityType is a plain string ("track" | "album" | "artist") for parity with the Drift schema and the server wire format. DAO surface tailored to consumers we know are coming: - observeLikedTrackIds(userId): Flow<List<String>> — audio-cache eviction reads this set to identify "liked" bucket members - observeLikedIdsOfType(userId, entityType): generalized variant - observeIsLiked(...): scalar Flow for LikeButton composables - upsertAll (sync writes) - delete (mutation queue → toggle off) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two related entities mirroring flutter_client/lib/cache/db.dart: - CachedPlaylists — one row per playlist; `systemVariant` is null for user playlists and "for_you" / "songs_like_artist" / etc. for system-generated mixes (used by the add-to-playlist sheet filter) - CachedPlaylistTracks — composite-PK join table, `position` carries ordering inside a playlist DAO surfaces split user vs system playlists at the query layer so ViewModels don't have to filter — observeUserPlaylists/observeSystemPlaylists. PlaylistTrackDao gets a deleteByPlaylist for the replace-all pattern after a sync delta lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Mirrors flutter_client/lib/cache/db.dart's AudioCacheIndex — one row per fully-downloaded audio file. Drives the 2-bucket LRU eviction policy that Phase 12's AudioCacheEvictionWorker will execute. DAO surface tailored to the eviction worker: - totalBytes / bytesBySource — sum-of-sizeBytes for cap checks - evictionCandidates(source) — oldest lastPlayedAt within a source bucket, NULL lastPlayedAt sorted first (never-played candidates evict before played ones) - touchLastPlayed(trackId, instant) — single-column update from the player on every "ready+playing" transition - bulk delete by track-id list for batch evictions CacheSource enum (MANUAL / INCIDENTAL / AUTO_PREFETCH) ported in Task 4.1's TypeConverters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two related fixes from re-reading the Drift source for slice 8: 1. The plan called this slice "last_played" but the Drift table is `cached_resume_state` — kept Drift's name for cross-reference during the port. Single-row JSON-blob pattern (queue, currentIndex, positionMs, source) — ResumeController (Phase 6.5) handles the Kotlin-side encode/decode so the schema stays stable across resume-shape evolution. 2. CacheSource enum was incomplete: Task 4.1 ported only 3 of the 5 Drift variants. Added AUTO_LIKED + AUTO_PLAYLIST (used by the auto-cache prefetcher to tag cached files by reason — drives the bucket eviction priority order INCIDENTAL > AUTO_PREFETCH > AUTO_PLAYLIST > AUTO_LIKED > MANUAL). No data migration needed — schema version is still 1 and we have no real users yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Mirrors flutter_client/lib/cache/db.dart's CachedHomeIndex — per-item rows that drive the Home screen sections (Recently Added Albums, Rediscover Albums/Artists, Most Played Tracks, Last Played Artists). Composite PK (section, position) — exactly one row per slot per section; sync replaces in-place via upsert. entityType ("album" / "artist" / "track") dispatches per-tile hydration to the right per-entity endpoint when the Home screen renders. DAO surface fits the sync flow: - observeBySection (Flow) for the Home composables - getBySection (suspend) for one-shot sync reads - upsertAll for sync writes - deleteBySection for replace-all on a section sync Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Last slice. Promotes the Phase 3.1 in-memory AuthStore placeholder to a Room-backed single-row auth_session table so session cookie + base URL survive process death. Design — hybrid storage: - MutableStateFlow is the primary read source so interceptor-thread reads stay synchronous (no awaiting a DAO call from inside an OkHttp interceptor) - Writes update the in-memory state synchronously AND launch a write-through coroutine that persists to the DAO - init() collects dao.observe() to keep in-memory in sync with persisted state on app start + any external DB writes AuthSessionDao gets partial-update queries (`setSessionCookie` / `setBaseUrl`) so we don't have to round-trip the full row on every mutation. First write does an upsert to seed the row. DatabaseModule grows a @Provides for AuthSessionDao — Hilt can't inject AppDatabase's abstract DAO accessors directly; each consumer-needed DAO gets a thin bridge. AuthCookieInterceptorTest updated: AuthStore now takes (dao, scope) constructor args. Test uses mockk for the DAO and TestScope with UnconfinedTestDispatcher so the in-memory state mutations the test asserts on aren't affected by the asynchronous DAO writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Mirrors flutter_client/lib/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>/api/*literal d08c937f3bCache-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>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>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>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>Last task of Phase 5. The app now has the full bottom-bar shell with NavHost wiring. - nav/Routes.kt — @Serializable destinations: top-level tabs (Home/Library/Search/Settings), detail screens with id args (AlbumDetail, ArtistDetail, PlaylistDetail), overlays (NowPlaying, Queue, Login). - nav/MinstrelNavGraph.kt — NavHost with composable<RouteType>() destinations. Library wires to the real LibraryScreen; everything else uses the shared EmptyState as a "Coming soon" placeholder. - MainActivity — Scaffold with NavigationBar bottom bar. Selected-tab tracking via currentBackStackEntryAsState + NavDestination.hasRoute(KClass) (type-safe routes API in nav-compose 2.8+). - Library cards' onArtistClick / onAlbumClick now navigate to ArtistDetail(id) / AlbumDetail(id) — stubs for now, lit up when those detail screens land. Bottom-bar icons (Lucide CMP): - House, LibraryBig, Search, Settings startDestination = Library so the new UI is the cold-start landing spot until the Home screen lands in Phase 6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>First 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>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>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>- 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>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>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>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>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>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>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>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>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>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>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>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>- 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 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>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>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>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 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>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>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>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>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>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>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>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 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 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>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>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>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 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 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>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>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>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>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>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>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>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>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>Per [feedback_ksp_could_not_be_resolved_is_downstream]: KSP's "X could not be resolved" usually masks a syntax error in X's source. Both MeRepository.kt and MeApi.kt had \`/api/me/*\` in their class KDoc — the `*/` inside the doc comment terminates the comment prematurely, leaving the class body unparseable. KSP then fails to resolve the type while reporting the symptom at the caller's constructor. Replaced the wildcard path with plain prose ("the /api/me endpoints"). Same fix shape as the Kotlin nested-comment trap documented in the memory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The previous push tried to fix detekt but missed two things: * regenerate() in PlaylistDetailViewModel had its 3 returns "fixed" in name only — the if(!refreshable)return was still there. Now folded into the variant Elvis chain with takeIf{refreshable}. * The helper-composable extractions I just shipped pushed three screens over detekt's 11-function-per-file cap. Compose screens naturally produce many small private composables; per the established pattern, suppress TooManyFunctions at the file level with a one-line rationale rather than fight the cap. * AdminUsersScreen main body was 94 lines (cap 60) because I rebuilt the Scaffold inline with the new Invites section. Extracted AdminUsersScaffold helper so the main function only owns state hoisting and dialog dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>* HealthzApi — GET /healthz unauthenticated endpoint returning {status, version, min_client_version}. * VersionCheckController — Hilt singleton; 5-minute poll loop on appScope, soft-fails on network errors (keeps last-known VersionResult: OK / TOO_OLD / SKIPPED). Reuses isVersionNewer() from the About card's UpdateRepository. Exposes recheck() for the banner's "Check now" button. * VersionTooOldBanner — shell-level Compose banner with AnimatedVisibility shrink/expand, triangle-alert icon, copy matching Flutter, "Check now" trailing button. Tiny VersionTooOldViewModel lifts the StateFlow through Hilt. * ShellScaffold adds VersionTooOldBanner() above ConnectionErrorBanner() in the existing banner slot. * MinstrelApplication uses the construct-the-singleton trick to start the poll loop at app launch. Closes audit v2 #23. Locally cached content keeps working when the banner is shown — the message nudges the user toward an update without blocking the rest of the UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Audit v3 Bug-1 + Bug-2 — cleanup sweep, two cold-launch recovery fixes: Bug-2: LoadingCentered() was duplicated as a private composable across 10 screens (LikedTab, HistoryTab, RequestsScreen, PlaylistsListScreen, DiscoverScreen, AdminQuarantine/Requests/Landing, HiddenTab, SearchScreen), each rendering a non-scrollable Box(fillMaxSize). PullToRefreshBox needs a scrollable child to bubble overscroll — the bare Box silently swallowed the pull-to-refresh gesture, so a cold-launch error couldn't be recovered without killing the process. New shared widget at shared/widgets/LoadingCentered.kt mirrors EmptyState's shape (single-item LazyColumn + fillParentMaxSize Box) so pull-down fires on the Loading branch. Drops the 10 duplicates. Bug-1: LibraryScreen's Artists and Albums tabs both rendered ErrorRetry(onRetry = {}) — the Retry button looked tappable but did nothing. Wired both to viewModel.refresh(). Net: cold-launch recovery now works on every shell screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three small audit v3 cleanups bundled: * Bug-6 / §4.19 — AdminLanding Users card subtitle was stale ("Manage accounts") even though Invites shipped weeks ago. Now: "Manage accounts and invites" to match Flutter. * §4.22 — Account card never showed the admin badge. Flutter appends " · admin" next to the username when isAdmin. Threaded isAdmin through AccountCard and added the suffix. * Bug-4 — NowPlayingBody used Column.Center with no scroll, so on small-portrait or any landscape phone the cover + scrubber + transport + actions row clip. Added verticalScroll so users on small screens can scroll the body instead of losing affordances. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Audit v3 §4.5: Flutter renders explanatory copy for each empty Home section ("No forgotten favourites yet. Like albums or artists to fill this in." for Rediscover, "Play some music..." for Most played, etc). Android was hiding empty sections entirely, which made the home view look sparse even when 2-3 sections had data and contributed to the user's emulator complaint about missing rows. Now: every section slot renders either the populated HorizontalScrollRow OR an EmptySection card (sentence-case title + understated body copy) so the page structure is always visible. Playlists row keeps its "hide-when-empty" behaviour for now since it leads the page — empty copy at the top would feel like an error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Foundation for the Flutter tile-provider pattern. Adds CachedAlbumDao / CachedArtistDao / CachedTrackDao observeById(id) Flow methods (query-only change; no schema migration). MetadataProvider exposes observeAlbum(id) / observeArtist(id) / observeTrack(id) as Flow<X?> that: - emits the Room row immediately if present, - fires a single background fetch when the cache emits null, - re-emits the populated row once the fetch upserts. Fetch dedup is per-ID via ConcurrentHashMap<String, Job> — five tiles asking for the same missing album = one network call. Fetch failures are swallowed (row stays null, tile keeps skeleton); FreshnessSweeper (next slice) retries. Also exposes refreshAlbum/Artist/Track(id) for the upcoming sweeper to force-refresh stale-but-cached rows; same dedup applies. Next slices: 2/3 - FreshnessSweeper periodic walk + EventsStream reconnect hook. 3/3 - Wire MetadataProvider into Home / Library / Search tiles and remove the band-aid Home hydration from commit040217ca. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The audit called this the worst kind of bug — when a track fails to load (404, decoder failure, premature EOS, network drop) ExoPlayer just silently skips to the next, hiding exactly the signal that distinguishes "this file is broken" from "the app is flaky". * PlayerController.onPlayerError now emits the failing track's title onto a CONFLATED Channel exposed as playbackErrorEvents: Flow<String>. Conflated rather than buffered so a stuck loop can't pile up; the reporter's debounce coalesces anyway. * PlaybackErrorReporter @Singleton collects the per-error stream, buffers in a 2-second debounce window, and emits coalesced user-facing strings: 1 error → "Couldn't play \"X\" — skipping" N errors → "Skipped N unplayable tracks" Matches the Flutter reporter shape so users see one toast on a network blip that kills N tracks instead of a stack of N toasts. * PlaybackErrorViewModel bridges the reporter's Flow into Hilt's ViewModel layer so ShellScaffold can collectAsState via hiltViewModel(). * ShellScaffold adds a second LaunchedEffect that pipes the reporter messages into the existing shared snackbar — no new UI surface needed. * MinstrelApplication uses the construct-the-singleton trick to start the reporter at app launch so it's collecting from the first Media3 connect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Ports Flutter's error-copy.json (45 server codes → sentence-case copy) as a Kotlin ErrorCopy object. fromThrowable(t): - Retrofit HttpException → parse {"error":{"code":...}} body, map code (e.g. "wrong_password" → "Current password is incorrect.", "playlist_not_found" → "That playlist no longer exists.") - IOException → connection_refused → "Couldn't reach the server. Check the URL and try again." - anything else → "Something went wrong." Wired into 22 ViewModels / screens, replacing the raw `e.message ?: "<generic>"` fallbacks that leaked exception text (e.g. "HTTP 404", "Unable to resolve host") into the UI. Load-state errors now read as actionable copy; settings form messages dropped their "Couldn't save:" prefixes since the friendly strings stand alone. ArtistDetail's playback path keeps its "Couldn't start playback: " prefix (local-action context). PlayerController's controller-connect failure and the About update-check are left on their own copy (not server-code errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>§4.10/§4.18 — PlaylistCard now overlays a small primary-tinted pill on system-playlist covers showing the variant ("For You" / "Discover" / "Today's mix" / …) instead of relying on the subtitle line. The subtitle now prefers track count, so system tiles show variant pill + track count rather than just the label. §4.16 — MiniPlayer cover wraps its content in a Crossfade keyed on coverUrl so artwork dissolves into the next track instead of hard- swapping. Pairs with the CoverPrefetcher cache-warming so the next cover is usually ready to fade in. Cleanup — migrated the AdminUsers invite Copy-token button off the deprecated LocalClipboardManager.setText to LocalClipboard.setClipEntry (suspend, via rememberCoroutineScope + ClipData/ClipEntry), clearing the build warning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The kebab "Go to album/artist" did popBackStack() then navigate() — two transactions, leaving a visible frame on whichever shell tab sat under NowPlaying before the detail pushed. Replaced with a single navigate(...) { popUpTo(NowPlaying) { inclusive = true } } so the modal is removed and the detail pushed in one atomic transaction, no intermediate frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wires the "Install vX.Y.Z" action the About card's update-check teed up. * ApkInstaller @Singleton — downloads the server APK via the shared OkHttpClient (inherits auth cookie + BaseUrlInterceptor host rewrite; apkUrl is server-relative) into cacheDir, then launches the system installer via a FileProvider content:// URI. canInstall() gates on PackageManager.canRequestPackageInstalls() on O+, and requestInstallPermission() opens the "install unknown apps" settings page when not yet granted. * Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider (${applicationId}.fileprovider) + res/xml/file_paths.xml exposing the cache dir. * AboutCardViewModel.install(info): permission check → download → launch, with isInstalling + installMessage state. Errors routed through ErrorCopy. * About card shows an "Install vX.Y.Z" button under the check button when an update is available, "Downloading…" while in flight, and the install message line. Extracted UpdateControls / InstallButton / ButtonSpinner helpers to keep AboutCard under the length cap; added @file:Suppress(TooManyFunctions) for the settings-card density. Closes audit v3 #25 — the last open parity item from the v3 sweep aside from the offline-pool Home cards (#28). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Ports flutter_client/lib/library/artist_detail_screen.dart:177-208 _ArtistAvatar: server coverUrl wins; when empty, use the first loaded album's /api/albums/{id}/cover; only show the bare Lucide.User icon when the artist has neither. Threads detail.albums.firstOrNull()?.id through ArtistHeader → ArtistAvatar. (Seeded-loading header passes null since the seed carries no album list.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Ports Flutter's UpdateBanner. UpdateBannerController polls /api/client/version at launch + every 24h, compares the bundled APK against this build via isVersionNewer, and exposes the available UpdateInfo (minus in-memory per-version dismissals). The shell banner nudges "Update Minstrel · {version} available" with Install (reusing ApkInstaller's download + system-install handoff, routing to the install-permission settings first when needed) and a dismiss X. Uses an understated surfaceVariant tone, distinct from the error-colored VersionTooOldBanner hard gate. Divergence noted: ApkInstaller exposes no byte progress, so the downloading state shows an indeterminate bar rather than Flutter's determinate one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The server returns relative cover_url (/api/albums/{id}/cover, /api/playlists/{id}/cover). Android only resolved the empty-fallback placeholder trick, so any non-empty relative URL from a fresh fetch went to Coil host-less and silently failed (album detail, artist-detail album grid, playlist cards, artist avatars). Add a shared ServerImage composable + resolveServerImageUrl that prefixes the placeholder host (rewritten by BaseUrlInterceptor) for relative paths, passes http(s) through, and falls back when blank. Route AlbumCard, PlaylistCard, AlbumDetail header, and ArtistAvatar through it. Mirrors Flutter's ServerImage._resolve. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Server emits stream_url as a relative path (/api/tracks/{id}/stream, internal/api/convert.go:75). PlayerController.toMediaItem passed it raw to setUri, so OkHttpDataSource saw a host-less URI and silently failed -- queue UI loaded but no audio played. Route streamUrl through the same resolver used for covers (resolveServerImageUrl), which prepends the placeholder.invalid host that BaseUrlInterceptor rewrites to the live server with the auth cookie. The setCustomCacheKey(id) still keys the SimpleCache by trackId, so cache residency is unaffected by the URL form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Five cache-first ViewModels all ended their flow pipeline with: .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), UiState.Loading) plus a per-file private const for the 5_000L share-stop timeout. Extract to a single Flow<UiState<T>> -> StateFlow<UiState<T>> extension in shared/CacheFirstStateFlow.kt; migrate Library, PlaylistsList, LikedTab, AddToPlaylist, and Home VMs. Each VM drops the catch + stateIn + ErrorCopy + SharingStarted imports + the local constant. AddToPlaylistVM keeps its onStart { refresh; emit Loading } block in front of the helper -- that re-emit is intentional UX for sheet re-opens past the 5s subscriber timeout.Mirrors Flutter's PlayCircleButton (library/widgets/play_circle_button.dart) on the Home AlbumCard / ArtistCard / PlaylistCard surfaces. 44dp accent disc bottom-right of the cover, parchment Play icon, self-managed spinner during the fetch-and-queue setup, drop shadow. New: shared/widgets/PlayCircleButton.kt. Cards gain an opt-in onPlay: (suspend () -> Unit)? parameter; when null the overlay is omitted, preserving the Library / Discover / Search / detail surfaces unchanged. HomeScreen wires three new HomeViewModel suspend methods: playAlbum GET /api/albums/{id}, play tracks from 0 playArtistShuffled GET /api/artists/{id}/tracks, FY shuffle, play 0 playPlaylist systemShuffle for refreshable system playlists, refreshDetail otherwise, 8s timeout PlaylistsApi gains systemShuffle for the rotation-aware shuffle path (GET /api/playlists/system/{kind}/shuffle, mirrors playlists.dart). PlaylistCard play is disabled offline + refreshable (server endpoint unreachable) and for empty playlists. Album / artist errors surface via the existing transientMessages snackbar channel, matching the ArtistDetailViewModel improvement over Flutter's silent fail. Scope: Home tiles only this slice. Flutter applies the overlay to the cards everywhere they render; revisit Library / Discover / Search / detail surfaces in a follow-up.Liked tracks now render a filled heart (HeartFilled, defined inline in LikeButton.kt) instead of just recoloring the outlined heart. Matches the cross-app convention (Spotify, Apple Music, etc.) where fill is the affordance change rather than tint alone. Lucide ships outlined-only by design (jar inspection: heart.kt, heart_crack.kt, heart_off.kt, etc., none filled). Built the filled variant locally from Lucide's exact path data so the toggle reads as a fill swap, not a different shape. No new dep - keeps the project's Lucide-everywhere icon system consistent. Operator authorized the divergence 2026-06-01 ("changing to material for this one symbol... is acceptable"); inline ImageVector route preserves Lucide visual harmony better than mixing material-icons just for one glyph.HomeViewModel.playPlaylist (the Home play-button overlay path) and PlaylistDetailViewModel.play (the detail-screen path) both converted PlaylistTrackRef -> TrackRef before player.setQueue, but only the detail-screen path filtered out unplayable rows (missing trackId or empty streamUrl). Home's path passed them through; Media3 then silently no-op'd on setUri("") and the queue appeared loaded but nothing started. Operator reported "Songs-like playlists queue music that never plays" via the Home play-overlay 2026-06-01. The same conversion in two places with different rules is exactly the foot-gun the §1-§3 DRY pass was supposed to head off; this commit adds it as a follow-up since the playlist-play helper wasn't covered by that sweep. Extracts `List<PlaylistTrackRef>.toPlayableTrackRefs()` in playlists/data/PlaylistsRepository.kt. Filters (isAvailable && streamUrl non-empty) THEN maps to TrackRef, in one pass. Both call sites now use it. The remaining private `toTrackRef()` in PlaylistDetailScreen is kept for the per-row TrackActionsButton wiring - the actions menu only needs id+display fields and doesn't queue anything itself.Operator 2026-06-01: "navigation layout and library sections are what I'd like to have implemented as it seems better than our current navbar solution. I think I'd like to have these nav options moved into the top bar centered." Top-bar restructure: - Centered nav (replaces the 192dp left sidebar): Home / Library / Discover, with icons + labels. Labels collapse below sm breakpoint so the bar stays icon-only on small viewports. - Right side (search input + user dropdown) unchanged. - Hamburger button + MobileNavDrawer + the mobileNav store all removed - the centered nav lives at all viewport sizes. Library page restructure (mirrors Android LibraryScreen): - New routes/library/+layout.svelte renders a tab bar across the five Library sub-pages: Artists / Albums / Liked / History / Playlists. Active tab gets an accent underline + onSurface text. - routes/library/+page.server.ts redirects bare /library to /library/artists (Android default tab). - /playlists (list) moved to /library/playlists; old URL gets a 308 redirect (routes/playlists/+page.server.ts) so existing bookmarks land on the new location. /playlists/[id] (detail) is unchanged - matches the server API URL shape. Deleted: Shell's sidebar markup, MobileNavDrawer.{svelte,test.ts}, the mobileNav store, the old routes/playlists/+page.svelte. Shell test rewritten to assert the new 3-item centered nav; playlists test moved next to its new +page.svelte and its test-utils import path updated.Replace dead-end empty copy with EmptyState cards that include a clear next step. - EmptyState.svelte: reusable card with `block` (whole-tab) and `inline` (sub-section) variants, an actions snippet for buttons. - Artists / Albums: when the whole library is empty, link to /settings (the operator can scan a folder there). - Liked: when all three sub-sections are empty, show a single whole-tab card ("No likes yet" with Explore Home + Browse albums). When only one sub-section is empty, an inline hint with a link to the corresponding library tab. - History: "Listen to something" button → Home. - Playlists: "Create a playlist" button calls the existing create flow; renamed from "New playlist" to avoid colliding with the header's button-by-name lookup in tests. Liked tab tests updated to match: the previous "three 'no liked X yet'" assertion is now a single onboarding card test + a sibling test for the mixed populated/empty case.