452 Commits

Author SHA1 Message Date
bvandeusen 04ece65a07 feat(android): Home Most-Played plays the section + NowPlaying LikeButton (audit v3 §4.7 + §4.15)
§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>
2026-05-27 22:26:15 -04:00
bvandeusen 57d01d70ad feat(android): PlaybackErrorReporter — surface ExoPlayer skip-on-error (audit v3 §4.1)
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>
2026-05-27 22:03:32 -04:00
bvandeusen bda69d33c9 fix(android): LikesRepository.toggleLike param is desiredState not liked
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>
2026-05-27 21:33:15 -04:00
bvandeusen 1c05b561ba fix(android) detekt: extract SettingsList helper
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>
2026-05-27 21:22:19 -04:00
bvandeusen dd7c5544bf fix(android) detekt: extract SignOutConfirmDialog helper
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>
2026-05-27 21:15:45 -04:00
bvandeusen 80b59aecf0 refactor(android): HomeRepository hydrates via MetadataProvider (audit v3 #24, slice 3/3)
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>
2026-05-27 21:11:25 -04:00
bvandeusen 4dec61ba55 feat(android): FreshnessSweeper — keep cache warm without user pulls (audit v3 #24, slice 2/3)
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>
2026-05-27 21:10:14 -04:00
bvandeusen 18b082def0 feat(android): MetadataProvider — cache-first reads + on-miss live fetch (audit v3 #24, slice 1/3)
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>
2026-05-27 21:08:59 -04:00
bvandeusen 05c7d922c4 feat(android): LikedTab inline heart + Settings sign-out confirmation
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>
2026-05-27 21:02:41 -04:00
bvandeusen d8459a2674 fix(android): ArtistDetail Play button surfaces failures (audit v3 Bug-7)
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>
2026-05-27 20:59:52 -04:00
bvandeusen 70ef15336e fix(android): Search kebab + hide dead Liked-cap selector (audit v3 §4.8 + Bug-3)
§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>
2026-05-27 20:58:54 -04:00
bvandeusen b0d0936c56 feat(android): Home per-section empty messages (audit v3 §4.5)
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>
2026-05-27 20:57:41 -04:00
bvandeusen cc6a8e2476 fix(android): cosmetic cleanup — admin badge, AdminLanding subtitle, NowPlaying clip
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>
2026-05-27 20:56:43 -04:00
bvandeusen 3239cb976d fix(android): shared LoadingCentered + LibraryScreen Retry actually retries
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>
2026-05-27 20:55:52 -04:00
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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