Commit Graph

237 Commits

Author SHA1 Message Date
bvandeusen 4015fb145d refactor(android): add shared formatDuration + ms/sec conversion helpers 2026-05-29 14:38:52 -04:00
bvandeusen d7dda2fcef fix(android): derive artist tile cover from first cached album
Library + Home artist tiles were blank: cached_artists stores no cover and the sync payload (SyncArtistWire) carries none. Mirror Flutter's artistTileProvider JOIN — ArtistRef gains coverAlbumId + a displayCoverUrl getter; LibraryRepository.observeArtists and MetadataProvider.observeArtist combine artists with albums to supply the first album (by sort title) as the cover source. ArtistCard renders displayCoverUrl through ServerImage. Updated LibraryRepositoryTest to stub albumDao.observeAll so combine emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:35:09 -04:00
bvandeusen 1b243347cb fix(android): resolve relative server cover URLs via shared ServerImage
The server returns relative cover_url (/api/albums/{id}/cover, /api/playlists/{id}/cover). Android only resolved the empty-fallback placeholder trick, so any non-empty relative URL from a fresh fetch went to Coil host-less and silently failed (album detail, artist-detail album grid, playlist cards, artist avatars). Add a shared ServerImage composable + resolveServerImageUrl that prefixes the placeholder host (rewritten by BaseUrlInterceptor) for relative paths, passes http(s) through, and falls back when blank. Route AlbumCard, PlaylistCard, AlbumDetail header, and ArtistAvatar through it. Mirrors Flutter's ServerImage._resolve.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:11:19 -04:00
bvandeusen 01b05f37c3 feat(android): per-tile skeleton reveal on Home via reactive section flows 2026-05-28 19:42:09 -04:00
bvandeusen 4eb225914c feat(android): add HomeArtistPrewarmer (top-8 artists, session-deduped) 2026-05-28 18:58:29 -04:00
bvandeusen b2512fff4b feat(android): bound MetadataProvider on-miss fetches to 4 concurrent 2026-05-28 18:26:13 -04:00
bvandeusen 5b5bff767a feat(android): add HomeTile model for per-tile hydration 2026-05-28 18:12:57 -04:00
bvandeusen 68891f1df9 fix(android): hold previous NowPlaying gradient color across track change
rememberDominantColor was keyed on coverUrl, so each track change reset the extracted color to Transparent and the gradient dipped toward the fallback before tweening to the new color. Drop the key so the held color stays on the previous track's dominant until the new palette resolves -- the gradient now tweens directly to the new color. Combined with CoverPrefetcher warming the next cover, this matches Flutter's preload-then-atomic-swap UX (no flash on track change) via native Compose mechanisms rather than a literal preload pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:12:33 -04:00
bvandeusen 61cf07a3cf feat(android): soft "update available" shell banner (#25)
Ports Flutter's UpdateBanner. UpdateBannerController polls
/api/client/version at launch + every 24h, compares the bundled APK
against this build via isVersionNewer, and exposes the available
UpdateInfo (minus in-memory per-version dismissals). The shell banner
nudges "Update Minstrel · {version} available" with Install (reusing
ApkInstaller's download + system-install handoff, routing to the
install-permission settings first when needed) and a dismiss X. Uses an
understated surfaceVariant tone, distinct from the error-colored
VersionTooOldBanner hard gate.

Divergence noted: ApkInstaller exposes no byte progress, so the
downloading state shows an indeterminate bar rather than Flutter's
determinate one.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:17:12 -04:00
bvandeusen 5e18d506fe fix(android): correct Lucide icon name CircleCheckBig (#38 slice 2)
The composables lucide artifact names the property CircleCheckBig
(camelCase), not the snake-case filename — the prior import did not
resolve and broke compileDebugKotlin.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:47:10 -04:00
bvandeusen ffc2acc010 feat(android): Hidden tab cache-first via Room + Flow (SWR) — responsiveness
Closes #37. The Hidden tab was fetch-on-visit (spinner every open);
now it paints instantly from the cached_quarantine_mine Room table
and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController
(drift watch + transaction full-replace + optimistic flag/unflag).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:14:28 -04:00
bvandeusen 4dc80047b2 feat(android): History tab cache-first via Room + Flow (SWR) — responsiveness
Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:33:29 -04:00
bvandeusen 14330e99da fix(android): provide AudioCacheIndexDao to Hilt graph
The offline-pool ShuffleSource (2a87a50b) injects AudioCacheIndexDao,
but DatabaseModule had no @Provides bridge for it — nothing had
injected that DAO via Hilt before, so the graph was missing the
binding and hiltJavaCompileDebug failed. Added the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:20:07 -04:00
bvandeusen a37fe4c167 feat(android): ArtistDetail avatar falls back to first album cover (parity map)
Ports flutter_client/lib/library/artist_detail_screen.dart:177-208
_ArtistAvatar: server coverUrl wins; when empty, use the first loaded
album's /api/albums/{id}/cover; only show the bare Lucide.User icon
when the artist has neither. Threads detail.albums.firstOrNull()?.id
through ArtistHeader → ArtistAvatar. (Seeded-loading header passes
null since the seed carries no album list.)

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:19:31 -04:00
bvandeusen 903a7961e3 feat(android): "Change server URL" link on Login (parity map: Auth)
Ports flutter_client/lib/auth/login_screen.dart:86-89 — a TextButton
under the Sign-in button that routes to the ServerUrl screen, so a
wrong server URL is recoverable pre-login without reinstalling or
signing out from Settings. Disabled while a submit is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:15:44 -04:00
bvandeusen 2a87a50b81 feat(android): offline-pool Home cards (audit v3 §4.3) — closes #28
Faithful port of Flutter's _OfflinePoolCard + shuffle_source.dart.
I was wrong earlier that Android lacked the source — the
audio_cache_index table already carries lastPlayedAt, exactly what
Flutter reads.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:55 -04:00
bvandeusen edbc805333 feat(android): system-playlist placeholder cards on Home (audit v3 §4.4, part of #28)
The Home Playlists row now always renders the For You + Discover +
3× Songs-like slots: a real PlaylistCard when the system playlist
has generated, otherwise a PlaylistPlaceholderCard showing its
build state — building / failed / seed-needed / pending — derived
from GET /api/me/system-playlists-status. Mirrors Flutter's
_buildPlaylistsRow + PlaylistPlaceholderCard.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:42:13 -04:00
bvandeusen 8f5635c489 feat(android): in-app APK update install (audit v3 #25)
Wires the "Install vX.Y.Z" action the About card's update-check
teed up.

* ApkInstaller @Singleton — downloads the server APK via the shared
  OkHttpClient (inherits auth cookie + BaseUrlInterceptor host
  rewrite; apkUrl is server-relative) into cacheDir, then launches
  the system installer via a FileProvider content:// URI. canInstall()
  gates on PackageManager.canRequestPackageInstalls() on O+, and
  requestInstallPermission() opens the "install unknown apps" settings
  page when not yet granted.
* Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
  (${applicationId}.fileprovider) + res/xml/file_paths.xml exposing
  the cache dir.
* AboutCardViewModel.install(info): permission check → download →
  launch, with isInstalling + installMessage state. Errors routed
  through ErrorCopy.
* About card shows an "Install vX.Y.Z" button under the check button
  when an update is available, "Downloading…" while in flight, and
  the install message line. Extracted UpdateControls / InstallButton /
  ButtonSpinner helpers to keep AboutCard under the length cap;
  added @file:Suppress(TooManyFunctions) for the settings-card density.

Closes audit v3 #25 — the last open parity item from the v3 sweep
aside from the offline-pool Home cards (#28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:18:49 -04:00
bvandeusen 5ce9ca9e62 fix(android): NowPlaying go-to-album/artist navigates atomically (audit v3 Bug-5)
The kebab "Go to album/artist" did popBackStack() then navigate() —
two transactions, leaving a visible frame on whichever shell tab sat
under NowPlaying before the detail pushed. Replaced with a single
navigate(...) { popUpTo(NowPlaying) { inclusive = true } } so the
modal is removed and the detail pushed in one atomic transaction,
no intermediate frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:09:33 -04:00
bvandeusen 6a473661ba fix(android) detekt: extract Home section helpers from HomeSuccessContent
The 25-row chunking + per-section empty messages pushed
HomeSuccessContent to 69 lines (cap 60). Extracted each section
into a LazyListScope extension (recentlyAddedSection /
rediscoverSection / mostPlayedSection / lastPlayedSection) so the
main composable is just the LazyColumn skeleton calling them. File
already carries @file:Suppress(TooManyFunctions) for the Compose
helper density.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:01:43 -04:00
bvandeusen 7f6aa9482a feat(android): typed-confirm on AdminUsers delete (audit v3 §4.20)
Delete-user was a plain Delete/Cancel AlertDialog — too permissive
for an irreversible action. Now mirrors Flutter's TypedConfirmSheet:
an OutlinedTextField where the admin must type "DELETE" before the
Delete button enables (case-insensitive). The button stays disabled
and muted until the word matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:57:30 -04:00
bvandeusen ca0fb70370 feat(android): TrackActions hidden-state reacts to SSE (audit v3 Bug-9/§4.12)
TrackActionsViewModel fetched the hidden-track snapshot only at init
and after local flag/unflag, so a hide/unhide from another device
left the sheet's Hide/Unhide label stale until the VM was recreated.
Now it subscribes to EventsStream and refreshes on any quarantine.*
event (flagged / unflagged / resolved / file_deleted /
deleted_via_lidarr).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:56:26 -04:00
bvandeusen 1d1a27540a feat(android): chunk Home Recently-Added into 25-album carousels (audit v3 §4.6)
A large library rendered all recently-added albums in one horizontal
LazyRow that scrolled forever. Now the list is chunked into stacked
carousels of 25 (RECENTLY_ADDED_CHUNK); the first carries the
"Recently added" title and the rest are untitled continuation rows,
matching Flutter's shared-ScrollController layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:51:50 -04:00
bvandeusen 38102df929 feat(android): seed headers on detail-screen Loading state (audit v3 Bug-8)
AlbumDetail / ArtistDetail / PlaylistDetail previously rendered only
the AppBar title from the navigation seed during Loading, with a bare
skeleton body. Now each Loading branch renders the full header from
the seed when present — cover + title + artist/description + count —
above the skeleton track rows / album grid, so the screen reads as
itself instantly instead of skeleton-then-pop. Falls back to the
plain skeleton when no seed (e.g. deep links, track-row navigations
that only carry an id).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:51:02 -04:00
bvandeusen 42a231e179 feat(android): variant pill + mini-cover crossfade + clipboard API (audit v3 §4.10/§4.16 tail)
§4.10/§4.18 — PlaylistCard now overlays a small primary-tinted pill
on system-playlist covers showing the variant ("For You" / "Discover"
/ "Today's mix" / …) instead of relying on the subtitle line. The
subtitle now prefers track count, so system tiles show variant pill
+ track count rather than just the label.

§4.16 — MiniPlayer cover wraps its content in a Crossfade keyed on
coverUrl so artwork dissolves into the next track instead of hard-
swapping. Pairs with the CoverPrefetcher cache-warming so the next
cover is usually ready to fade in.

Cleanup — migrated the AdminUsers invite Copy-token button off the
deprecated LocalClipboardManager.setText to LocalClipboard.setClipEntry
(suspend, via rememberCoroutineScope + ClipData/ClipEntry), clearing
the build warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:47:49 -04:00
bvandeusen ef85f5fd0b feat(android): EventsStream reconnect-with-backoff (audit v3 §4.13)
Previously a mid-session SSE drop (server restart / network blip)
left the stream closed until the next sign-in — cross-device
reactivity (likes, playlist updates, request status, quarantine
changes) silently died until app relaunch.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:52:24 -04:00
bvandeusen 8455922e8c feat(android): QueueScreen rows show artist · album + duration (audit v3 §4.17)
Queue rows only showed title + artist. Now the subtitle reads
"Artist · Album" (collapsing gracefully when either is empty) and a
trailing m:ss duration appears when known — matches Flutter's queue
row density so users can tell tracks apart by album + length.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:51:03 -04:00
bvandeusen a7127e127f test(android): LibraryViewModelTest expects ErrorCopy generic fallback
The error test asserted the raw exception text leaked into the UI
state — exactly the behaviour ErrorCopy removes. An
IllegalStateException is neither HttpException nor IOException, so
it maps to the generic "Something went wrong." Updated the
assertion to the new contract and dropped the now-unused
assertTrue import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:32:14 -04:00
bvandeusen 3af8fa7207 feat(android): ErrorCopy — friendly error messages across the app (audit v3 §4.2)
Ports Flutter's error-copy.json (45 server codes → sentence-case
copy) as a Kotlin ErrorCopy object. fromThrowable(t):
  - Retrofit HttpException → parse {"error":{"code":...}} body,
    map code (e.g. "wrong_password" → "Current password is
    incorrect.", "playlist_not_found" → "That playlist no longer
    exists.")
  - IOException → connection_refused → "Couldn't reach the server.
    Check the URL and try again."
  - anything else → "Something went wrong."

Wired into 22 ViewModels / screens, replacing the raw
`e.message ?: "<generic>"` fallbacks that leaked exception text
(e.g. "HTTP 404", "Unable to resolve host") into the UI. Load-state
errors now read as actionable copy; settings form messages dropped
their "Couldn't save:" prefixes since the friendly strings stand
alone. ArtistDetail's playback path keeps its "Couldn't start
playback: " prefix (local-action context). PlayerController's
controller-connect failure and the About update-check are left on
their own copy (not server-code errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:25:19 -04:00
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