452 Commits

Author SHA1 Message Date
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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
bvandeusen 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