Commit Graph

1271 Commits

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