Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.7 040217cab6 fix(android): hydrate missing Home entities so sections actually render
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>
2026-05-27 20:37:39 -04:00
bvandeusenandClaude Opus 4.7 e8a7e48bd5 fix(android): Settings column wasn't scrolling
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>
2026-05-27 19:52:42 -04:00
bvandeusenandClaude Opus 4.7 7e1d4cde81 fix(android) detekt: extract VersionTooOldViewModel to its own file
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:09:36 -04:00
bvandeusenandClaude Opus 4.7 ae26d66987 feat(android): VersionGate banner (audit v2 #23)
* 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>
2026-05-27 18:47:00 -04:00
bvandeusenandClaude Opus 4.7 d20fab5459 fix(android) detekt: compareComponentWise via zip+firstOrNull (single return)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:14:56 -04:00
bvandeusenandClaude Opus 4.7 5f31fdc300 feat(android): About card check-for-updates button (audit v2 #20, slice 2)
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>
2026-05-27 18:11:49 -04:00
bvandeusenandClaude Opus 4.7 2cb9f062c5 fix(android) detekt: rename ListenBrainz wire file + shrink ListenBrainzForm
* 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>
2026-05-27 17:36:58 -04:00
bvandeusenandClaude Opus 4.7 e3e9c48a0a feat(android): ListenBrainz Settings card (audit v2 #20, slice 1)
* 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>
2026-05-27 17:32:29 -04:00
bvandeusenandClaude Opus 4.7 e6bf99f580 feat(android): CoverPrefetcher — warm Coil cache with next track's cover (audit v2 #17, slice 3)
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>
2026-05-27 17:06:45 -04:00
bvandeusenandClaude Opus 4.7 7082ebf9a5 fix(android) detekt: collapse hasUsableInternet to single return
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:57:03 -04:00
bvandeusenandClaude Opus 4.7 59a111914c feat(android): connectivity observer + shell ConnectionErrorBanner (audit v2 #21)
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>
2026-05-27 16:29:49 -04:00
bvandeusenandClaude Opus 4.7 6a932405f8 feat(android): MiniPlayer → NowPlaying cover Hero transition (audit v2 #17, slice 2)
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>
2026-05-27 15:39:11 -04:00
bvandeusenandClaude Opus 4.7 01fdd2f380 feat(android): NowPlaying dominant-color gradient backdrop (audit v2 #17, slice 1)
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>
2026-05-27 15:08:00 -04:00
bvandeusenandClaude Opus 4.7 f9b0c267e3 fix(android): drop stray @Composable on SKELETON_PLAYLIST_ROWS const
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>
2026-05-27 14:58:30 -04:00
bvandeusenandClaude Opus 4.7 69179e0af3 fix(android) detekt: extract AlbumDetailStateContent helper
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>
2026-05-27 14:40:36 -04:00
bvandeusenandClaude Opus 4.7 89203fc4a1 feat(android): skeleton bodies on Library + detail screens (audit v2 #16, slice 2)
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>
2026-05-27 14:31:29 -04:00
bvandeusenandClaude Opus 4.7 43ee8f9a39 feat(android): skeleton tiles + Home cross-fade reveal (audit v2 #16, slice 1)
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>
2026-05-27 14:18:02 -04:00
bvandeusenandClaude Opus 4.7 f21f53d04a fix(android) detekt: name LinkedHashMap load factor constant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:06:34 -04:00
bvandeusenandClaude Opus 4.7 208a7d056b feat(android): detail-screen seed extras (audit v2 #11)
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>
2026-05-27 13:33:11 -04:00
bvandeusenandClaude Opus 4.7 c816490061 fix(android) detekt: shrink AdminUsersScreen, real regenerate fix, suppress TooManyFunctions
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>
2026-05-27 13:14:34 -04:00
bvandeusenandClaude Opus 4.7 c2c9de26e7 feat(android) + fix detekt: AdminUsers Invites + Requests/PlaylistDetail refactors
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>
2026-05-27 13:01:13 -04:00
bvandeusenandClaude Opus 4.7 7500d283a8 feat(android): PlaylistDetail Regenerate button + refreshSystem endpoint
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>
2026-05-27 12:54:47 -04:00
bvandeusenandClaude Opus 4.7 49a5324be8 feat(android): Requests polish — Listen / cancel confirm / progress / kind avatar
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>
2026-05-27 12:52:25 -04:00
bvandeusenandClaude Opus 4.7 ec585997ae feat(android): Search artists/albums as horizontal carousels
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>
2026-05-27 12:47:05 -04:00
bvandeusenandClaude Opus 4.7 a42a4df736 fix(android): silence JVM + Kotlin annotation-target warnings
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>
2026-05-27 11:51:36 -04:00
bvandeusenandClaude Opus 4.7 7250f1f0e0 fix(android): LibraryViewModelTest — pass PlayerController to ctor
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>
2026-05-27 11:48:44 -04:00
bvandeusenandClaude Opus 4.7 37043fb7e0 fix(android): Lucide.Shuffle in LibraryScreen — Shuffle is an extension
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>
2026-05-27 11:35:25 -04:00
bvandeusenandClaude Opus 4.7 de79cf0342 fix(android): detekt + silence JDK 22+ native-access warning
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>
2026-05-27 11:22:53 -04:00
bvandeusenandClaude Opus 4.7 92a9b55c12 feat(android): Library AppBar — Shuffle all button
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>
2026-05-27 11:12:11 -04:00
bvandeusenandClaude Opus 4.7 cd1c054f62 feat(android): AdminRequests — show requester username
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>
2026-05-27 11:10:50 -04:00
bvandeusenandClaude Opus 4.7 a7f1160b7e feat(android): Hidden tab — cover thumb + relative-time stamp
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>
2026-05-27 11:09:27 -04:00
bvandeusenandClaude Opus 4.7 c29d1e0b3d feat(android): now-playing row highlight on track surfaces
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>
2026-05-26 22:04:59 -04:00
bvandeusenandClaude Opus 4.7 225ff35c4c fix(android): detekt LongMethod on MiniRow (62/60)
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>
2026-05-26 21:54:21 -04:00
bvandeusenandClaude Opus 4.7 a606267c1e feat(android): MiniPlayer prev/next + inline scrubber + like button
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>
2026-05-26 21:35:51 -04:00
bvandeusenandClaude Opus 4.7 e82710dc0b fix(android): MeRepository KDoc — actually remove the /api/me/* trap
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>
2026-05-26 21:24:23 -04:00
bvandeusenandClaude Opus 4.7 c74fa41fd0 fix(android): KSP MeRepository resolution — KDoc /api/me/* trap
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>
2026-05-26 21:24:05 -04:00
bvandeusenandClaude Opus 4.7 3c1d99037c fix(android): MeApi — move request bodies inline (project pattern)
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>
2026-05-26 21:04:23 -04:00
bvandeusenandClaude Opus 4.7 a1b1eed740 fix(android): MeRepository resolution — match project Api+body pattern
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>
2026-05-26 21:03:56 -04:00
bvandeusenandClaude Opus 4.7 b1bcfe7fa3 fix(android): detekt ReturnCount on PasswordViewModel.change (3/2)
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>
2026-05-26 20:46:33 -04:00
bvandeusenandClaude Opus 4.7 ae45e8f32e fix(android): actually wire ProfileCard + PasswordCard into Settings
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>
2026-05-26 20:15:27 -04:00
bvandeusenandClaude Opus 4.7 14c5262ed7 feat(android): Settings — Profile + Password cards
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>
2026-05-26 20:15:01 -04:00
bvandeusenandClaude Opus 4.7 f18b7d4658 feat(android): MeApi + MyProfile + MeRepository foundation
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>
2026-05-26 20:13:41 -04:00
bvandeusenandClaude Opus 4.7 f7c3bd2dcf feat(android): Settings — My Requests + Admin tiles
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>
2026-05-26 19:59:20 -04:00
bvandeusenandClaude Opus 4.7 8a2279d5df feat(android): NowPlaying drag-down-to-dismiss + close button
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>
2026-05-26 19:50:55 -04:00
bvandeusenandClaude Opus 4.7 29c676fcf8 fix(android): Requests cancel → MutationQueue offline fallback
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>
2026-05-26 19:49:30 -04:00
bvandeusenandClaude Opus 4.7 118c687847 fix(android): pull-to-refresh works on Empty/Error states
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>
2026-05-26 19:47:54 -04:00
bvandeusenandClaude Opus 4.7 4d7a4312db fix(android): hide Storage prefetch + cache-liked toggles (no-ops)
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>
2026-05-26 19:44:50 -04:00
bvandeusenandClaude Opus 4.7 d99d317563 fix(android): silent-breakage cluster — search/artist/nowplaying
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>
2026-05-26 19:43:55 -04:00
bvandeusenandClaude Opus 4.7 622c90a2d5 fix(android): LibraryViewModelTest — pass SyncController to constructor
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>
2026-05-26 18:48:12 -04:00
bvandeusenandClaude Opus 4.7 b6a48a56e8 fix(android): detekt LongMethod on DiscoverScreen (63/60)
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>
2026-05-26 18:44:27 -04:00
bvandeusenandClaude Opus 4.7 cf07a2a5a8 feat(android): pull-to-refresh — library tabs + admin + hidden
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>
2026-05-26 18:25:50 -04:00
bvandeusenandClaude Opus 4.7 4ca10e2afa feat(android): pull-to-refresh on Playlists / Discover / Requests
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>
2026-05-26 18:19:12 -04:00
bvandeusenandClaude Opus 4.7 e6e4f6dcf1 feat(android): PullToRefreshScaffold + Home / Album / Artist detail
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>
2026-05-26 18:16:02 -04:00
bvandeusenandClaude Opus 4.7 4a3215cc07 feat(android): MiniPlayer TrackActions kebab + shell-level snackbar
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>
2026-05-26 18:08:20 -04:00
bvandeusenandClaude Opus 4.7 c9bf0479ec fix(android): StorageCard — simpler OutlinedButton + DropdownMenu
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>
2026-05-26 17:55:29 -04:00
bvandeusenandClaude Opus 4.7 5455cd5d80 fix(android): detekt TooManyFunctions on AuthStore (14/11)
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>
2026-05-26 16:53:39 -04:00
bvandeusenandClaude Opus 4.7 6ef08edd99 feat(android): Settings — Storage section UI + Sync / Clear actions
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>
2026-05-26 16:25:24 -04:00
bvandeusenandClaude Opus 4.7 b438772c96 feat(android): persist CacheSettings + PlayerModule reads from AuthStore
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>
2026-05-26 16:23:13 -04:00
bvandeusenandClaude Opus 4.7 9baed7e579 fix(android): AdminRequestsViewModel — missing imports for EventsStream
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>
2026-05-26 15:43:24 -04:00
bvandeusenandClaude Opus 4.7 525873fffc feat(android): per-screen SSE subscribers — playlists / requests / admin
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>
2026-05-26 15:02:42 -04:00
bvandeusenandClaude Opus 4.7 3ce30bf19c feat(android): LiveEventsDispatcher — cross-screen SSE invalidations
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>
2026-05-26 14:58:57 -04:00
bvandeusenandClaude Opus 4.7 29cfbd61b1 feat(android): EventsStream — SSE subscription to /api/events/stream
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>
2026-05-26 14:58:21 -04:00
bvandeusenandClaude Opus 4.7 a9b3c936f1 fix(android): detekt LongMethod on NowPlayingScreen (61/60)
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>
2026-05-26 14:53:55 -04:00
bvandeusenandClaude Opus 4.7 03a6dc4e5d feat(android): shuffle + repeat on NowPlaying
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>
2026-05-26 14:45:29 -04:00
bvandeusenandClaude Opus 4.7 dc68156d98 fix(android): detekt — package naming, file rename, length caps
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>
2026-05-26 14:18:15 -04:00
bvandeusenandClaude Opus 4.7 4580f950e3 feat(android): PlayEventsReporter — wire mobile plays to server
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>
2026-05-26 14:04:23 -04:00
bvandeusenandClaude Opus 4.7 ddc4472e29 feat(android): MutationQueue PLAY_OFFLINE kind
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>
2026-05-26 14:02:52 -04:00
bvandeusenandClaude Opus 4.7 c396b673ac feat(android): surface queue source on PlayerUiState
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>
2026-05-26 14:02:06 -04:00
bvandeusenandClaude Opus 4.7 92c128e388 feat(android): EventsApi + wire types for /api/events
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>
2026-05-26 14:01:44 -04:00
bvandeusenandClaude Opus 4.7 415200d8f0 feat(android): persist client_id for play-event reporting
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>
2026-05-26 14:01:21 -04:00
bvandeusenandClaude Opus 4.7 3f00006b74 feat(android): TrackActions kebab on NowPlaying with hideQueueActions
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>
2026-05-26 12:24:13 -04:00
bvandeusenandClaude Opus 4.7 965ec412d1 feat(android): TrackActions button on Playlist / Library tabs / Search
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>
2026-05-26 12:23:33 -04:00
bvandeusenandClaude Opus 4.7 d0ff607994 feat(android): TrackActions overflow menu + first surface (AlbumDetail)
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>
2026-05-26 12:18:51 -04:00
bvandeusenandClaude Opus 4.7 55aba8f916 feat(android): QuarantineRepository.flag + HideTrackSheet
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>
2026-05-26 12:16:00 -04:00
bvandeusenandClaude Opus 4.7 dd42bd5121 feat(android): PlaylistsRepository.appendTrack + AddToPlaylistSheet
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>
2026-05-26 12:15:08 -04:00
bvandeusenandClaude Opus 4.7 1f4a5e08bd feat(android): MutationQueue PLAYLIST_APPEND + QUARANTINE_FLAG kinds
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>
2026-05-26 12:13:27 -04:00
bvandeusenandClaude Opus 4.7 ac1832da43 feat(android): PlayerController playNext/enqueue/startRadio + RadioApi
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>
2026-05-26 12:12:12 -04:00
bvandeusenandClaude Opus 4.7 f628ae4479 fix(android): LikedTab tracks tap to play
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>
2026-05-26 10:31:45 -04:00
bvandeusenandClaude Opus 4.7 484ad6c496 fix(android): MainAppBarActions sources isAdmin internally
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>
2026-05-26 10:31:38 -04:00
bvandeusenandClaude Opus 4.7 0927b9177b fix(android): extract MiniCover from MiniPlayer body (detekt LongMethod)
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>
2026-05-26 08:26:09 -04:00
bvandeusenandClaude Opus 4.7 f3ee182cd7 feat(android): covers on AlbumDetail header + MiniPlayer + NowPlaying
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>
2026-05-26 08:22:42 -04:00
bvandeusenandClaude Opus 4.7 283706c4f8 feat(android): AlbumCard cover fallback for cached-only albums
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>
2026-05-26 08:17:08 -04:00
bvandeusenandClaude Opus 4.7 2c640b897a feat(android): shared TrackCoverThumb + cover art on Playlist/History rows
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>
2026-05-26 08:08:27 -04:00
bvandeusenandClaude Opus 4.7 41c6258b05 feat(android): track cover thumbnails on Home Most-Played + Liked tracks
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>
2026-05-26 01:46:53 -04:00
bvandeusenandClaude Opus 4.7 a85a95507c fix(android): AlbumDetail shuffle button actually shuffles
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>
2026-05-26 01:01:31 -04:00
bvandeusenandClaude Opus 4.7 802281c7c5 fix(android): don't pre-fill localhost default in ServerUrl field
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>
2026-05-26 00:50:44 -04:00
bvandeusenandClaude Opus 4.7 95c5067dbf fix(android): include modified files for Phase 20 (previous commit missed them)
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>
2026-05-26 00:44:38 -04:00
bvandeusenandClaude Opus 4.7 45e2248970 feat(android): Phase 20 — Settings theme picker (System / Light / Dark)
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>
2026-05-26 00:43:47 -04:00
bvandeusenandClaude Opus 4.7 2851f8c694 chore(android): untrack local Gradle/build cache, extend .gitignore
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>
2026-05-26 00:35:56 -04:00
bvandeusenandClaude Opus 4.7 0b72827682 fix(android): unblock first device test — auth contrast + dynamic base URL
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>
2026-05-26 00:27:05 -04:00
bvandeusenandClaude Opus 4.7 0415b5ccc3 fix(android): suppress SwallowedException on the 3 dispatch catches
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>
2026-05-25 23:32:52 -04:00
bvandeusenandClaude Opus 4.7 2f205eb0d9 feat(android): Phase 18 — MutationReplayer drains offline write queue
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>
2026-05-25 23:11:49 -04:00
bvandeusenandClaude Opus 4.7 a87ec770a5 fix(android): rename state_ → internal (detekt VariableNaming)
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>
2026-05-25 22:44:44 -04:00
bvandeusenandClaude Opus 4.7 5869ec9505 feat(android): Phase 17b — persist user identity across cold restart
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>
2026-05-25 22:40:09 -04:00
bvandeusenandClaude Opus 4.7 16b3a1e9e2 feat(android): Phase 17a — PlaylistDetail per-track LikeButton
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>
2026-05-25 22:31:06 -04:00
bvandeusenandClaude Opus 4.7 f0278ed2bf feat(android): Phase 16 — Queue screen + View-queue affordance on NowPlaying
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>
2026-05-25 21:30:06 -04:00
bvandeusenandClaude Opus 4.7 b342a7c41b fix(android): suppress TooManyFunctions on DatabaseModule
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>
2026-05-25 21:12:38 -04:00
bvandeusenandClaude Opus 4.7 118f3d31f3 fix(android): add Hilt @Provides for SyncMetadataDao
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>
2026-05-25 20:57:21 -04:00
bvandeusenandClaude Opus 4.7 66dfc473db feat(android): Phase 15 — SyncController for /api/library/sync delta sync
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>
2026-05-25 19:39:22 -04:00
bvandeusenandClaude Opus 4.7 7dfe6f1b69 feat(android): Phase 14 — Like buttons on Album + Artist detail
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>
2026-05-25 18:46:59 -04:00