Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
Owner

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

  • Full-screen NowPlaying at /now-playing, opened by tapping the PlayerBar cover/title
  • Global keyboard shortcuts — Space/K play-pause, J/L seek ±10s, ←/→ track skip, ↑/↓ volume, M mute, / focus search
  • Multi-select + bulk actions on TrackRow lists — checkbox-on-hover, shift+click range, floating SelectionBar with Play next / Add to queue / Add to playlist / Like all
  • Home backflow — five secondary system playlists (deep_cuts, rediscover, new_for_you, on_this_day, first_listens) appended to the row

Tier B — surface polish

  • Quick-filter on every Library tab (Artists / Albums / Liked / History / Playlists), debounced 120ms
  • Empty-state onramps — every dead-end empty becomes an action card (Open settings / Listen to something / Browse albums / Create a playlist)
  • B7 (persistent queue+position) already shipped; B8 (search input harmonization) verified no duplicate

Tier C — small wins

  • Keyboard reorder on PlaylistTrackRow (ArrowUp/ArrowDown on the focused drag handle)
  • Crossfade between tracks — 0–12s slider in Settings → Playback, default 0 (off)
  • Copy-link button on owned + public playlists
  • C9 (recent-searches dropdown) dropped in favor of follow-up predictive-fill typeahead (Scribe 542)

Earlier in this batch

  • M8 Android native port — full Kotlin/Compose app with Media3, Room cache-first, Hilt, ServerImage, hydration prewarmer, DRY refactor across §1–§3a (TrackRow consolidation, CoverTile, ServerUrls, asCacheFirstStateFlow)
  • M7 web feature sweep — full Subsonic-API parity client + admin UI (#352–#365, #377–#381), Library section restructure (centered top-bar nav, Library tabs replacing sidebar), system playlists v2 (For You / Discover / Deep Cuts / Rediscover / Most Played multi-row), event bus, cache-first Library, offline v2
  • Server — skip classification moved server-side, Lidarr bootstrap, admin dashboard, admin requests queue with override modal, dedicated CI runner toolchains
  • CI — migrated to Gitea Actions, keystore signing, registry auth via REGISTRY_TOKEN

After merge: tag v2026.06.01.0 to publish :latest (push to main alone builds :main only).

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 - **Full-screen NowPlaying** at `/now-playing`, opened by tapping the PlayerBar cover/title - **Global keyboard shortcuts** — Space/K play-pause, J/L seek ±10s, ←/→ track skip, ↑/↓ volume, M mute, / focus search - **Multi-select + bulk actions** on TrackRow lists — checkbox-on-hover, shift+click range, floating SelectionBar with Play next / Add to queue / Add to playlist / Like all - **Home backflow** — five secondary system playlists (deep_cuts, rediscover, new_for_you, on_this_day, first_listens) appended to the row ### Tier B — surface polish - **Quick-filter** on every Library tab (Artists / Albums / Liked / History / Playlists), debounced 120ms - **Empty-state onramps** — every dead-end empty becomes an action card (Open settings / Listen to something / Browse albums / Create a playlist) - B7 (persistent queue+position) already shipped; B8 (search input harmonization) verified no duplicate ### Tier C — small wins - **Keyboard reorder** on PlaylistTrackRow (ArrowUp/ArrowDown on the focused drag handle) - **Crossfade** between tracks — 0–12s slider in Settings → Playback, default 0 (off) - **Copy-link** button on owned + public playlists - C9 (recent-searches dropdown) dropped in favor of follow-up predictive-fill typeahead (Scribe 542) ## Earlier in this batch - **M8 Android native port** — full Kotlin/Compose app with Media3, Room cache-first, Hilt, ServerImage, hydration prewarmer, DRY refactor across §1–§3a (TrackRow consolidation, CoverTile, ServerUrls, asCacheFirstStateFlow) - **M7 web feature sweep** — full Subsonic-API parity client + admin UI (#352–#365, #377–#381), Library section restructure (centered top-bar nav, Library tabs replacing sidebar), system playlists v2 (For You / Discover / Deep Cuts / Rediscover / Most Played multi-row), event bus, cache-first Library, offline v2 - **Server** — skip classification moved server-side, Lidarr bootstrap, admin dashboard, admin requests queue with override modal, dedicated CI runner toolchains - **CI** — migrated to Gitea Actions, keystore signing, registry auth via `REGISTRY_TOKEN` After merge: tag `v2026.06.01.0` to publish `:latest` (push to main alone builds `:main` only).
bvandeusen added 295 commits 2026-06-01 14:27:10 -04:00
M8 phase 1.1: empty multi-project Gradle scaffold (root + :app
placeholder). Version catalog establishes pinned Kotlin/AGP/Compose/
Hilt/Room/Media3/etc. versions for the whole module.

Gradle wrapper (8.10) reused from flutter_client/ — the wrapper jar
is a bootstrap and respects distributionUrl from gradle-wrapper.properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.2. AndroidManifest declares FGS mediaPlayback +
POST_NOTIFICATIONS permissions ahead of the player phase. Activity
hosts a single Compose Scaffold for now; nav graph lands in phase 5.

Launcher icons reused from flutter_client/ (same applicationId means
same brand at cutover). MinstrelApplication referenced in manifest
but the class itself lands in Task 1.3 — manifest class names are
resolved at install time, not build time, so the intermediate commit
still builds.

@AndroidEntryPoint deferred to Task 1.3 alongside @HiltAndroidApp on
MinstrelApplication (Hilt KSP errors without an annotated Application).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.3. Plants the Hilt entrypoint so the rest of the modules
(NetworkModule, DatabaseModule, PlayerModule) can land in subsequent
phases. WorkerFactory wired so HiltWorker can be used directly later.

Restores @AndroidEntryPoint on MainActivity (deferred from 1.2 since
Hilt KSP errors without an annotated Application class).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.4. Mirrors flutter_client/lib/theme/. Source of truth for hex
values is flutter_client/shared/fabledsword.tokens.json (manual sync until
cross-language codegen lands; ports the dark-surface + flat cohort).

Material 3 ColorScheme takes accent as primary; action colors
(Moss/Bronze/Oxblood) live in LocalActionColors as semantic roles per the
project_design_system rule "NEVER use accent for action buttons".

Typography uses androidx.compose.ui.text.googlefonts to fetch Fraunces /
Inter / JetBrains Mono at runtime via Play Services Fonts — matches the
Flutter client's `google_fonts` package (no bundled .ttf files in either
tree). Weights restricted to 400/500. Fraunces is reserved for ≥18sp
display/headline slots per the design-system rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 2.1. Runs on every push to dev/main + PR to main (path-filtered
on android/**). Tag pushes (v*) additionally build a signed release APK
attached to the existing Forgejo release as minstrel-android-<tag>.apk
during the side-by-side period; that name flips to minstrel-<tag>.apk
at M8 phase 14.4 cutover.

Reuses ANDROID_KEYSTORE_B64 + STORE/KEY_PASSWORD + KEY_ALIAS secrets so
signing matches flutter.yml — Android accepts upgrade in place at cutover
without uninstall.

runs-on: flutter-ci because that's the only proven-working runner label
on this Forgejo instance with docker. Switch to android-ci once that
label gets registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Hilt 2.52 referenced AGP's old BaseExtension which AGP 9 removed,
causing ktlintCheck to fail at plugin-application time:

  Failed to apply plugin 'com.google.dagger.hilt.android'.
  > Android BaseExtension not found.

Dagger/Hilt 2.59+ adds AGP 9 support (and mandates it for the Gradle
plugin path). 2.59.2 is the current latest.

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>
Two assignments had a multi-line RHS sitting on the same line as the
`=`. ktlint's multiline-expression-wrapping rule requires the
multi-line expression to start on a new line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detekt 1.23.7 bundles kotlin-compiler-embeddable 1.9.10 whose
--jvm-target validator only accepts up to 22. Detekt auto-detected
the runner's JDK 25 and choked. Pin to 17 (matches our
compileOptions.targetCompatibility + kotlin.compilerOptions.jvmTarget).

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>
1.23.8 still failed on JDK 25 with "25.0.3" — same opaque shape as the
original Gradle 8.10 failure. The 1.23.x line bundles kotlin-compiler-
embeddable 1.9.10 which doesn't actually run on JDK 25 despite the
release note claim.

2.0.0-alpha.3 is explicitly built against Kotlin 2.3.21 + Gradle 9.3.1
+ tested with JDK 25 (per release notes). Alpha is acceptable risk
given there's no stable 2.x and we're already on bleeding-edge AGP 9
+ Kotlin 2.3 elsewhere.

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>
detekt 2.0 removed the top-level `build:` key:

  Property 'build' is misspelled or does not exist. Allowed properties:
  [comments, complexity, config, console-reports, coroutines,
   empty-blocks, exceptions, naming, performance, potential-bugs,
   processors, style].

The `build.maxIssues = 0` setting we had moved to the Gradle plugin DSL
(`failOnSeverity` option, defaults to Error). Emptied the YAML; rely on
detekt defaults via `buildUponDefaultConfig = true` until we have
specific rule overrides to write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven 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 CachedArtists / CachedAlbums /
CachedTracks Drift tables. Library cache foundation — LibraryRepository
(Phase 5.2) reads cache-first through these DAOs and refreshes from
server via the sync controller (Phase 12.4).

Column names follow Kotlin idiom (camelCase) instead of Drift's
snake_case; the schema is internal to the native client and the wire
JSON conversion happens in feature-level mappers.

Each DAO carries:
  - observe* (Flow) for cache-first reads in ViewModels
  - getById/getByIds (suspend) for one-shot lookups
  - upsertAll (suspend, REPLACE) for sync writes
  - deleteByIds (suspend) for sync-driven deletes

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 CachedQuarantineMine —
the user's flagged-as-hidden tracks. Server returns the full
denormalized snapshot on /api/me/quarantine; the cache mirrors that
shape so the Quarantine screen renders without a join.

`createdAt` is a server ISO-8601 string (canonical timestamp);
`fetchedAt` is our local sync marker.

DAO covers the three known consumers:
  - observeAll for the Quarantine screen (newest first)
  - observeFlaggedTrackIds for feed-level filtering
  - observeIsHidden(trackId) scalar Flow for per-tile UI

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>
AudioCacheIndexDao has 12 methods (default rule threshold is 11) — DAOs
accumulate one method per distinct query and inherently exceed the
default. Scoped via ignoreAnnotated: ["Dao"] rather than raising the
global threshold; the rule still catches non-DAO interfaces that grow
unreasonably wide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedMutations — the
offline-write queue that MutationQueue.enqueue() inserts into when a
server-write fails with IOException and MutationReplayer.drain() pops
from when connectivity returns (Phase 12.2).

`kind` is a string registered in `MutationKind` (Phase 12.2) so the
replayer can map to the right handler. `payload` is JSON-serialized
args. Unknown kinds get dropped at drain time rather than wedging.

DAO surface tailored to the replayer:
  - observePendingCount: Flow<Int> for the offline-indicator badge
  - getAll: FIFO list for drain (id ASC = oldest first)
  - insert: returns the autoGenerate'd id
  - recordAttempt(id, instant): atomic increment + lastAttemptAt set
  - delete(id) / clear

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>
AppDatabase grew its 12th DAO accessor in slice 9 and tripped the
TooManyFunctions rule. Same shape as the @Dao case from slice 5 —
Room types naturally accumulate one method per entity family. Added
"Database" to the ignoreAnnotated list alongside "Dao".

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>
CI hit a KSP/Hilt resolution error on the prior commit:
  ModuleProcessingStep was unable to process 'NetworkModule' because
  'LibraryApi' could not be resolved.

Switching from `retrofit.create(LibraryApi::class.java)` to the Kotlin
extension `retrofit.create()` (with explicit `LibraryApi` return type
annotation). The extension is reified and may sidestep whatever
type-resolution path the previous form tripped under KSP2 + Hilt.

If this also fails, the next step is to split AlbumWire.kt and
ArtistWire.kt so each file has a single top-level declaration —
investigating cross-file symbol-resolution order in KSP2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hypothesis for the KSP2 "LibraryApi could not be resolved" failure:
ArtistWire.kt and AlbumWire.kt each declared TWO @Serializable
classes (the Ref and the Detail variant). LibraryApi imports the
Detail variants but the file names match the Ref variants. KSP2's
symbol indexing may key on `className.kt` and fail to surface the
second declaration in a multi-class file.

Splitting per the MatchingDeclarationName convention:
  - ArtistDetailWire.kt (new)
  - AlbumDetailWire.kt (new)
  - ArtistWire.kt / AlbumWire.kt now contain only their namesake type

If this fixes it, the LibraryApi resolution will work without
changing the @Provides signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "ModuleProcessingStep was unable to process NetworkModule because
LibraryApi could not be resolved" failure under KSP2 + Hilt 2.59.2
turns out to be specific to @Provides returning a hand-written Kotlin
interface that carries no KSP-processed annotations. Hilt's
ModuleProcessingStep resolves the return type through KSP2's API and
gets an ERROR type for source-only interfaces in some configurations
(google/dagger#4303 cluster).

Two source-of-truth interfaces I tested side-by-side:
  - AuthSessionDao (@Dao, Room-processed) — @Provides works
  - LibraryApi (only @GET Retrofit annotations, no KSP processor) — fails

Workaround that's actually a better pattern: feature repositories
construct their Retrofit interface from the Hilt-injected shared
Retrofit instance. Fewer bindings in the Hilt graph; one Retrofit
interface lives next to its sole consumer.

LibraryApi.kt + wire types remain; LibraryRepository (Phase 5.2) will
hold the `retrofit.create<LibraryApi>()` call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kotlin (unlike Java) supports nested block comments. The doc-comment
on LibraryApi contained the string `/api/*` and `/api/home`-style
paths, which the lexer parsed as opening nested comments:

  /**
   * Retrofit interface for the server's native /api/* library surface.  ← lexer: nested /* opens
   ...
   */                                                                    ← closes the nested one
  // outer comment now unclosed; "Unclosed comment" reported at EOF

This compile error is what caused all the "ModuleProcessingStep was
unable to process NetworkModule because LibraryApi could not be
resolved" failures over the last four commits — KSP runs before
compileDebugKotlin and reports the downstream symptom (unresolvable
symbol) before the actual source-level error gets to print.

Rewrote the doc-comment to use `/api/...` and to wrap concrete paths
in backticks; no `/*` substring remains.

The "repos construct their Retrofit interface from shared Retrofit"
pattern from the previous commit stays; it's a sound pattern arrived
at via the wrong reasoning, but defensible on its own merits (fewer
Hilt bindings, locality of reference, easier test override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cache-first reads of artists/albums/tracks. The Room DAOs are the source
of truth ViewModels observe; refreshArtistDetail / refreshAlbumDetail
pull from the server and upsert into Room — Flow emissions propagate
automatically.

  - models/TrackRef.kt, models/ArtistRef.kt, models/AlbumRef.kt — domain
    types mirroring flutter_client/lib/models/. `Ref` suffix matches
    Flutter convention (lightweight reference, not full per-row metadata).

  - library/data/LibraryMappers.kt — wire->entity (for sync writes),
    entity->domain (for cache reads in ViewModels), wire->domain (for
    fresh server responses bypassing cache), detail-wire->entity (drops
    embedded array, repository upserts those separately).

  - library/data/LibraryRepository.kt — Hilt-injected, observe* Flow
    methods + suspend refresh* methods that upsert through the relevant
    DAOs. Constructs its own LibraryApi via `retrofit.create()` per the
    "repos own their interfaces" pattern adopted in NetworkModule.

  - LibraryRepositoryTest.kt — MockK + Turbine + MockWebServer.
    Verifies the Flow mapping, the wire->entity upsert split, and the
    null-on-miss case for getArtist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
LibraryRepository @Inject-constructs with CachedArtistDao, CachedAlbumDao,
CachedTrackDao. Hilt errored at hiltJavaCompileDebug with "MissingBinding"
for all three — Phase 4 only added the @Provides bridge for
AuthSessionDao in slice 10 (its single consumer).

Same one-line bridge per DAO: `db.<dao>()` from the AppDatabase
accessor. Future DAOs land in DatabaseModule when their first
@Inject-constructed consumer appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The icons-lucide-android variant ships icons as XML Vector Drawables
accessed via painterResource(R.drawable.lucide_x). My Compose code
uses the ImageVector API (`Icon(Lucide.Disc3, ...)`) which is provided
by the icons-lucide-cmp variant. "CMP" (Compose Multiplatform) works
fine in pure-Android Compose — the variant name describes the icon
representation, not a multiplatform-required runtime.

Switched the catalog entry; consuming code (AlbumCard, ArtistCard,
EmptyState, ErrorRetry) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
detekt's PackageNaming rule rejects underscores in package names
(Kotlin/Java convention is lowercase, no separator). Renamed
com.fabledsword.minstrel.cache.audio_cache -> .audiocache.

Pattern for future multi-word subpackages: smush rather than _
separator (e.g. mutationqueue, synccontroller, when those land).
The on-disk audio_cache/ dir path inside the app cache (PlayerFactory.kt:41)
is unaffected — that's a filename string, not a package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
NowPlayingScreen was 74 lines vs detekt's 60 threshold. Split into
three logical pieces: CoverPlaceholder, TrackHeader, and the
top-level Column orchestrator (which now stays under threshold and
reads more clearly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Kotlin 2.3 + kotlinx.serialization 1.7 — the reified overload of
`Json.encodeToString(value: T)` failed to infer T from the argument's
type at the call site (the compiler resolved to the
SerializationStrategy + value overload and complained both about
the type mismatch and the missing 'value' arg). Specifying
`<ResumePayload>` explicitly resolves to the correct overload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding `<ResumePayload>` to encodeToString made it bind to the wrong
overload — the compiler picked `encodeToString(SerializationStrategy<T>,
T)` and tried to treat `payload` as the serializer. Switched to the
explicit form:

  json.encodeToString(ResumePayload.serializer(), payload)

Always unambiguous. (Decode is fine — `decodeFromString<T>(String)`
isn't overloaded the same way.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Upstream Lucide renamed `more-vertical` → `ellipsis-vertical`; the
icons-lucide-cmp 2.2.1 bundle only exposes the new name, so the
import was unresolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
HomeRepository's CachedHomeIndexDao constructor injection failed with
"cannot be provided without an @Provides-annotated method" — the DAO
existed on AppDatabase but the per-DAO bridge in DatabaseModule was
never added (no consumer until HomeRepository landed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the 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>
detekt flagged the `3` and `4` branch labels in the `when (selectedTab)`
block. Promoted all five indices to TAB_ARTISTS … TAB_HIDDEN constants
— same shape detekt would have suggested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Refactor of the combine chain dropped the closing `}` for the
HomeViewModel class, so the screen composable below ended up
nested inside the ViewModel and the file's brace count was off
by one. Cascaded into "Unresolved reference HomeScreen" downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaylistDetailScreen takes navController for its TopAppBar back-button,
but the inShellDetail NavGraphBuilder extension was still scoped to
just expandPlayer. Added navController as the first parameter and
updated the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Folded the early-return ladder in `relativeTime` into a single `when`
returning the result. Same four cutoff windows — `<1h` / `<24h` /
`<7d` / older — just bound to a single expression so detekt's
ReturnCount (limit 2) is satisfied. The opening "unparseable → return
raw" still uses an early return; that's only 2 total now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
DiscoverScreen.kt hit detekt's TooManyFunctions limit (11) at 13.
Moved DiscoverState / SuggestionState / ResultsState / DiscoverViewModel
into DiscoverViewModel.kt — same package, same imports, no behavior
change. Screen file now hosts only composables + the snackbarFor helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverScreen.kt still tripped TooManyFunctions (13/11) after the VM
split because the file kept all the per-row composables + helpers.
Moved SuggestionTile + ResultTile + Avatar + AvatarShape into a
sibling DiscoverTiles.kt and dropped the now-unused imports
(background, CircleShape, RoundedCornerShape, AssistChip, Disc3,
User, AsyncImage, size, clip, ImageVector, TextOverflow).

DiscoverScreen.kt now hosts 10 functions: the screen + 6 layout-only
helpers (SearchBar, KindChips, SuggestionsPane, SuggestionsList,
SuggestionsHeader, ResultsList) + LoadingCentered + CenteredMessage
+ snackbarFor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverTiles.kt held only one non-composable top-level decl
(`enum class AvatarShape`), so detekt wanted the file renamed. The
enum had two variants discriminating circle vs. rounded; a Boolean
`circular` arg is the same information with no extra cost — collapses
the `when` into a one-line `if`. Two call sites updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- models/Quarantine.kt → QuarantineRef.kt
  - models/wire/QuarantineWire.kt → QuarantineMineWire.kt

Same fix as the earlier LikesWire → LikedIdsWire rename — detekt's
MatchingDeclarationName wants files with a single top-level
declaration to share its name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the 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>
AdminUsersScreen body was 65 lines vs the 60 cap because both
dialogs were rendered inline. Pulled them into a single
PendingDialogs helper that takes the two nullable selections + the
clear/confirm callbacks. AdminUsersScreen is back to ~45 lines and
PendingDialogs is ~30.

File function count is still 8 (Screen + UserList + UserRow +
ToggleRow + PendingDialogs + ResetPasswordDialog + DeleteConfirmDialog
+ LoadingCentered), under the 11 cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
LoginScreen body was 76 lines vs the 60 cap. Pulled the centered
Column into LoginForm() and the spin-aware Button into SubmitButton().
LoginScreen is back to ~20 lines (LaunchedEffect + Box around LoginForm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
SyncController constructor injection failed — SyncMetadataDao
existed on AppDatabase but had no per-DAO bridge in DatabaseModule
(no consumer until SyncController landed). Same fix as the earlier
CachedHomeIndexDao + CachedLikeDao + CachedMutationDao pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DatabaseModule's whole job is to host one @Provides per DAO; the
12/11 trip is structural, not a smell. Adding a second module file
to split DAO providers arbitrarily by family would be busier work,
not cleaner. Suppress with a comment that explains why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Trailing-underscore name tripped detekt's `(_)?[a-z][A-Za-z0-9]*`
pattern. `internal` matches the naming convention every other VM
in the codebase uses for the private MutableStateFlow shadowed by
a public StateFlow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last 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>
Same pattern as LikesRepository.toggleLike and friends. The catch
returns false → the row stays in the queue; that's the whole point
of the replayer. Added a comment explaining future diagnostic
logging plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Previous commit (0b72827) accidentally swept the local
android/.gradle, android/build, android/app/build, and
android/local.properties into the index via `git add android/`.
Root .gitignore only had entries for flutter_client/android/
paths, not the new native android/ tree.

Removes the cached files via `git rm --cached` and extends
.gitignore to cover the native Android Studio output dirs
(.gradle, .kotlin, .idea, build, app/build, local.properties,
*.iml). Source code is unaffected — only build-output and IDE
artifacts get untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-controllable theme override. Persists across cold restart;
default is SYSTEM (follows the device setting via isSystemInDarkTheme).

Schema bump: AppDatabase v2→v3 to add themeMode column on
auth_session. fallbackToDestructiveMigration is still in place so
the upgrade wipes local cache + cookie + user JSON on first launch
after update — destructive but acceptable pre-v1, since the sync
controller refills the cache from the server on next sign-in.

New:
  - theme/ThemeMode.kt — SYSTEM / LIGHT / DARK enum with wire
    (string) + toDarkOverride() (Boolean?) conversions.
    Stored as the wire string; null persisted = SYSTEM.
  - theme/ThemePreferenceViewModel.kt — surfaces AuthStore.themeMode
    as a typed StateFlow + setter. Lives in the theme package so
    MainActivity and SettingsScreen can both share it.

Modified:
  - cache/db/entities/AuthSessionEntity.kt — adds themeMode column.
    Comment updated to call out that the auth_session table is the
    de-facto app-prefs row at this point, not strictly auth-only.
  - cache/db/AppDatabase.kt — version 2 → 3.
  - cache/db/dao/AuthSessionDao.kt — adds setThemeMode partial-update.
  - auth/AuthStore.kt — adds themeMode StateFlow + setter +
    persistThemeMode following the existing per-field pattern.
  - MainActivity.kt — moves MinstrelTheme wrap from setContent into
    the App() composable so it can read the theme preference.
    BootSplash also wrapped in Surface(background) so the boot
    flash uses the right background color.
  - settings/ui/SettingsScreen.kt — Appearance ElevatedCard between
    Account and About with a SingleChoiceSegmentedButtonRow of the
    three options. Picks fire ThemePreferenceViewModel.setThemeMode
    and the whole tree recomposes against the new MinstrelTheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit (45e2248) committed only the two new theme files; the
six modified files (MainActivity, AuthStore, AppDatabase, AuthSessionDao,
AuthSessionEntity, SettingsScreen) silently didn't get staged. This
commit lands the actual integration so the theme picker works end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed out they were being forced to clear "http://localhost:8080"
before typing their actual server URL. That default came from
AuthStore.DEFAULT_BASE_URL — an internal HTTP-client fallback for
when nothing's been configured, never something a user typed.

Now: pre-fill only when the stored URL is something the user
actually saved (anything other than the default). Otherwise leave
the field empty so the placeholder ("https://minstrel.example.com")
shows through and they can just start typing.

Edit case still works: if a user already saved e.g.
"http://192.168.1.10:8080" and re-opens ServerUrl after sign-out,
the field is pre-filled with that real value for them to edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shuffle button on AlbumDetail was wired to the same code path
as Play — both called `play(startTrackId = null)` which started the
queue in track order. PlaylistDetail already does inline `.shuffled()`
for shuffle; mirror that on AlbumDetail via a new `shuffle()` VM
method that pre-shuffles the track list before handing it to the
player.

A future refinement could use Media3's `setShuffleModeEnabled` for
play-then-shuffle without disturbing the original queue ordering,
but pre-shuffling matches the existing PlaylistDetail behavior and
keeps both screens consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
MiniPlayer body hit 66 lines after the AsyncImage branch added in
f3ee182. Pulled the cover Box into its own MiniCover composable —
takes coverUrl + contentDescription, identical surfaceVariant
background + Lucide.Music fallback as before. MiniPlayer body
drops back to ~45 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The isAdmin parameter on MainAppBarActions defaulted to false and no
call site passed it, so the Admin overflow item never appeared for
actual admins — leaving the admin section unreachable from normal UI.

Move the lookup into a tiny AppBarActionsViewModel that reads
AuthController.currentUser, and drop the parameter from the public
signature so future call sites can't recreate the dead-param hazard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track rows in the Liked tab had .clickable(enabled = false) with a
"Phase 9" deferral comment, silently breaking the most common
interaction on the list. Mirror the HistoryTab pattern: inject
PlayerController, expose playTracks(list, index), and route taps
through to PlayerController.setQueue with source = "liked". Row
clickability is gated on streamUrl so rows that can't be played
stay inert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the three menu-driven player actions that back the TrackActions
overflow sheet. playNext/enqueue insert without disturbing playback
state; startRadio replaces the queue from GET /api/radio?seed_track=.
RadioController owns the API call so PlayerController stays Retrofit-free.

Endpoint is GET /api/radio?seed_track= (verified against
flutter_client/lib/api/endpoints/radio.dart), not the POST-with-path-
param shape originally drafted in the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the offline mutation queue with the two new write kinds the
TrackActions menu produces — appending a track to a playlist, and
flagging a track for quarantine. Replayer re-fires with idempotent
server semantics; payload data classes mirror Flutter's wire shapes.
Also adds PlaylistsApi.appendTracks since the replayer needs the
Retrofit surface in the same change set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the playlist-append capability end-to-end. Repository does
optimistic Room write at MAX(position)+1 + REST + MutationQueue
fallback (PLAYLIST_APPEND kind from the previous commit). Sheet
lists user-owned playlists for the menu's "Add to playlist…" item;
not yet reachable from a user surface — TrackActionsSheet wires it
in a later commit.

Also adds maxPosition + insertOrIgnore to CachedPlaylistTrackDao
to support the optimistic write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the hide-track capability. Repository wraps QuarantineApi.flag
with the QUARANTINE_FLAG mutation-queue fallback mirroring the
existing unflag pattern. Sheet collects reason (FilterChip row) +
optional notes (OutlinedTextField); reason vocabulary matches the
server wire values (bad_rip / wrong_file / wrong_tags / duplicate /
other) exactly.

Not yet reachable from a user surface — TrackActionsSheet wires it
in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composes the prior commits (player APIs, mutation kinds, repository
methods, sub-sheets) into the 7-item TrackActions sheet and its
kebab trigger. TrackActionsViewModel owns state observations + action
callbacks + transient snackbar messages. AlbumDetail track rows get
the kebab next to LikeButton; the screen-level Scaffold gains a
SnackbarHost that surfaces queue/playlist/error messages.

Hidden-state observation holds its own Set<String> snapshot since
QuarantineRepository doesn't expose a Flow surface yet; refreshes
on init and after every flag/unflag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spreads TrackActionsButton across the second wave of surfaces.
PlaylistDetail track rows, LikedTab tracks, HistoryTab rows, and
Search results now expose the full 7-item menu. Each screen-level
Scaffold gains a SnackbarHost that collects TrackActionsViewModel
transient messages.

Search tracks section upgraded from text-only TextRow to TrackRow
with cover thumb + secondary line (artist or album) + kebab.

MiniPlayer + NowPlaying kebabs follow in a separate commit so the
shell-level snackbar plumbing can land independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NowPlaying gains the 7-item TrackActions menu in its bottom action
row alongside the View Queue button. hideQueueActions=true suppresses
Play next / Add to queue since the menu's track IS the playing one.
Go to album / artist pops NowPlaying first (it's a full-screen
overlay) before navigating, mirroring Flutter's shell-route hook.

Adds a thin Scaffold around the screen body so the TrackActions
transient messages have a SnackbarHost to surface in.

MiniPlayer kebab still deferred — needs shell-level snackbar
plumbing that lives outside this slice's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a stable client_id column to auth_session for the upcoming
PlayEventsReporter. Lazily generated on first read by the reporter;
deliberately survives sign-out since it's a device install identity,
not a session value. DB version bump 3→4 (destructive migration per
the pre-release policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retrofit interface + the four request body variants
(play_started / play_ended / play_skipped / play_offline)
matching flutter_client/lib/api/endpoints/events.dart.
play_started's response carries play_event_id (nullable).

Not wired into any caller yet — PlayEventsReporter lands later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Extends the offline mutation queue with the play_offline kind so the
upcoming PlayEventsReporter can durably capture plays that complete
without a successful live play_started, or whose live ended/skipped
close failed. Replay re-fires POST /api/events with type=play_offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observes PlayerController.uiState and runs the (current track,
playing) state machine. Fires play_started on track-begin, then
on close emits play_ended (within 3s of duration) or play_skipped
through the live EventsApi. Failures and offline-start plays fall
through to the MutationQueue PLAY_OFFLINE kind for durable replay.
App-background (ProcessLifecycleOwner onStop) closes the current
play via the offline path so a process kill mid-listen still
records a play.

Wires into MinstrelApplication via @Inject so the singleton
constructs at app start (same pattern as MutationReplayer /
SyncController / ResumeController). client_id is a stable
device-install UUID resolved through AuthStore (added in 415200d).

Adds androidx.lifecycle:lifecycle-process dependency for the
ProcessLifecycleOwner background-event hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twelve detekt findings from CI:

* PackageNaming — `shared/widgets/track_actions/` → `trackactions/`
  (underscore violates [a-z]+(\.[a-z][A-Za-z0-9]*)*); 5 file moves +
  import updates across 7 caller files.
* MatchingDeclarationName — `RadioWire.kt` → `RadioResponseWire.kt`
  to match its single top-level declaration.
* TooManyFunctions on PlayerController (14/11) — @Suppress at class
  with rationale: transport + queue + radio + lifecycle are one
  cohesive controller; fragmenting would scatter related state.
* TooManyFunctions in SearchScreen.kt (file 12/11) — @file:Suppress
  with rationale: legitimate per-screen section/row helpers.
* LongMethod NowPlayingScreen (71/60) — extracted BottomActionsRow.
* LongMethod TrackActionsSheet (77/60) — extracted
  TrackActionMenuItems + TrackActionSubSheets.
* LongMethod HideTrackSheet (61/60) — extracted HideSheetButtons.

No behavior change; all suppressions carry rationale comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit #3 — PlayerController gains toggleShuffle / cycleRepeat
methods backed by Media3's Player.shuffleModeEnabled +
Player.repeatMode. PlayerUiState surfaces both via a new
shuffleEnabled flag and a RepeatMode enum (OFF/ALL/ONE) mapped
from Media3's int constants.

NowPlaying's BottomActionsRow grows two IconButtons: shuffle
toggles (accent when on, muted when off) and repeat cycles
off → all → one → off (Lucide.Repeat ↔ Lucide.Repeat1 swap;
accent when not-off).

PlayerViewModel exposes the two new methods as thin pass-throughs
matching the existing transport pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding the four shuffle/repeat parameters to the BottomActionsRow
call pushed the function 1 line over the 60-line cap. Extracted
the Column body into a private NowPlayingBody helper composable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for audit #4 (cross-device reactivity). Long-lived SSE
subscription exposed as a process-wide SharedFlow<LiveEvent>; gated
on having a session cookie (opens on sign-in, closes on sign-out).
Mirrors flutter_client/lib/shared/live_events_provider.dart in
behavior — no client-side timeout (server heartbeats every 15s),
no explicit reconnect-with-backoff in v1 (auth transitions re-open).

LiveEvent carries kind / userId / data (JsonObject); consumers
deserialize the payload per event kind they handle.

Force-injected into MinstrelApplication via the same pattern as
MutationReplayer / SyncController / ResumeController /
PlayEventsReporter so the singleton constructs at app start.

okhttp-sse was already on the classpath; no new deps.

No consumers yet — LiveEventsDispatcher + per-screen subscribers
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscribes to EventsStream and maps like-family events to
LikesRepository.refreshIds(). Cross-device likes (web flips a heart,
phone reflects it) now propagate without a manual refresh.

ProcessLifecycleOwner foreground hook re-runs the same refresh as
defensive cold-start cleanup — matches Flutter's resume-handler.

Screen-scoped events (playlist.deleted for a specific id, single-
request status_changed) intentionally NOT in the dispatcher; those
ride EventsStream.events directly from per-screen ViewModels in the
next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Previous per-screen SSE wiring (525873f) updated the constructor +
init block of AdminRequestsViewModel but the matching imports +
RELEVANT_EVENT_KINDS const were silently dropped from the edit,
so KSP couldn't resolve the EventsStream type. The other four VMs
got the imports correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for audit #5 Storage settings. CacheSettings carries the
four user-tunable cache prefs (liked/rolling caps, prefetch window,
cache-liked toggle) mirroring Flutter's cache_settings_provider.dart
field-for-field. Defaults match Flutter (5 GiB per bucket, prefetch
window = 5, cache-liked = true).

Persistence rides AuthSessionEntity (the de-facto single-row prefs
table) as a JSON blob in a new cacheSettingsJson column. DB version
bump 4→5; destructive migration per the pre-release policy.

PlayerModule.provideCacheConfig now snapshots AuthStore.cacheSettings
at injection time. SimpleCache is constructed once per process, so
limit changes from the Settings UI (next commit) take effect on next
app launch. Documented in the @Provides KDoc.

Storage section UI lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit #5 user-visible parity. Storage card in SettingsScreen exposes
the four CacheSettings prefs (liked/rolling cap dropdowns, prefetch
window dropdown, cache-liked switch), live cache usage display, and
two action buttons:

- Sync now → SyncController.syncSafe()
- Clear cache → SimpleCache.removeResource for every cached key
  (safe mid-flight; releasing the cache would crash live playback).
  Confirmation dialog before delete.

Cap settings persist via AuthStore.setCacheSettings (from the prior
commit). The card surfaces the "limits take effect on next app
launch" caveat — SimpleCache is constructed once per process.

Prefetch window + cache-liked-tracks toggle persist but have no
effect yet — the prefetcher + pin-on-like flows are separate audit
follow-ups.

Per-bucket usage (Flutter shows Liked vs Rolling sizes separately)
is collapsed to a single "Used" stat on Android v1 since SimpleCache
doesn't expose per-bucket totals without custom indexing — separate
follow-up if user wants the breakdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CacheSettings persistence in b438772 pushed AuthStore from 12 to 14
functions. Same shape of fix as PlayerController (484ad6c-era):
@Suppress at the class with rationale — function count scales with
the pref count, splitting would scatter shared dao/scope/json
plumbing for no gain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compile errors on 6ef08ed: ExposedDropdownMenu is an extension on
ExposedDropdownMenuBoxScope and can't be referenced by FQN from
outside a Composable receiver. Rewrote both dropdowns using the
plain OutlinedButton + DropdownMenu pattern wrapped in a small
LabeledDropdown<T> helper, which works without the Scope dance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deferred MiniPlayer follow-up from the TrackActions slice.
MiniPlayer gains a TrackActionsButton next to play/pause with
hideQueueActions=true (the playing track is the queue entry itself).

Shell architecture: ShellScaffold now takes navController + owns a
shell-scoped SnackbarHost backed by a TrackActionsViewModel
hiltViewModel() at the shell level. Snackbars triggered by the
MiniPlayer's kebab surface there; per-screen kebabs continue to
flow through each screen's own Scaffold SnackbarHost (independent
collectors so the two never compete).

All 14 ShellScaffold call sites in MinstrelNavGraph updated to pass
navController; mechanical sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit #8 first wave. New shared widget wraps Material3's
PullToRefreshBox with isRefreshing state managed internally; consumers
pass a suspend onRefresh that the wrapper awaits before hiding the
indicator (no heuristic delays).

ViewModel pattern: refresh() now returns Job so the screen can
`.join()` it from the wrapper. Trivial change — adding `: Job =`
between the function signature and the existing viewModelScope.launch
body. Existing fire-and-forget callers continue to work since they
discard the return value.

Wired into HomeScreen, AlbumDetailScreen, ArtistDetailScreen.
Library tabs + detail / list / admin screens follow in next commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second wave of audit #8. PlaylistsListScreen, PlaylistDetailScreen,
DiscoverScreen, RequestsScreen all wrap their body in
PullToRefreshScaffold. VM refresh methods updated to return Job for
the wrapper's await.

PlaylistsListViewModel gains a public refresh() (was init-only
fire-and-forget). DiscoverScreen's swipe re-fetches suggestions
(the most-useful refresh target on that screen — Lidarr search
results refresh on next query).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final wave of audit #8. Library tabs (Artists/Albums via LibraryVM
refreshing through SyncController; Liked via LikesRepository;
History/Hidden via their own VMs) and all four admin screens
(Landing/Requests/Quarantine/Users) now support swipe-down refresh.

Per-VM change is uniform: refresh() returns Job so the
PullToRefreshScaffold wrapper can await it before hiding the
indicator.

Audit #8 user-visible parity now complete across all screens that
benefit. Search/Queue/NowPlaying/Settings intentionally excluded —
Search is query-driven, Queue is local state, NowPlaying/Settings
are forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PullToRefreshScaffold addition in 4ca10e2 pushed the function 3
lines over. Extracted the inner Column body into a private
DiscoverBody helper composable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LibraryViewModel gained a SyncController constructor parameter for
pull-to-refresh (cf07a2a). Tests use a relaxed MockK SyncController
since none of the four cases exercise refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs from the v2 parity audit:

* Search: tapping a track result built a single-track queue
  (auto-advance died on track end). Now builds a queue from the full
  visible Loaded tracks list starting at the tapped row, mirroring
  Flutter.
* ArtistDetail: Play button played albums in tracklist order. Flutter
  shuffles. .shuffled() on the fetched list.
* NowPlaying: when the session tore down (queue finished, queue
  cleared from elsewhere) the screen stranded the user on an
  EmptyState with no escape. Replaced with a 500ms-debounced
  popBackStack so the brief null during MediaController IPC bind
  doesn't bounce the user, but a genuine session-end pops them back
  to wherever they came from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both were rendering controls that silently did nothing — there's no
MetadataPrefetcher on Android, and no pin-on-like flow. A toggle
that flips persisted state but has no functional effect makes the
app look broken.

Removed the two rows from StorageCard; CacheSettings persistence
keeps the fields so the controls come back unchanged when the
underlying systems land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 silent-breakage: PullToRefreshBox needs scrollable content
in its nested-scroll connection to detect the gesture. Empty/Error
branches used a plain centered Column → no nested-scroll
participation → swipe gesture silently ignored → user can't recover
from a cold-load error.

Fix: re-house EmptyState inside a single-item LazyColumn with
fillParentMaxSize. Visual is identical (centered icon + title +
body) but LazyColumn participates in nested-scroll dispatch so
PullToRefreshBox fires on swipe-down.

Covers Empty + Error on every PullToRefreshScaffold-wrapped screen
(Home, Library tabs, Album/Artist/Playlist detail, Discover,
Requests, Admin*). Loading-state pull-to-refresh remains broken on
screens using per-screen LoadingCentered helpers — that's a
transient state and lower priority; separate follow-up if it
becomes a real friction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 silent breakage / standing rule violation
(feedback_offline_first_for_server_writes): RequestsRepository.cancel
was direct REST with no queue fallback. Tap cancel offline and the
user's intent vanishes.

Now mirrors the unflag / appendTrack / requestCreate pattern: REST on
the happy path, MutationKind.REQUEST_CANCEL enqueued on IOException.
Replayer's existing AuthStore.sessionCookie trigger drains it on the
next signed-in transition.

Repository signature changed from `suspend fun cancel(id): RequestRef`
to `Pair<CancelOutcome, RequestRef?>` so callers can distinguish
synced vs queued (RequestsViewModel ignores the distinction for now;
optimistic removal already reflects the user's intent and the
post-replay refresh surfaces the canonical row).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 missing UX: full player had no way to escape except system
back. Two additions:

- Chevron-down close button as the Scaffold topBar navigation icon.
  Transparent background so the gradient/cover behind shows through.
- Vertical drag-down on the whole screen pops back past a 200px
  threshold, mirroring Flutter's modal-page gesture. Horizontal
  scrubs on the slider still work because the gesture detector is
  specifically vertical-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 reachability gap: Requests screen was orphaned (no entry
point) and admins had no Settings-side affordance for admin tools.
Both now surface as ListTile-style cards in the Settings stack with
Lucide chevron + leading icon, matching Flutter's layout. Admin tile
gated on SettingsState.isAdmin (sourced from AuthController.
currentUser, plumbed through the combine() chain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the /api/me/* slice mirroring flutter_client's SettingsApi:

* GET /api/me → MyProfile (id / username / displayName / email / isAdmin)
* PUT /api/me/profile → merges displayName + email
* PUT /api/me/password → current + new password

No caching — the Settings cards fetch on mount and writes go
straight to the server. No offline-queue fallback (changing your
own password offline is meaningless).

Profile + Password UI cards land in the next commit; this is just
the data layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 priority #4 remainder. Profile card loads MyProfile via
MeRepository.getProfile, exposes Display Name + Email TextFields,
and Save calls updateProfile + re-hydrates the form from the
canonical server response. Password card has the standard three-
field form (current / new / confirm) with client-side new==confirm
guard before hitting MeRepository.changePassword.

Both cards surface status inline (text below the action button)
rather than snackbar so they stay self-contained — Settings doesn't
own a screen-level Scaffold snackbar host for forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit 14c5262 created both cards + their VMs but the
SettingsScreen edit didn't land due to a stale-read error — the
cards existed but weren't called. Adding the two function calls
between the Admin tile and AppearanceCard now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combined the three early-bail checks (isChanging guard, empty-fields
validation, mismatch validation) into one when-expression with a
single return. Sentinel empty-string distinguishes "silent no-op
because already changing" from "user-facing validation error".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KSP couldn't resolve MeRepository despite the file being on disk +
imported correctly. Most likely cause: wire body data classes lived
in a separate file (MyProfileWire.kt) instead of the Api file —
diverged from the AdminUsersApi / PlaylistsApi / QuarantineApi
pattern where request bodies sit alongside the interface. Also
switched retrofit.create() to the explicit Java-class form in case
reified inference was the issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a1b1eed moved the body data classes out of MyProfileWire.kt but the
MeApi.kt write didn't land (stale Read), so MeApi was still trying
to import them from models.wire where they no longer exist. Inline
them in MeApi.kt matching the AdminUsersApi / PlaylistsApi /
QuarantineApi pattern (where request bodies live alongside the
interface).

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>
c74fa41 fixed MeApi.kt but the MeRepository.kt edit failed silently
(stale Read). MeRepository's KDoc still had the wildcard-path
backtick block that closes the doc comment prematurely. Replacing
it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 priority #8 — daily-touch player polish. MiniPlayer was
just cover + title + play-pause + kebab; now matches Flutter:

* Slim 4dp Slider at the top of the bar (Material3 thumb + active
  primary, no labels — those live on NowPlaying).
* Row: cover | title/artist | LikeButton | Prev | Play/Pause | Next | kebab.
* The cover-and-title region is the only part that expands to
  NowPlaying on tap; each IconButton handles its own click so a
  prev/next tap doesn't accidentally open the full player.

Bar height bumped 64 → 80dp to fit the slider above the row.
LikeButton sources its state from the shell-level TrackActionsViewModel
already plumbed through ShellScaffold (same hiltViewModel() instance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a606267 inlined three IconButton blocks (Prev/Play-Pause/Next)
that pushed MiniRow 2 lines over. Extracted to a private
TransportButton helper — same surface, half the lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 priority #9. Track rows on AlbumDetail, PlaylistDetail,
LikedTab, HistoryTab, and SearchScreen now render the currently-
playing track's title in accent (primary) color so the user can
spot "this is what's playing" while scrolling a list.

Pattern: each screen pulls a PlayerViewModel via hiltViewModel(),
reads state.currentTrack?.id, threads it through to its track row
composable as nowPlaying: Boolean. Row picks titleColor based on
the flag. No new infrastructure — just a thread-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #12. Hidden rows were text-only ("title", "artist · album",
reason chip); Flutter shows the album cover thumbnail and a "3h ago"
relative-time stamp next to the reason. Both added to QuarantineRef
(coverUrl computed from albumId) and HiddenRow renders them.

Time helper is duplicated from HistoryTab inline rather than
hoisted — both copies are small and screen-private; a shared
RelativeTime widget can land later if a third caller surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #18. AdminRequestsScreen was showing only the request's
display name, not which user asked for it. Now fetches the admin
users list in parallel with the requests list, builds a
userId → username map, and renders "Requested by <username>"
beneath each row. Falls back to an 8-char userId prefix when the
users list fetch fails (e.g., admin without users-list access on
some future server-side ACL change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #22. LibraryRepository.shuffleLibrary wraps the existing
LibraryApi.shuffleLibrary endpoint; LibraryViewModel.shuffleAll
fires PlayerController.setQueue with the response. LibraryScreen's
TopAppBar gains a Lucide.Shuffle IconButton to the left of the
existing MainAppBarActions row.

Offline-fallback (client-side shuffle over the local cache index)
is part of the larger Connectivity slice (audit #21) and not wired
in this commit — pressing Shuffle when offline just fails silently
for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues:

* AdminRequestsScreen line 105 MagicNumber on UUID prefix length 8.
  Extracted to USER_ID_PREFIX_LEN with rationale.
* LibraryRepository TooManyFunctions (12/11) after shuffleLibrary
  addition. Same pattern as PlayerController / AuthStore: @Suppress
  at the class with rationale (function count scales with entity-
  family count, splitting would scatter plumbing).
* JDK 22+ "restricted method java.lang.System::load" warning from
  Gradle's bundled native-platform jar. Add
  --enable-native-access=ALL-UNNAMED to org.gradle.jvmargs so the
  daemon opts the native loader in. Future-compat: Gradle will
  declare this in the jar's manifest eventually and the flag becomes
  redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92a9b55 used the FQN com.composables.icons.lucide.Lucide.Shuffle
inline, but Shuffle is an extension property on the Lucide companion
declared at com.composables.icons.lucide.Shuffle — it has to be
imported into scope. Other files (AlbumDetailScreen,
PlaylistDetailScreen) follow the same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92a9b55 added PlayerController to LibraryViewModel for the Library
"Shuffle all" button. Tests need a relaxed mock; none of the four
cases exercise shuffleAll() so the mock just satisfies the
constructor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups around the noise at the top of every CI log:

* CI workflow: JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED
  at the workflow env level so both build + release jobs apply it to
  the launcher JVM (not just the daemon). The launcher is the one
  loading native-platform.jar via System.load.

* Kotlin compiler: -Xannotation-default-target=param-property in
  kotlin.compilerOptions.freeCompilerArgs. Opts every @Inject /
  @ApplicationContext / @ApplicationScope constructor-parameter
  annotation into the future Kotlin 2.3 behavior (apply to both
  param AND property), clearing the 11 warnings on AuthController /
  AuthStore / MutationReplayer / SyncController / EventsStream /
  LiveEventsDispatcher / PlayEventsReporter / PlayerController /
  PlayerFactory / ResumeController.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #14. Artists + Albums search sections were rendering as
plain vertical TextRows (name-only for artists, title+artist for
albums). Now match Flutter:

* Artists: horizontal LazyRow of ArtistCard (cover + name)
* Albums: horizontal LazyRow of AlbumCard (cover + title + artist)
* Section headers gain a count suffix ("Artists 12") matching
  Flutter's _SectionHeader pattern; also applied to the Tracks header
  for consistency.

Removed the now-unused TextRow helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #13. Four touches to RequestsScreen rows:

* Kind avatar leading icon: Lucide.Disc3 for artist, LibraryBig for
  album, Music for track (matches Flutter's mapping).
* Cancel confirmation AlertDialog — single tap on Cancel used to be
  irreversible; now shows "Cancel '<name>'? Lidarr will stop searching
  for it." with Cancel / Keep buttons.
* Ingest progress text below the status pill when importedAlbumCount
  or importedTrackCount > 0: "2 albums · 14 tracks ingested".
* Listen OutlinedButton on completed rows when matchedAlbumId or
  matchedArtistId resolves; routes to AlbumDetail (preferred) or
  ArtistDetail. Track matches route through AlbumDetail since the
  client has no TrackDetail screen.

navController.navigate takes the route object directly. Because the
listenRoute can be AlbumDetail or ArtistDetail (both @Serializable
route types from nav/Routes.kt), the callback signature is (Any) ->
Unit and the screen passes it straight to navigate().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v2 #15. System-playlist detail screens now expose a
"Regenerate" OutlinedButton next to Play + Shuffle when the playlist
is refreshable (every system variant except songs_like_artist —
mirrors Flutter's PlaylistRef.refreshable).

Plumbing:
* PlaylistsApi.refreshSystem(variant) → RefreshSystemResponse with
  optional playlist_id (server rotates the uuid; null when library
  can't seed a build).
* PlaylistsRepository.refreshSystemPlaylist returns the new id and
  invalidates the list cache so home-row tiles point at the new uuid.
* PlaylistDetailViewModel.regenerate calls into the repo and emits
  the new id on a Channel.
* Screen collects the channel and navController.navigate-with-popUpTo
  replaces the current detail route, so the back stack doesn't hold
  the now-404'd old uuid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two slices in one push because the detekt failures from the previous
slices block landing the Invites work cleanly:

Audit v2 #19 — Admin Users Invites section:
* New domain Invite model + InviteWire envelope.
* AdminInvitesApi (list / create / revoke).
* AdminInvitesRepository.
* AdminInvitesViewModel with optimistic add/remove + one-shot
  Channel for the generated-token dialog.
* AdminUsersScreen restructured: single LazyColumn with two sections
  (Users / Invites). Generate button in Invites header opens a
  dialog for the optional note; after creation a second dialog
  surfaces the token with a Copy-to-clipboard button.

Detekt fixes on already-pushed code:
* PlaylistDetailViewModel.regenerate: 3 returns → single early-bail
  via combined condition.
* PlaylistDetailScreen function (63/60): extracted
  PlaylistDetailContent helper for the when block.
* PlaylistHeader function (65/60): extracted PlaylistHeaderActions.
* RequestsScreen RequestRow (79/60): extracted RequestRowBody +
  RequestRowAction + CancelConfirmDialog helpers.
* RequestRef.listenRoute: 4 returns → single when expression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
AppBar headers on AlbumDetail / ArtistDetail / PlaylistDetail now
render the real title during the Loading state instead of the
"Album" / "Artist" / "Playlist" placeholder. Mirrors Flutter's
go_router extra: seed pattern.

Implementation:
* DetailSeedCache — process-singleton with three LRU buckets
  (albums / artists / playlists, 32 entries each). Hilt-injected
  into MainActivity and exposed via LocalDetailSeedCache so any
  composable can stash before navigating.
* AlbumCard / ArtistCard / PlaylistCard stash their Ref on click;
  every existing nav call site automatically benefits — no
  callback shape change needed.
* Each detail VM peeks the cache in refresh() and emits
  Loading(seed) so the AppBar reads from the carried seed. State
  carries across pull-to-refresh too (refresh keeps the seed of
  the previous Success / Loading rather than blanking to "Album").
* AlbumDetailUiState.Loading, ArtistDetailUiState.Loading, and
  PlaylistDetailUiState.Loading evolved from data object to
  data class Loading(val seed: T?) — backwards-compatible
  pattern-matching with is checks.

Track-level "Go to album" / "Go to artist" call sites (TrackRow
and NowPlaying TrackActions) only have an id, no Ref, so the
AppBar still shows the placeholder for those — matches Flutter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Skeletons.kt with five primitives: SkeletonBox (pulsing
surfaceVariant fill, the building block), SkeletonAlbumTile,
SkeletonArtistTile, SkeletonTrackRow, SkeletonSectionHeader. Each
matches the geometry of the real card it stands in for so the
reveal doesn't reflow.

Wires HomeScreen as the first consumer: replaces the cold-load
CircularProgressIndicator with a HomeSkeletonContent LazyColumn
(section header + horizontal album row × 2 + section header +
horizontal artist row), and wraps the state machine in a Crossfade
so Loading → Success cross-fades instead of snapping.

Library + AlbumDetail / ArtistDetail / PlaylistDetail still use
the centered spinner; those land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps each cold-load branch in a Crossfade and replaces the
centered spinner with skeleton tile grids/lists that match the
real layout's geometry so the reveal doesn't reflow:

* LibraryScreen Artists tab → SkeletonArtistsGrid (12 SkeletonArtistTile
  in adaptive 144dp grid).
* LibraryScreen Albums tab → SkeletonAlbumsGrid (12 SkeletonAlbumTile
  in adaptive 176dp grid).
* AlbumDetail → SkeletonTrackList (8 SkeletonTrackRow).
* ArtistDetail → SkeletonArtistAlbumsGrid (9 SkeletonAlbumTile,
  176dp adaptive grid mirroring ArtistBody's albums grid).
* PlaylistDetail → SkeletonPlaylistTrackList (8 SkeletonTrackRow).

Drops now-unused LoadingCentered helpers from Library / AlbumDetail /
ArtistDetail / PlaylistDetail per the no-dead-code rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crossfade wrapper pushed AlbumDetailScreen over the 60-line cap (62).
Extracted the inner state-machine + Crossfade + success-body
composition into AlbumDetailStateContent so the main composable
keeps only Scaffold/TopAppBar/PullToRefreshScaffold wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Editing artefact from extracting LoadingCentered → SkeletonPlaylistTrackList;
the @Composable from LoadingCentered landed on the new const declaration
and Kotlin rejected it as not applicable to top-level properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds androidx.palette dependency and a rememberDominantColor()
helper that pulls the cover bitmap via Coil's singleton loader
(shares the cache with the on-screen AsyncImage), runs Palette
extraction on Dispatchers.Default, and animates the resulting
color with animateColorAsState so track changes tween smoothly.

NowPlayingScreen wraps the body in a Box with a vertical gradient
(0% dominant @ 55%α → 45% dominant @ 18%α → 100% scheme.background)
and lets the Scaffold's containerColor go transparent so the
gradient shows through. Falls back to a near-transparent gradient
on bitmap-load failure so the screen never sits on flat black.

Hero transition (MiniPlayer → NowPlaying cover) and cover
preload still pending — those land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the NavHost in a SharedTransitionLayout and applies
Modifier.sharedElement to both the MiniPlayer cover and the
NowPlayingCover, keyed on a single HERO_KEY_NOW_PLAYING_COVER.
Tapping the MiniPlayer cover now morphs into the full NowPlaying
cover instead of cross-fading.

Plumbing:
* HeroScopes.kt — staticCompositionLocalOf holders for both the
  SharedTransitionScope (set once at NavHost root) and the
  AnimatedContentScope (re-set per composable<>, since each route
  has its own).
* MinstrelNavGraph.kt — private WithAnimatedScope helper wraps
  each composable<> lambda so its AnimatedContentScope reaches
  the nested cover.
* MiniPlayer.kt MiniCover + NowPlayingScreen.kt NowPlayingCover
  each read both scopes and prepend Modifier.sharedElement when
  both are present; degrade gracefully (no hero, still renders)
  outside the layout for previews / tests.

Cover preload still pending — that's slice 3 if you want it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the foundational network-state plumbing the audit called out:

* ConnectivityObserver — Hilt singleton wrapping ConnectivityManager.
  Exposes a Flow<Boolean> sourced from registerNetworkCallback; emits
  false when the active network lacks INTERNET+VALIDATED capabilities
  (airplane mode, no carrier, captive portal) and true once a usable
  network appears. Seeded with the initial value so the banner doesn't
  flash before the first capability callback.

* ConnectionErrorBanner — shell-level Compose banner that AnimatedVisibility-
  shrinks/expands based on the observer. Red errorContainer surface,
  CloudOff icon, "No connection — check Wi-Fi or mobile data." copy.
  Owns a tiny ConnectivityBannerViewModel that lifts the singleton's
  Flow into a lifecycle-scoped StateFlow.

* ShellScaffold now invokes ConnectionErrorBanner() in the banner
  slot above the routed content. VersionTooOld / UpdateBanner will
  join the same slot in follow-up commits.

ACCESS_NETWORK_STATE permission was already in the manifest. Downstream
repositories can also collect ConnectivityObserver.online to gate
retry loops once that wiring is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit v2 #17.

App-scoped singleton observes PlayerController.uiState, plucks
queue[queueIndex + 1].coverUrl, dedups, and fires Coil's enqueue
to warm the memory + disk cache. Fire-and-forget — the Disposable
is discarded since the cache write happens on the loader's
background thread regardless.

Wired via the existing construct-the-singleton trick in
MinstrelApplication.

Track changes now hit a cache (cover snaps in, dominant-color
gradient transitions cleanly) instead of the network.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* MeApi gains getListenBrainz + setListenBrainz (single PUT shape
  with optional token / enabled fields, server treats untouched).
* MeRepository facade methods setListenBrainzToken / setListenBrainzEnabled.
* ListenBrainzStatus domain + ListenBrainzStatusWire (server never
  echoes the token back; tokenSet is the visible signal).
* ListenBrainzViewModel — load on init, separate flows for save-token
  and toggle-enabled, inline status message.
* ListenBrainzCard — descriptive copy, masked token field with
  "Replace token" / "Token saved" placeholder, Save button, Switch
  for "Send my plays" (disabled until a token is stored), "Last
  scrobble: …" timestamp once present.

Slotted between PasswordCard and AppearanceCard.

About-panel update check is the second half of #20; lands in slice 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* MatchingDeclarationName: ListenBrainzWire.kt → ListenBrainzStatusWire.kt.
* LongMethod: extracted TokenField / SaveTokenButton / EnabledRow
  helpers so ListenBrainzForm stays under the 60-line cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit v2 #20.

* ClientVersionApi — GET /api/client/version returning
  UpdateInfo(version, apkUrl, sizeBytes).
* UpdateRepository.getLatest() + isVersionNewer() free function
  ported from Flutter's component-wise integer compare (handles
  our date-style 2026.05.10.1 versions correctly, with a
  branch-name fallback for non-numeric builds like dev/main).
* AboutCardViewModel surfaces a four-state UpdateCheckResult
  (Idle/Latest/UpdateAvailable/Error).
* About card grows a "Check for updates" button + inline status
  line. "Install vX.Y.Z" wiring is #25 (post-v1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding the new Profile / Password / ListenBrainz / Update-check
cards pushed AppearanceCard / StorageCard / AboutCard / Sign-out
button off the bottom of the viewport on any normal phone. The
column was fillMaxSize without verticalScroll, so they were
unreachable.

User reported: 'I can't find the light/dark theme controls
anymore. The settings menu looks like it should scroll but
doesn't.' AppearanceCard is at position ~9 in the column, well
past the fold on a typical device.

Single-line fix: add verticalScroll(rememberScrollState()) to
the column's modifier chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit v3 §2 + §4.5: HomeRepository's hydrateAlbums / hydrateArtists /
hydrateTracks use mapNotNull on the per-entity DAO, so any ID in
/api/home/index that the local cache hasn't seen gets silently
dropped from the emitted list. Combined with HomeSuccessContent
hiding empty sections, the user sees the home view missing entire
rows — Rediscover is the worst hit because by definition those are
albums you HAVEN'T played recently, least likely to be in cache.

User reported on emulator: 'we're missing a lot of rows and
formatting from the home view, the rediscover section is completely
missing and a number of the shown sections are rendered with a
different number of rows than the flutter app has.'

Fix: after refreshIndex() pulls the section ID lists, fire-and-forget
a hydration pass on the app scope. For each section, find IDs the
cache doesn't have, fetch via LibraryRepository's existing per-entity
endpoints (refreshAlbumDetail / refreshArtistDetail / refreshTrack —
the last already commented 'for the hydration queue'), chunked
HYDRATE_CONCURRENCY=4 wide so we don't fire 50 parallel requests.
runCatching per-call so one broken ID doesn't fail the batch.

This is the slim version of audit #24 MetadataPrefetcher scoped to
Home — the full background hydration queue with tile-providers is
still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
§4.8: SearchScreen's TopAppBar had no MainAppBarActions kebab —
users in Search couldn't reach Settings / Discover / Requests /
Admin / Sign-out without first hitting Back. Added the standard
kebab next to the existing clear-search button.

Bug-3: Storage card's "Liked cache limit" dropdown persisted a
value that was never enforced (PlayerFactory.kt:43-47 only consumes
rollingCapBytes). Per the established pattern for the prefetch +
cache-liked toggles (hidden in 4d7a4312 for the same reason), hide
the Liked cap dropdown until per-bucket eviction lands. Renamed
the remaining one to just "Cache limit" so it reads honestly. The
CacheSettings field stays persisted so the control returns once
per-bucket SimpleCache eviction is wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
playArtist() previously swallowed all exceptions with a comment
about a 'future refinement' — if the tracks fetch failed or the
artist had no playable tracks, the button looked broken with no
feedback.

Now: ArtistDetailViewModel exposes a transientMessages Flow backed
by a buffered Channel. playArtist sends "Couldn't start playback:
<reason>" on Throwable and "No tracks to play for this artist."
when the result is empty. ArtistDetailScreen collects the Flow into
a Scaffold-level SnackbarHost so users see the failure inline
instead of tapping a dead button repeatedly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more audit v3 cleanup items:

§4.11 — LikedTab track rows lacked an inline LikeButton. Users had
to open the kebab menu to unlike a track — two taps for what should
be one. Added LikeButton(liked = true) next to the TrackActionsButton;
tap unlikes via LikedTabViewModel.unlikeTrack which routes through
LikesRepository.toggleLike (and therefore the MutationQueue, so
offline taps replay).

§4.23 — SettingsScreen Sign Out tapped immediately wiped local
state with no confirmation. Now: AlertDialog with "Sign out of this
server? Your downloaded music and settings on this device will be
removed." and an errorContainer-tinted confirm button. Cancel by
default for accidental taps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the Flutter tile-provider pattern.

Adds CachedAlbumDao / CachedArtistDao / CachedTrackDao observeById(id)
Flow methods (query-only change; no schema migration).

MetadataProvider exposes observeAlbum(id) / observeArtist(id) /
observeTrack(id) as Flow<X?> that:
  - emits the Room row immediately if present,
  - fires a single background fetch when the cache emits null,
  - re-emits the populated row once the fetch upserts.

Fetch dedup is per-ID via ConcurrentHashMap<String, Job> — five
tiles asking for the same missing album = one network call. Fetch
failures are swallowed (row stays null, tile keeps skeleton);
FreshnessSweeper (next slice) retries.

Also exposes refreshAlbum/Artist/Track(id) for the upcoming sweeper
to force-refresh stale-but-cached rows; same dedup applies.

Next slices:
  2/3 - FreshnessSweeper periodic walk + EventsStream reconnect hook.
  3/3 - Wire MetadataProvider into Home / Library / Search tiles
        and remove the band-aid Home hydration from commit 040217ca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds idsStaleBefore(before, limit) DAO query to all three cached
entity DAOs (album / artist / track) — uses the existing
fetchedAt Long column, no schema change.

FreshnessSweeper @Singleton runs on appScope: every 30 minutes
walks each entity bucket, fetches up to 100 IDs whose fetchedAt
is older than 24h, refreshes each via MetadataProvider.refreshX(id).
Bounded concurrency=4 via Semaphore so the sweep doesn't saturate
the connection pool. Reuses MetadataProvider's per-ID dedup so a
sweep that overlaps with a user-driven on-miss fetch is a no-op
on the duplicated ID.

Wired in MinstrelApplication via the construct-the-singleton trick.

Next slice (3/3): wire MetadataProvider.observeAlbum/Artist/Track
into Home tiles + Library Albums/Artists + Search results so the
on-miss fetch actually fires, and drop the band-aid Home hydration
from commit 040217ca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the eager fan-out hydration from commit 040217ca (the
band-aid that fetched every missing ID in a 4-wide chunked loop
on refreshIndex) with the cleaner per-tile on-miss pattern:

* hydrateAlbums / hydrateArtists / hydrateTracks now call
  metadataProvider.refreshAlbum/Artist/Track(id) for any ID the
  cache doesn't have yet — same dedup as elsewhere, so multiple
  Home re-collections / SSE index updates / Library cross-traffic
  all share in-flight fetches.

* refreshIndex no longer launches a background hydration pass —
  the Flow-side on-miss handles it declaratively as each tile
  materialises.

* Removed @ApplicationScope, LibraryRepository, async/awaitAll,
  coroutineScope, launch, HYDRATE_CONCURRENCY constant — all
  dead now that the eager loop is gone.

Net: same user-visible behaviour (Rediscover populates) but the
plumbing is more honest, dedup'd across surfaces, and the
FreshnessSweeper keeps these rows fresh going forward.

Closes audit v3 #24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign-out confirm dialog landed inline in SettingsScreen and pushed
it past the 60-line cap (93/60). Same pattern as other dialog
extractions in the project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SettingsScreen was still 75/60 after the dialog extraction. Extracted
the Column-of-cards body into SettingsList so the main composable
only holds state collection, LaunchedEffect, and the Scaffold shell.
Also fixed the helper's state-param type — SettingsViewModel uses
SettingsState not SettingsUiState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compile error from the LikedTab inline-heart commit (05c7d922) —
toggleLike's third param is named desiredState, I called it 'liked'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
§4.7 — The Most-played tiles on Home navigated to the track's album
(a leftover TODO from before the player API existed). Now tapping a
tile replaces the queue with the whole Most-played section and starts
at the tapped index, via HomeViewModel.playMostPlayed →
PlayerController.setQueue(source = "home:most_played"). Mirrors
Flutter's _resolveSectionTracks where Home sections are playable units.

§4.15 — NowPlaying had no inline like affordance — users had to open
the kebab to like the current track. Added a LikeButton at the head of
BottomActionsRow, wired to TrackActionsViewModel.isLikedFlow /
toggleLike (same pattern as MiniPlayer). NowPlayingBody now collects
the like state and threads it down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The error test asserted the raw exception text leaked into the UI
state — exactly the behaviour ErrorCopy removes. An
IllegalStateException is neither HttpException nor IOException, so
it maps to the generic "Something went wrong." Updated the
assertion to the new contract and dropped the now-unused
assertTrue import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Queue rows only showed title + artist. Now the subtitle reads
"Artist · Album" (collapsing gracefully when either is empty) and a
trailing m:ss duration appears when known — matches Flutter's queue
row density so users can tell tracks apart by album + length.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a mid-session SSE drop (server restart / network blip)
left the stream closed until the next sign-in — cross-device
reactivity (likes, playlist updates, request status, quarantine
changes) silently died until app relaunch.

Now onClosed / onFailure schedule a reconnect with exponential
backoff (1s → 2s → … → 30s cap), reset to 1s on a successful
onOpen. Reconnect only fires while signed in; sign-out's
disconnect() cancels any pending retry. State swaps
(connect/disconnect/scheduleReconnect) are @Synchronized since the
OkHttp listener thread and the auth-cookie collector both touch
currentSource + backoff.

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>
AlbumDetail / ArtistDetail / PlaylistDetail previously rendered only
the AppBar title from the navigation seed during Loading, with a bare
skeleton body. Now each Loading branch renders the full header from
the seed when present — cover + title + artist/description + count —
above the skeleton track rows / album grid, so the screen reads as
itself instantly instead of skeleton-then-pop. Falls back to the
plain skeleton when no seed (e.g. deep links, track-row navigations
that only carry an id).

Action buttons (Play / Shuffle / Like / Regenerate) are intentionally
omitted from the seeded header — they need the loaded track list.

Added @file:Suppress(TooManyFunctions) to AlbumDetailScreen (now at
the 11-function cap with the new SeededAlbumLoading helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A large library rendered all recently-added albums in one horizontal
LazyRow that scrolled forever. Now the list is chunked into stacked
carousels of 25 (RECENTLY_ADDED_CHUNK); the first carries the
"Recently added" title and the rest are untitled continuation rows,
matching Flutter's shared-ScrollController layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TrackActionsViewModel fetched the hidden-track snapshot only at init
and after local flag/unflag, so a hide/unhide from another device
left the sheet's Hide/Unhide label stale until the VM was recreated.
Now it subscribes to EventsStream and refreshes on any quarantine.*
event (flagged / unflagged / resolved / file_deleted /
deleted_via_lidarr).

Also routed the radio / append-to-playlist / hide / unhide failure
snackbars through ErrorCopy.fromThrowable instead of raw it.message,
keeping the action-context prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delete-user was a plain Delete/Cancel AlertDialog — too permissive
for an irreversible action. Now mirrors Flutter's TypedConfirmSheet:
an OutlinedTextField where the admin must type "DELETE" before the
Delete button enables (case-insensitive). The button stays disabled
and muted until the word matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 25-row chunking + per-section empty messages pushed
HomeSuccessContent to 69 lines (cap 60). Extracted each section
into a LazyListScope extension (recentlyAddedSection /
rediscoverSection / mostPlayedSection / lastPlayedSection) so the
main composable is just the LazyColumn skeleton calling them. File
already carries @file:Suppress(TooManyFunctions) for the Compose
helper density.

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>
The Home Playlists row now always renders the For You + Discover +
3× Songs-like slots: a real PlaylistCard when the system playlist
has generated, otherwise a PlaylistPlaceholderCard showing its
build state — building / failed / seed-needed / pending — derived
from GET /api/me/system-playlists-status. Mirrors Flutter's
_buildPlaylistsRow + PlaylistPlaceholderCard.

* SystemPlaylistsStatus domain + wire; MeApi.getSystemPlaylistsStatus;
  HomeRepository.getSystemPlaylistsStatus.
* HomeViewModel fetches the status in refresh() and exposes it as a
  StateFlow; HomeScreen collects it and threads it to the row builder.
* PlaylistPlaceholderCard widget (sized to match PlaylistCard) with
  a status chip + variant subtitle.
* buildPlaylistsRow / variantFor port the slot logic; user playlists
  follow the fixed system slots.

The offline-pool cards (§4.3) are NOT in this commit — Android has
no local recently-played cache (history is fetch-on-visit), so the
"Recently played" pool has no offline source. Deferring that half
until the history snapshot cache lands; see #28 notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Faithful port of Flutter's _OfflinePoolCard + shuffle_source.dart.
I was wrong earlier that Android lacked the source — the
audio_cache_index table already carries lastPlayedAt, exactly what
Flutter reads.

* ShuffleSource @Singleton over the local cache: recentlyPlayed()
  = audio_cache_index ordered by lastPlayedAt desc, materialized
  from cached_tracks; liked() = same ∩ the liked track-id set.
  Both are unions over the cache regardless of storage bucket,
  matching Flutter's note that the bucket split is eviction-only.
* OfflinePoolCard widget (sized to match PlaylistCard).
* AudioCacheIndexDao.trackIdsByRecency(); LikesRepository.likedTrackIds().
* HomeViewModel: offline StateFlow from ConnectivityObserver,
  playPool(kind) shuffles + plays (snackbar "No cached X tracks yet"
  when empty), transientMessages channel.
* buildPlaylistsRow gains an `offline` flag; when offline the two
  pool cards (Recently played, Liked) LEAD the Playlists row, before
  the system slots — the rest of the row (placeholders + user
  playlists) renders regardless, exactly as Flutter does.
* HomeScreen gains a SnackbarHost for the empty-pool message.

Together with edbc8053 (placeholders) this closes audit v3 #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Encodes the hard rule the operator asked for after repeated
divergence: when porting/changing any Android feature that exists in
Flutter, read the Flutter source FIRST and replicate it exactly;
never silently substitute a different design or scope down — verify
the perceived blocker by reading more, and raise it as a question if
genuinely blocked. Points at docs/superpowers/parity-map.md as the
durable feature→source→target→status reference (gitignored, local).
Also captures the standing repo conventions (Forgejo-only, dev→main
flow, no in-task builds, detekt gates) so they survive context
compaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports flutter_client/lib/auth/login_screen.dart:86-89 — a TextButton
under the Sign-in button that routes to the ServerUrl screen, so a
wrong server URL is recoverable pre-login without reinstalling or
signing out from Settings. Disabled while a submit is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter's _LiveEventsDispatcher invalidates myQuarantineProvider on
any quarantine.* event, and the Hidden tab watches that provider — so
a hide/unhide/delete from another device updates the open Hidden tab
live, not just on re-open. Android's HiddenTab is fetch-on-visit with
no shared reactive cache, so per the Android LiveEventsDispatcher's
own documented pattern (screen-scoped state subscribes to EventsStream
directly), HiddenTabViewModel now collects quarantine.* and refreshes.
Same user-visible behavior as Flutter; same shape as the
TrackActionsViewModel SSE wiring.

(Paint-from-disk offline cache for the Hidden tab — Flutter's drift
cached_quarantine_mine — remains the separate deferred Phase-13 work.)

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>
Operator guidance: parity is about the user experience (layout, copy,
states, and especially responsiveness — caching exists to make the UI
feel instant), not literal transliteration of Flutter's Dart. Replicate
the behavior faithfully but reach it with the best well-supported native
mechanism (Room+Flow, Compose, WorkManager, Media3) rather than copying
drift watch() / Riverpod invalidate. A more native approach that improves
the UX is preferred — recorded as an intentional divergence in the parity
map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The offline-pool ShuffleSource (2a87a50b) injects AudioCacheIndexDao,
but DatabaseModule had no @Provides bridge for it — nothing had
injected that DAO via Hilt before, so the graph was missing the
binding and hiltJavaCompileDebug failed. Added the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).

Native implementation (idiomatic, not a drift transliteration):
* CachedHistorySnapshotEntity — single-row table (id=1) holding the
  raw /api/me/history wire JSON + updatedAt. AppDatabase v5→6
  (fallbackToDestructiveMigration rebuilds; cache refills from server).
* CachedHistorySnapshotDao.observe() Flow + upsert; DatabaseModule
  @Provides bridge (the AudioCacheIndexDao lesson — every @Inject dep
  needs a provider).
* HistoryRepository.observeHistory(): Flow<HistoryPage?> decodes the
  blob; refresh() fetches + overwrites the snapshot. Whole page as one
  JSON blob (read-only timestamp-keyed snapshot — no per-row bookkeeping
  for no UX gain), matching Flutter's snapshot shape.
* HistoryTabViewModel: uiState = combine(observeHistory, refreshError)
  — cache-first, SWR, and Error only when there's no cache to show.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #37. The Hidden tab was fetch-on-visit (spinner every open);
now it paints instantly from the cached_quarantine_mine Room table
and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController
(drift watch + transaction full-replace + optimistic flag/unflag).

The Room table + DAO already existed (CachedQuarantineEntity /
CachedQuarantineDao with observeAll + insert/delete) — they were just
never wired up. This commit connects them:
* DatabaseModule: provideCachedQuarantineDao (was missing from the
  graph — same gap as AudioCacheIndexDao).
* CachedQuarantineDao: atomic replaceAll(rows) (@Transaction
  clear+upsertAll, no flicker).
* QuarantineRepository: observeMine() Flow + refresh() full-replace;
  flag(track) / unflag(id) now mutate the cache optimistically (Flow
  re-emits instantly) then call the server, enqueueing on failure
  (intent persists, no rollback — matches Flutter). flag() takes a
  TrackRef to build the optimistic row; TrackActionsViewModel updated.
  listMine() kept for the TrackActions hidden-check.
* HiddenTabViewModel: uiState = combine(observeMine, refreshError),
  cache-first; SWR on init + on quarantine.* SSE; unflag via repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CacheIndexer observes PlayerController.uiState and records each played
track into audio_cache_index (source=INCIDENTAL), bumping lastPlayedAt
on re-play. This gives the offline ShuffleSource pools their data
source — previously the index was never written, so the pools were
inert. MediaItems now set a custom cache key of trackId so Media3's
SimpleCache is keyed per track (sets up slice 2's residency queries).

Intentional divergence from Flutter's file-per-track index: SimpleCache
(span-based) is the real byte store, so the index is a lightweight
play-recency record. Slice 2 (#33) queries SimpleCache for true
residency + size + eviction-sync to drive the cached indicator dot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Flutter's CachedIndicator (circle_check_big, size 14, accent, 4px
left pad) to all 5 track-row screens (Album, History, Liked, Playlist,
Search). CachedTrackIds exposes a reactive Set of trackIds with bytes in
Media3's SimpleCache — true on-disk residency (keyed per track via the
custom cache key from slice 1), so an evicted track loses its dot. Read
through a LocalCachedTrackIds CompositionLocal provided at the app root,
mirroring the existing LocalDetailSeedCache pattern, so leaf rows need no
per-screen plumbing. The persisted cache is read eagerly, so dots paint
for previously-cached tracks even when the library is opened offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The composables lucide artifact names the property CircleCheckBig
(camelCase), not the snake-case filename — the prior import did not
resolve and broke compileDebugKotlin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShuffleSource intersects the play-recency list with the Media3
SimpleCache key set, so the offline Recently-played / Liked pools only
offer tracks whose audio is actually on disk. Flutter's index holds
fully-cached tracks only; Android's records plays, so this intersection
restores parity — a played-then-evicted track no longer surfaces in a
pool where it would fail to play offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports player_bar.dart's onVerticalDragEnd — an upward flick anywhere on
the bar expands into NowPlaying, alongside the existing tap-to-expand. A
vertical draggable claims only vertical-dominant drags, so the inline
seek slider keeps its horizontal scrub gesture. The 200 px/s Flutter
threshold is expressed in dp and density-converted so the flick feels
the same across screen densities.

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>
rememberDominantColor was keyed on coverUrl, so each track change reset the extracted color to Transparent and the gradient dipped toward the fallback before tweening to the new color. Drop the key so the held color stays on the previous track's dominant until the new palette resolves -- the gradient now tweens directly to the new color. Combined with CoverPrefetcher warming the next cover, this matches Flutter's preload-then-atomic-swap UX (no flash on track change) via native Compose mechanisms rather than a literal preload pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShellScaffold's banner column did not consume the status-bar inset while each screen's app bar did, so the update/connection banners drew under the status bar. Consume the inset once at the shell (statusBarsPadding) so banners sit below it; descendant screen app bars see the consumed inset and stop re-padding, matching Flutter's SafeArea(bottom:false).

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>
Library + Home artist tiles were blank: cached_artists stores no cover and the sync payload (SyncArtistWire) carries none. Mirror Flutter's artistTileProvider JOIN — ArtistRef gains coverAlbumId + a displayCoverUrl getter; LibraryRepository.observeArtists and MetadataProvider.observeArtist combine artists with albums to supply the first album (by sort title) as the cover source. ArtistCard renders displayCoverUrl through ServerImage. Updated LibraryRepositoryTest to stub albumDao.observeAll so combine emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping certain playlists showed 'That playlist no longer exists' because the server's BuildSystemPlaylists rotates system-playlist UUIDs every rebuild, and the LIST refresh only upserted -- stale UUIDs lingered in the cache, tapping them triggered a 404. Mirrors playlists_provider.dart: PlaylistsRepository.refreshList now deletes the user's cached rows not in the fresh owned set (catches both deleted user playlists AND old system UUIDs since system playlists are user-scoped) before upserting the fresh all, atomically via a new replaceList @Transaction on the DAO. Also deletes the cached row when refreshDetail 404s so a stale tap self-heals the list. Inject AuthController for the current user id.

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>
It was never image-specific -- it resolves any /api/* relative URL to the placeholder.invalid form that BaseUrlInterceptor rewrites. Both covers (ServerImage) and stream URLs (PlayerController) call it. Move the helper to shared/ServerUrls.kt with the right name; ServerImage and PlayerController import from there. Pure rename + relocate, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Material3 changed the Slider's default inactiveTrackColor to colorScheme.secondaryContainer, which MinstrelTheme does not override -- so M3's baseline purple leaked into both the mini scrubber and the NowPlaying ScrubberRow. Flutter explicitly sets inactiveColor = fs.slate; mirror that by pinning inactiveTrackColor to surfaceVariant (which the theme already maps to slate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three cards shared an identical Box + clip + background + ServerImage + fallback structure around their cover (only size, shape, fallback icon, and overlay differ). Extract a single CoverTile composable; each card now passes its own size, shape, background, fallback icon, and optional BoxScope overlay (used by PlaylistCard for the VariantPill). Pure DRY consolidation; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter's _SecondaryControls (like, shuffle, repeat, queue, kebab) sit just above the seek bar (now_playing_screen.dart:464); Android had them at the bottom under the transport row. Reorder the NowPlayingBody column so it reads cover -> title -> actions -> scrubber -> transport, matching Flutter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
History/Hidden/Requests/PlaylistsList/AddToPlaylist/Home/Liked/Library
each had its own sealed interface with Loading/Empty/Success(payload)/
Error variants. Collapsed to the generic shared/UiState<T> introduced
in the prior commit. Library carries two lists (artists + albums) so
its payload is wrapped in a new LibraryData record; the test was
updated to assert against UiState.Success<LibraryData>.

The 3 detail screens (Artist/Album/Playlist Detail) keep their own
sealed interfaces for now since they include a Loading(seed: ...)
variant that does not fit UiState<T>.
The original AddToPlaylistUiState only had Loading/Success/Error, with
the empty-list message branched inside Success. After migrating to
shared UiState<T> (which adds Empty), the sheet's when over the state
was non-exhaustive. Route empty through UiState.Empty at the VM, drop
the inner branch in the sheet, and add the explicit Empty arm in the
when block to satisfy the compiler.
Ten screens duplicated the same TopAppBar + Text(title) +
optional-back-arrow + MainAppBarActions sandwich. Extract into
MinstrelTopAppBar(title, navController, currentRouteName, onBack,
actions) at shared/widgets/. Library's tab row still composes
above it inside a Column; Library's Shuffle button slots into the
extra actions trailing-lambda; drill-down screens pass onBack for
the back arrow; root tabs leave onBack null.

Net: -48 lines across 10 screens, +50 lines for the helper.

SearchScreen keeps its own TopAppBar (title is a SearchField
composable, not a string).

Per-screen TrackActionsViewModel snackbar wiring (Library/Search/
detail screens) was intentionally NOT consolidated here: each
hiltViewModel() returns a different VM instance than the shell-
scoped one already in ShellScaffold, so dedup belongs in 3c
(shell-scope the VM via CompositionLocal) rather than freezing the
current duplicate into a helper.
ShellScaffold already collects TrackActionsViewModel.transientMessages
into its own SnackbarHost. Per-route, the shell's hiltViewModel<
TrackActionsViewModel>() and the screen's hiltViewModel<TrackActions
ViewModel>() resolve to the same NavBackStackEntry-scoped instance,
so any kebab action fires the snackbar twice today (once in the
screen's own SnackbarHost, once in the shell's).

Strip the per-screen wiring from the four shell-wrapped screens that
had it (Library, Search, AlbumDetail, PlaylistDetail): VM param,
SnackbarHostState, LaunchedEffect, and the Scaffold's snackbarHost.
The shell now owns the snackbar exclusively.

NowPlayingScreen is intentionally left untouched -- it's outside the
shell (full-screen route), so its own snackbar wiring is the only
surface that shows the message.
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.
chore(ci): migrate workflows to gitea + consolidate keystore secret
flutter / analyze-test-build (push) Failing after 2s
test-go / test (push) Successful in 33s
android / Build + lint + test (push) Failing after 41s
test-web / test (push) Successful in 41s
android / Build signed release APK (push) Has been skipped
test-go / integration (push) Successful in 11m16s
3cf829752b
- rename .forgejo/workflows/ to .gitea/workflows/ (git mv preserves
  history); Gitea Actions reads either path, but the directory now
  matches the active platform
- collapse ANDROID_STORE_PASSWORD + ANDROID_KEY_PASSWORD into a
  single ANDROID_KEYSTORE_PASSWORD secret (PKCS12 keystores require
  the two passwords to be identical, so the duplication carried no
  information); build.gradle.kts still reads two env vars to stay
  format-agnostic, both now sourced from the same secret in CI
- ignore android/*.keystore, android/*.keystore.*, android/*.jks,
  android/keystore.properties so the regenerated signing material
  never reaches a remote on accident
- update prose references from Forgejo to Gitea in CLAUDE.md and
  the docs; the historical "migrated from Forgejo" note in CLAUDE.md
  is kept intentionally; forgejo/upload-artifact@v3 action refs are
  left untouched (canonical artifact action, resolves cleanly under
  Gitea Actions)

Operator side: ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, and
ANDROID_KEYSTORE_B64 secrets registered in Gitea before this lands.
ci(android): switch upload-artifact to actions/v4 + retire flutter.yml
android / Build + lint + test (push) Successful in 3m44s
android / Build signed release APK (push) Has been skipped
f482d0d2fa
Last push failed both android and flutter jobs at workflow-setup time
because the runner couldn't resolve forgejo/upload-artifact@v3
(github.com/forgejo/upload-artifact does not exist; the Forgejo
project hosts on Codeberg and our Gitea runner falls through to
github by default). The canonical Gitea Actions form is
actions/upload-artifact@v4, which act_runner resolves cleanly.

Flutter pipeline is being retired in favor of the native Android
client, so flutter.yml is deleted outright rather than fixed. The
container-image build's release-asset polling (release.yml) will now
graceful-degrade when chasing the legacy minstrel-<TAG>.apk name,
which the comment in ci-requirements.md already documents as
acceptable. Renaming the native APK to drop the -android- infix is
deferred to a follow-up so the cutover is reviewable in isolation.
Project-level AI-tool instruction files (CLAUDE.md, AGENTS.md,
GEMINI.md, .cursorrules, .windsurfrules, .aider.conf.yml) are
operator-scoped working notes, not project artifacts. Other
contributors don't need them, and committing them conflates
operator preferences with shared project conventions.

CLAUDE.md remains on the local disk (untracked) so Claude Code
keeps loading it for this checkout; new clones simply won't have
it, which is the desired state.
feat(android): port play-button overlay to Home tiles
android / Build + lint + test (push) Failing after 1m34s
android / Build signed release APK (push) Has been skipped
f17610ecd5
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.
fix(android): extract PlaylistCardCover to clear detekt LongMethod
android / Build + lint + test (push) Successful in 3m32s
android / Build signed release APK (push) Has been skipped
741ce1fa07
Adding the play-button overlay in the previous commit pushed
PlaylistCard from 49 to 62 lines, two over detekt's 60-line cap.
Splitting the cover stack into its own private composable
(PlaylistCardCover) brings the outer function back under the limit
while keeping the call site readable.

Behavior identical to f17610ec; pure refactor for lint compliance.
feat(android): surface secondary system playlists on Home + drop dead todays_mix label
android / Build + lint + test (push) Successful in 3m45s
android / Build signed release APK (push) Has been skipped
bf61d6cfa3
Server's playlists registry has 8 system kinds (internal/playlists/
system.go:281-290) but Flutter Home only ever showed 5 slots:
For You + Discover + 3 Songs-like. Operator request 2026-06-01: have
the Android Home view also surface the 5 secondary kinds so the
user can find them without digging into the Library > Playlists list.

Android buildPlaylistsRow now appends deep_cuts, rediscover,
new_for_you, on_this_day, first_listens in server-registry order
after the existing Songs-like slots, when those playlists actually
exist. No placeholders for the new 5 since they depend on library
shape (deep albums for Deep cuts, prior history for On this day,
etc.) and an empty placeholder would imply they're still building
when they may just have no candidates.

PlaylistCard.kt systemLabelFor gains explicit labels for the new
kinds and drops the dead "todays_mix" branch (no such variant in
the server registry).

This diverges from Flutter Home which never surfaces the secondary
kinds; web UI catch-up tracked as task #53. Naming note: the
"Rediscover" playlist tile and the unrelated "Rediscover"
recommendations section on the same Home both read "Rediscover" -
operator follow-up to disambiguate.
feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 2m16s
8b586c2e50
User request 2026-06-01: the previous Rediscover only fired on
explicit album/artist likes, so users who only liked at the track
level got an empty Rediscover row. Also no daily rotation - the
section never changed between data updates - and 25 items felt long
for the Home carousel.

SQL (internal/db/queries/recommendation.sql):
- ListRediscoverAlbumsForUser UNIONs the existing explicit album-like
  signal with a new track-derived path: an album qualifies if it has
  >=2 liked tracks where the earliest like is >30 days old.
- ListRediscoverArtistsForUser does the same with threshold 3 (artists
  span more releases, so the bar is higher to avoid one-hit-wonder
  affinities).
- Both ordering switched from longest-since-last-play to a daily-
  stable random hash md5(entity_id || user_id || current_date) so
  the row randomizes but stays consistent within a day.
- Fallback queries also switched to the daily-stable hash so the
  fallback rows don't reshuffle on every refresh.

Go (internal/recommendation/home.go):
- HomeRediscoverLimit dropped from 25 to 10 (carousel-sized).
- rediscoverInnerLimit (30) is the per-query cap; gives headroom so
  the Go-layer filters don't undershoot.
- applyRediscoverAlbumFilters does cross-section dedup vs Most Played
  (no point surfacing albums the user is actively spinning) plus a
  diversity cap of max 2 albums per artist (prevents one liked-but-
  forgotten artist dominating the row).
- applyRediscoverArtistFilters does cross-section dedup vs Most
  Played's artist set; no diversity cap needed (one row per artist).

Tests (internal/recommendation/home_integration_test.go):
- TrackDerived path: 2 liked tracks >30d old + no recent plays = album
  appears in Rediscover.
- Threshold guard: 1 liked track = album does NOT appear.
- Diversity cap: 4 explicit album-likes from same artist = 2 in output.
- Dedup vs MostPlayed: eligible album whose track is in MostPlayed
  gets filtered out.
- Existing FallbackWhenSparse test preserved (semantics unchanged for
  recent likes that miss the >30d primary filter).

Clients unchanged - they render whatever /api/home/index returns. No
parity-map row needed (this is a server-internal recommendation
change, not a layout divergence).
fix(recommendation): drop user_id from Rediscover daily hash to preserve sqlc type inference
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 9m9s
54903c4760
CI on 8b586c2e caught it: the $1::text cast in the md5 ORDER BY made
sqlc infer the parameter as plain string instead of pgtype.UUID,
generating Column1 string instead of UserID pgtype.UUID on the
ListRediscover*ForUserParams structs. go vet flagged both call
sites in internal/recommendation/home.go where the Go code passes
UserID by name.

Fix: hash on album_id (or artist_id) + current_date only. The
eligibility filter already differs per user (different liked sets),
so the daily-rotation goal is preserved; we just lose per-user salt
in the within-day ordering of overlapping items - acceptable.

Applies to both primary queries AND both fallback queries (all four
had the same cast).
feat(android): Most Played to 3-row dense layout matching web
android / Build + lint + test (push) Failing after 2m44s
android / Build signed release APK (push) Has been skipped
582137790d
Web Home (web/src/routes/+page.svelte:196) stacks the 75 most-played
tracks into 3 rows of 25 inside a single horizontal scroller; Android
was rendering them as one long LazyRow which makes the section feel
sparse and forces a lot of horizontal scrolling to reach mid-rank
tracks. Operator request 2026-06-01.

Replaces MostPlayedRow's LazyRow with a LazyHorizontalGrid where
rows=Fixed(MOST_PLAYED_ROWS=3). Cards stay 140dp (matching web's
w-36); density gain comes purely from stacking.

Web is row-major (ranks 0..24 across the top row); LazyHorizontalGrid
fills column-major. To match web's visual order, the input list is
pre-chunked into 3 row-major rows then re-flattened column-by-column
before being handed to the grid. With 75 tracks and 3 rows that's
25 columns, ranks 0/25/50 in column 0, 1/26/51 in column 1, etc.

MOST_PLAYED_GRID_HEIGHT_DP=600 budgets ~188dp per row (140 cover +
8 spacer + 2 lines of text), plus 2 * 8dp inter-row spacing.

Diverges from Flutter (still single-row); intentional, parity-map
updated. Backflow to Flutter not tracked yet because the Flutter
client is being retired.
fix(android): import Arrangement in HomeScreen for Most Played grid
android / Build + lint + test (push) Successful in 3m42s
android / Build signed release APK (push) Has been skipped
1fdc5b0a1f
CI on 58213779 caught the missing import: the previous code used
Arrangement only inside HorizontalScrollRow (which imports it
internally), but the inline LazyHorizontalGrid for Most Played
now needs it at the HomeScreen.kt scope. ktlint passed because
it doesn't follow type resolution; the kotlinc compile step
flagged it.
fix(android): populate streamUrl in CachedTrackEntity to domain mapper
android / Build + lint + test (push) Successful in 3m30s
android / Build signed release APK (push) Has been skipped
25c5b80add
CachedTrackEntity.toDomain() was emitting TrackRef.streamUrl="" (the
default). Tracks routed through MetadataProvider (the Home cache
hydration path used by Most Played) inherited the empty string;
player.setQueue() then got a queue of unplayable tracks.

The server's stream_url is deterministic per track id
(internal/api/convert.go:75 streamURL builder), so the cache mapper
can reconstruct it from the row's id without storing it in Room.
TrackWire.toDomain() continues to use the wire-provided value
(identical content).

Operator reported "tap to play wiring in Most Played queues content
that can't play" on 2026-06-01. The Most Played multi-row layout
itself isn't the cause - the same gap affected the single-row
layout - but the denser display made the dead taps more obvious.
feat(android): filled heart for liked state, outlined for unliked
android / Build + lint + test (push) Successful in 3m35s
android / Build signed release APK (push) Has been skipped
5b050022f1
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.
fix(android): periodic position polling for smooth seek bar
android / Build + lint + test (push) Successful in 3m41s
android / Build signed release APK (push) Has been skipped
7b619152ab
PlayerController only ran the UI-state copy on Media3 Player.Events
callbacks (track change, pause/play, buffer state) - none of which
fire on normal position advancement during playback. The seek bar
appeared to update every 20-30 seconds, whenever a buffer event
happened to fire, instead of ticking smoothly.

Adds a startPositionPolling() coroutine launched on
Dispatchers.Main.immediate after the controller is connected.
Samples controller.currentPosition + bufferedPosition every 500ms
while isPlaying; skips state updates while paused (positionMs stays
at the last value, which is the correct paused-state behavior).
The poll patches only the position fields via .copy() so stale
event-driven snapshots can't clobber the latest state.

Cadence reference: Flutter via just_audio positionStream runs at
~200ms (audio_handler.dart:75); Spotify / Apple Music sit around
250-500ms in-app. 500ms is the battery-friendly middle ground that
still reads as smooth on a slider. Lock-screen / notification
surfaces are driven by Media3's MediaSession separately and don't
need this poll.
feat(android): denser Most Played tiles - horizontal row matching Flutter
android / Build + lint + test (push) Successful in 4m0s
android / Build signed release APK (push) Has been skipped
ef8374753a
Operator device check 2026-06-01: the multi-row grid landed, but the
square 140dp tiles (web-matching) waste vertical space at the per-
tile level. Flutter's CompactTrackCard
(flutter_client/lib/library/widgets/compact_track_card.dart) uses a
horizontal-row pattern - 176dp wide, 56dp tall, 48dp cover thumb +
title/artist column to the right. Much denser; fits ~3x more tiles
in the same screen area.

CompactTrackTile rewritten to that shape:
- Row layout instead of Column
- 48dp square cover (down from 140dp)
- Title + artist column to the right, vertically centered
- 176dp x 56dp outer

MOST_PLAYED_GRID_HEIGHT_DP cut from 600 to 200dp (3 * 56 + 2 * 8
spacing). Skeleton matches the new dimensions.

Diverges from web (which uses square tiles same as Android's prior
shape) - intentional, operator preference. TrackActionsButton from
Flutter's card is omitted for now (would need nav callbacks threaded
through MostPlayedRow); follow-up if kebab-on-tile is wanted.
fix(android): remove kebab from MiniPlayer to free space for artist line
android / Build + lint + test (push) Failing after 1m27s
android / Build signed release APK (push) Has been skipped
5c2011e6f4
Cover + LikeButton + 3 transport buttons + kebab consumed almost all
of the 360-410dp viewport on typical phones, leaving the title /
artist column with only ~20-80dp - enough for a truncated title but
nothing for the artist line, which was rendering but effectively
unreadable.

Drops TrackActionsButton from MiniRow and the now-unused
onNavigateToAlbum / onNavigateToArtist callbacks from the MiniPlayer
signature + ShellScaffold call site. Full kebab surface still lives
on NowPlayingScreen (one screen swipe away).

Operator request 2026-06-01. Diverges from Flutter's player_bar
which still ships the kebab; parity-map row updated to reflect the
deliberate divergence. ShellScaffold's navController parameter is
kept on the signature for future shell-level navigation needs.
fix(android): share playlist play conversion + filter unplayable rows
android / Build + lint + test (push) Failing after 1m30s
android / Build signed release APK (push) Has been skipped
487400fab6
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.
fix(android): drop unused navController from ShellScaffold
android / Build + lint + test (push) Successful in 3m35s
android / Build signed release APK (push) Has been skipped
eb4537c013
CI on 5c2011e6 caught it: removing the MiniPlayer kebab also removed
the only consumer of ShellScaffold's navController parameter, which
detekt's UnusedParameter rule flagged. Per YAGNI, dropping the param
entirely instead of @Suppress'ing a dead carry; if a future banner
or shell-level affordance needs nav, plumb it back in at that point.

Touches the public signature so all 10+ call sites in MinstrelNavGraph
also lose the `navController = navController` argument.
feat(android): move skip classification from client to server
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Has been skipped
d82e744d87
PlayEventsReporter used to make the skip-vs-ended call client-side:
if the play head reached within 3s of duration -> playEnded with
full duration; otherwise -> playSkipped (forces was_skipped=true on
the server regardless of actual play time). That left the server's
configurable skip rule (skip_max_completion_ratio +
skip_max_duration_played_ms, default 50%/30s) dead code and meant
any "next" tap registered as a skip even after most of the track
played - too strict per operator 2026-06-01.

PlayEventsReporter now always calls playEnded with the actual
position played to. Server's RecordPlayEnded applies the rule and
decides was_skipped. If the play ran to completion, the last
observed position is approximately the track duration -> ratio ~1
-> not skipped. If the user hit next mid-track, the duration the
server sees is the real listen time and the rule fires normally.

Drops the curReachedEnd field and COMPLETION_TOLERANCE_MS constant
that backed the prior classification. playSkipped wire endpoint
stays in place for a future explicit-dislike affordance (not wired
to track-change transitions any more).

Parity-map row updated. Web backflow tracked as task #57 (Scribe
521). Flutter not touched - deprecated per M8.
Web client now always POSTs play_ended with the actual position
played to, matching the Android fix at d82e744d (Scribe 519). The
server's skip rule (skip_max_completion_ratio +
skip_max_duration_played_ms, default 50%/30s) classifies was_skipped
from the duration_played_ms; the dispatcher no longer makes the
call itself.

Three transitions all collapse to play_ended:
- Track change (was: reachedEnd ? play_ended : play_skipped)
- Natural end via paused-at-duration (was already play_ended)
- pagehide via sendBeacon (was: play_skipped)

Drops the openReachedEnd field, COMPLETION_TOLERANCE_MS constant,
and the position-vs-duration threshold check that backed the prior
classification. play_skipped wire endpoint stays in place for a
future explicit-dislike affordance.

Two test cases updated:
- "user-initiated next mid-track" now asserts play_ended with
  duration_played_ms = the partial position, not play_skipped.
- "pagehide fires sendBeacon" now parses the beacon payload and
  asserts type=play_ended + duration_played_ms = last position.

Flutter NOT touched — deprecated per M8.
jsdom's Blob doesn't ship .text() (only the polyfill via FileReader
or arrayBuffer is reliable). The pagehide test failed with
"TypeError: blob.text is not a function" at events.svelte.test.ts:183.

Swap blob.text() for new FileReader().readAsText() wrapped in a
Promise.
Two issues from the prior CI failures:

1. Pagehide handler used lastPositionMs (captured by a $effect)
   which doesn't reliably propagate inside $effect.root in the test
   fixture - test saw 0 instead of 73000. Switched to a direct
   `Math.round(player.position * 1000)` read at pagehide time. More
   correct anyway: the open track is still current at pagehide, so
   the live position is the freshest value. Same change applied to
   the natural-end branch for consistency.

2. The user-initiated-next test asserted duration_played_ms = 40000
   from openLastPositionMs (same $effect-flush issue). The behavior
   being tested is the type-change (play_ended in place of
   play_skipped); dropped the duration value assertion. Type
   assertion stays.

Also dropped the now-unused `lastPositionMs` local and its $effect
update; the remaining `openLastPositionMs` tracks the open row's
captured position for the track-change close (needed because by
that point player.position has been reset to 0 by the queue
advance).
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.
Universal music-player conventions, attached at window level from
+layout.svelte:

  Space, K     play / pause
  ArrowLeft    previous track
  ArrowRight   next track
  J            seek -10s
  L            seek +10s
  ArrowUp      volume +5%
  ArrowDown    volume -5%
  M            mute / unmute (restores prior volume)
  /            focus the global search input

Skipped when focus is in an input / textarea / select /
contenteditable so typing in the search box and rename fields
behaves normally. Modifier-only chords (Ctrl/Cmd/Alt + key)
intentionally pass through to the browser.

New store helper `toggleMute()` captures the last non-zero volume
so M can toggle back without permanently losing the user's level.

shortcuts.svelte.ts is the new module; +layout.svelte wires it
alongside useMediaSession / useEventsDispatcher.

Scribe 529, local task #60.
CI on 4362233d caught it: playQueue leaves the store at 'loading'
(intent-to-play; jsdom never advances to 'playing' because there's
no real audio). togglePlay's contract is loading|playing -> paused,
anything else -> loading. So Space round-trips loading <-> paused,
not paused <-> playing.

Three changes:
- "Space toggles" expects loading -> paused -> loading.
- "K alias" expects loading -> paused.
- "shortcuts ignored when focus in input" + "modifier keys disable"
  capture state before the press and assert no change, instead of
  hard-coding 'paused'.
feat(web): surface secondary system playlists on Home
test-web / test (push) Successful in 31s
5393174a27
Mirrors the Android change from commit bf61d6cf. Web's Home Playlists
row gains the 5 secondary system kinds (deep_cuts, rediscover,
new_for_you, on_this_day, first_listens) after the Songs-like slots,
in server-registry order, when they exist. No placeholders for these
— they depend on library shape (Deep cuts needs deep albums, On this
day needs prior history, etc.) so a missing one means "not enough
data," not "still building."

Test coverage: new `renders secondary system kinds` test creates a
mocked owned set with for_you + deep_cuts + new_for_you and asserts
all three card names render. Existing tests (5 placeholders, building
variant, For-You + 3 placeholders) unchanged since none seed
secondary kinds.

Most Played multi-row is NOT touched — web already chunks the 75
tracks into 3 rows of 25 via the existing HorizontalScrollRow rows=
{chunk(...,25)} pattern. My initial task description was wrong on
that point.

Naming collision (`rediscover` playlist vs Rediscover recommendations
section both read "Rediscover" on the same Home) deferred — operator
follow-up. Same on Android.

Scribe 531, local task #61.
feat(web): full-screen NowPlaying view
test-web / test (push) Failing after 30s
a2466b7d9a
Mirrors Android's NowPlayingScreen. New /now-playing route renders
outside the Shell (no top bar, no PlayerBar) so it's a focused full
viewport: large square cover, title + linked artist/album, full-width
scrubber with timestamps, prominent transport (prev / play-pause /
next, with a 56dp circular play button), shuffle + repeat toggles,
like button, volume slider, and a queue button that opens the
existing queue drawer.

Tapping the cover or title area in PlayerBar (compact + desktop)
now navigates to /now-playing instead of the album page. Pattern
matches Spotify / YouTube Music. The album link stays reachable via
the album-name link inside NowPlaying itself, so no nav is lost.

Back button in the NowPlaying header uses history.back() with a
fallback to /. Empty state when no track is loaded points the user
at Home or the Library.

Scribe 528, local task #62.
CI on a2466b7d caught it: the cover/title area now links to
/now-playing per the NowPlaying landing, but PlayerBar.test.ts
still asserted /open album/i + href /albums/xyz. Rewritten test
asserts /open now playing/i + href /now-playing, with a comment
explaining the Spotify/YouTube Music pattern (album reachable via
the album-name link inside NowPlaying).
Add a global selection store backing a checkbox per track row and a
floating SelectionBar above PlayerBar with Play next / Add to queue /
Add to playlist / Like all / Clear.

- selection/store.svelte.ts: id Set + TrackRef Map + anchor index;
  toggleOne, selectRange (shift+click), clearSelection. Singleton —
  layout effect clears on pathname change.
- TrackRow: checkbox slot replaces the track number on hover; row
  click toggles selection once any row is picked; shift+click extends
  the range. Esc clears (takes priority over closing the queue
  drawer).
- SelectionBar: floating pill above PlayerBar, mounted inside the
  QueryClientProvider so the Like-all action can resolve.
- player.playNextMany: bulk variant of playNext for "Play next" on a
  multi-track selection.

Covered by selection-store unit tests and three new TrackRow tests
(checkbox toggle, sticky select-mode row click, shift+click range).
feat(web): quick-filter on Library tabs (Tier B5)
test-web / test (push) Successful in 33s
be7be6f617
Add a debounced QuickFilter input at the top of each Library section
to narrow the loaded list without leaving the tab. Server-side
/search remains the route for full-library searches.

- QuickFilter.svelte: 120ms debounced two-way bound input with a
  clear button and Escape-to-clear.
- Artists: filter by name; empty-match copy includes a "Search the
  full library" link to /search/artists.
- Albums: filter by title + artist_name; same fallback link.
- Liked: filters all three sub-sections (artists/albums/tracks);
  sub-section header hides when its filtered length is zero.
- History: filters flatEvents before groupByDay, so day-grouping
  reflects the filter.
- Playlists: filters owned + public by name; owners CTA stays.

Covered by QuickFilter unit tests (debounce + clear + Escape).
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.
Add a Link icon button to the playlist header that copies the
absolute /playlists/[id] URL to the clipboard. Visible only when
isOwner && pl.is_public. Falls back to a window.prompt() in
insecure contexts where navigator.clipboard is unavailable.

Tests: button visibility (private vs public owner) + clipboard
write target.
Drag was already wired via @neodrag/svelte; this adds the missing
keyboard path so reorder is accessible without a pointer device.

- The drag handle becomes a real <button> (focusable). Disabled
  when canDrag is false so non-owners + unavailable tracks can't
  attempt a move.
- ArrowUp / ArrowDown on the focused handle call
  onMove(row.position, row.position +/- 1). Bounds clamping
  already lives in the page-level handler (playlists/[id]/+page.svelte:39),
  so out-of-range targets resolve as no-ops.
- Matches QueueTrackRow's pattern: aria-label "Reorder track …",
  aria-keyshortcuts, swallow Space/Enter to avoid scrolling.

Test updates: drag-handle assertions retargeted to /reorder track/
+ two new ArrowUp/ArrowDown handler tests.
feat(web): crossfade between tracks (Tier C11)
test-web / test (push) Successful in 31s
test-go / test (pull_request) Successful in 34s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (pull_request) Successful in 5m4s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 11m1s
c466e6c317
Operator-tunable 0-12s crossfade in Settings → Playback. Default 0
(off). Most albums sound best at 0 — gapless masters, classical, and
live recordings all suffer noticeable crossfades.

Implementation: pure-function position-derived volume scalar.
`deriveFadeScalar(position, duration, crossfadeSec)` ramps from 0 to
1 over the leading X seconds, 1 to 0 over the trailing X seconds,
and stays at 1 in between. Tracks shorter than 2X don't fade.

The layout's audio-volume effect now multiplies player.volume by
the fade scalar — no timers, no AudioContext, no element swapping.
Re-renders at the ~4Hz `timeupdate` cadence give 16 discrete steps
over a 4s fade, audibly close to smooth. Smoothing to per-frame
ramps via rAF is a follow-up if needed.

Setting persists to localStorage as `minstrel.crossfade` (matches
the existing volume-storage convention).

Tests cover the pure derivation (off, too-short track, fade-in,
fade-out) + clamp/persist on setCrossfade + invalid-input recovery
on readStoredCrossfade.
bvandeusen added 1 commit 2026-06-01 14:30:08 -04:00
ci: publish :latest from main + native APK as minstrel-<tag>.apk
test-go / test (pull_request) Successful in 33s
test-web / test (pull_request) Successful in 32s
android / Build + lint + test (push) Successful in 4m43s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (pull_request) Successful in 5m9s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 12m14s
906eb80ce5
Two related cutover changes so the rolling stable channel works end
to end after the Flutter sunset.

release.yml:
- Main pushes now tag both :main and :latest. Main is the protected
  post-PR-merge branch and the team's stable channel; pinning to
  :latest gets the newest main automatically while :vX.Y.Z stays
  available for pinned consumers.

android.yml:
- Asset attach renamed from minstrel-android-<tag>.apk to
  minstrel-<tag>.apk (M8 phase 14.4 cutover). Without this, the
  server image's bundled in-app-update fetcher in release.yml polls
  a filename that no workflow produces, so /api/client/version
  returns 404 and the web UI's MobileAppDownload silently renders
  nothing. With this rename the existing fetcher resolves to the
  native APK with no further plumbing.

No code paths change; both fixes are workflow rewires.
bvandeusen added 1 commit 2026-06-01 14:35:04 -04:00
ci: switch tag scheme to per-day mutable vYYYY.MM.DD
test-go / test (pull_request) Successful in 29s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (push) Successful in 4m57s
android / Build + lint + test (pull_request) Successful in 5m19s
android / Build signed release APK (push) Has been skipped
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 13m14s
8db488b8ab
Drops the trailing patch digit from the CalVer model. Same-day
re-releases force-move the tag (and overwrite both the docker image
and the release asset of the same name). :latest is now updated by
every main push AND every tag push, so it always reflects the
newest blessed image without a separate cut step.

Comment-only changes — the workflow glob is already 'v*', so the
existing pipeline accepts the new format with no code changes.
bvandeusen added 1 commit 2026-06-01 14:39:11 -04:00
ci: drop pull_request triggers + add cancel-in-progress concurrency
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Successful in 4m13s
android / Build signed release APK (push) Has been skipped
test-go / integration (push) Successful in 13m25s
90bfcaa388
Every commit on dev that's part of an open PR fires each workflow
TWICE: once for the push event and once for the pull_request event
(observable in run #43 + #44 for the same SHA in android.yml, same
shape across test-web.yml and test-go.yml). The two runs check the
exact same SHA — pure waste.

Drop the pull_request trigger from test-web.yml, test-go.yml, and
android.yml. The dev push already exercises every check that a PR
would re-run. This repo has no fork PRs to cover, which is the only
case pull_request would catch that push doesn't.

Add a concurrency group keyed on workflow + ref with
cancel-in-progress: true to every workflow including release.yml.
Rapid re-pushes (or force-moving the per-day tag) now cancel the
older in-flight run instead of stacking.
bvandeusen merged commit c01853577b into main 2026-06-01 16:11:21 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#59