14 Commits

Author SHA1 Message Date
bvandeusen 41dd2892e3 feat(player): resume last session on launch (#54)
The #52 teardown clears the session when idle/dismissed, so the
headset / lock-screen play button had nothing to resume and the user
lost their place.

- track.dart: toJson() (round-trips fromJson)
- db.dart: CachedResumeState single-row snapshot, schema 9->10 + migration
- audio_handler.dart: queuedTracks getter
- player_provider.dart: restoreQueue() — configure + setQueueFromTracks
  + seek, no play (restores PAUSED)
- resume_controller.dart: restores last snapshot paused on launch;
  persists {source,index,position_ms,tracks} debounced on track
  change / pause and immediately on app teardown; never clobbers the
  saved snapshot when the queue is empty so a teardown stays
  recoverable; restore gated on auth + no active session
- app.dart: wired into postFrame

db.g.dart regenerated by CI per project convention. Deferred fast-follow:
media-button-when-fully-stopped re-init (needs a configure()-injected
callback; tracked on #54).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:58 -04:00
bvandeusen 47572b2e95 feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.

- ArtistSuggestion/SeedContribution model mirroring web types, with
  attributionText() matching web's "Because you liked/played X[, Y, and
  Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
  (image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
  name + attribution + Request, reusing the existing createRequest +
  mutation-queue-replay flow with optimistic hide); typing → Lidarr
  search replaces; clearing → suggestions return. Mirrors web exactly.

Flutter-only; server endpoint unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:54 -04:00
bvandeusen d67c0de596 refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.

Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
  exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
  GET /api/playlists/system/{kind}/shuffle ({kind} = raw
  system_variant). Non-singleton/unknown kind → 404. Deleted
  playlists_{foryou,discover}_refresh.go and the per-kind shuffle
  wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
  the refresh affordance generically without hardcoding kinds.

Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
  systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
  playlist.refreshable; label is "Refresh {name}". Tests reworked;
  obsolete refresh-foryou/discover api tests deleted.

Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
  songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
  detail screen gate kebab/Regenerate/source-tagging on refreshable
  so songs_like_artist plays via get() (no by-kind endpoint).

Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:21:09 -04:00
bvandeusen 03c13d21c6 feat(flutter): home screen on per-item rendering (Slice C)
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.

Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.

The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
  smaller than /api/home), then sections appear shaped with
  skeleton tiles; each tile materializes as the hydration queue
  drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
  instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
  real cards next to skeleton cards. Layout doesn't shift because
  skeletons match real-card dimensions exactly.

CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.

Old /api/home endpoint untouched, so the web client keeps working
unchanged.
2026-05-13 21:05:43 -04:00
bvandeusen 0db7054518 feat(flutter): user-facing requests view + cancel (#356)
Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.

- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
  cancel(id). Same /api/requests endpoint the web /requests page
  uses; identical AdminRequest wire shape so the existing model
  is reused (just augmented with matched_*_id fields for the
  "Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
  already shipped server-side / web-side): 12s refresh while any
  row is mid-ingest (status='approved'), stops on settle. Riverpod
  Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
  Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
  Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
  the Cancel-confirm dialog.

Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:08:23 -04:00
bvandeusen d872a30506 feat(flutter): SystemPlaylistsStatus model + endpoint method
Adds the model + MeApi.systemPlaylistsStatus() for the home Playlists
row's placeholder-variant logic (building / failed / pending /
seed-needed). Mirrors GET /api/me/system-playlists-status which
returns the caller's most recent system_playlist_runs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:36:15 -04:00
bvandeusen d5c8d316c5 fix(flutter): clear analyze --fatal-infos errors from admin parity slice
CI caught six issues against the new admin slice:

- AsyncValue<T> in this Riverpod 3.3.1 codebase exposes `.value`
  (returns T?), not `.valueOrNull`. Switched the three admin readers
  to match the established convention (player_bar, queue_screen,
  now_playing_screen all use `.value`).
- home_screen.dart still has ctx.push calls in _albumsRow / _artistsRow
  (carousel tap handlers) — restored the go_router import that
  Task 2 over-eagerly removed.
- admin_user_edit_sheet.dart had a single-line `if (!await ...) return;`
  that violated curly_braces_in_flow_control_structures. Wrapped in
  braces.
- admin_quarantine_item.dart doc comment had `<top>` placeholders that
  the analyzer flagged as unintended HTML. Rephrased without angle
  brackets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:02:14 -04:00
bvandeusen 2b595c40cd feat(flutter): admin DTOs (request, quarantine item, user, invite)
Hand-rolled to match the server's actual JSON shapes:
- AdminRequest carries user_id (UUID, not username); kind switches
  which title field is the display name
- AdminQuarantineItem aggregates reports per track with reason_counts
  and a nested reports[] list, mirroring adminQueueRowView
- AdminUser includes display_name + auto_approve_requests
- Invite uses invited_by UUID + note (server hardcodes 24h TTL)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:51:33 -04:00
bvandeusen 3f2822dfc6 feat(flutter): discover screen — search Lidarr + create requests
- models/lidarr.dart (LidarrSearchResult + LidarrRequestKind)
- api/endpoints/discover.dart (search + createRequest)
- discover/discover_screen.dart with TextField, kind toggle (Artists/Albums),
  results list with in_library / requested pills
- /discover route + explore icon on home AppBar (5 actions now: Library,
  Playlists, Search, Discover, Settings)
2026-05-08 14:53:06 -04:00
bvandeusen dfbeed01a6 feat(flutter): settings screen + 4-icon home AppBar
- models/my_profile.dart (MyProfile + ListenBrainzStatus)
- api/endpoints/settings.dart (profile / password / listenbrainz CRUD)
- settings/settings_screen.dart with three sections:
  - Profile: display_name + email, Save profile
  - Password: current + new + confirm, validates >=8 + match
  - ListenBrainz: token paste + scrobble-enabled toggle
- /settings route + Settings icon on home AppBar
- Home AppBar now: Library + Playlists + Search + Settings (was just Search)
2026-05-08 14:51:04 -04:00
bvandeusen 66545bf8c3 feat(flutter): library screen with 5 tabs (Artists/Albums/History/Liked/Hidden)
Models:
- history_event.dart (HistoryEvent + HistoryPage envelope)
- quarantine_mine.dart (QuarantineMineRow for /api/quarantine/mine)
- Renamed Page<T> -> Paged<T> to avoid collision with Material's Page

Endpoints:
- library_lists.dart: listArtists / listAlbums (paged)
- me.dart: history + quarantineMine
- likes.dart: extended with listTracks/Albums/Artists (paged)

UI:
- library_screen.dart: TabBar over 5 tabs
- Artists tab: 3-col grid
- Albums tab: 2-col grid
- History tab: list with relative-time formatting (matches HistoryRow.svelte)
- Liked tab: 3 stacked sections (artists scroll-row, albums scroll-row, tracks list)
- Hidden tab: list of quarantined tracks with reason pill
- /library route + library_music icon on home AppBar

First page only for v1 (limit 50). Infinite scroll deferred.
2026-05-08 14:40:26 -04:00
bvandeusen 5541171e94 feat(flutter): playlists list + detail screens
- models/playlist.dart (Playlist, PlaylistTrack, PlaylistDetail)
- api/endpoints/playlists.dart (list with kind=user|system|all, get detail)
- playlists/playlists_provider.dart (Riverpod family providers)
- playlists/playlists_list_screen.dart (with system-variant pill)
- playlists/playlist_detail_screen.dart (header + Play button + rows)
- /playlists + /playlists/:id routes wired
- Home AppBar: queue_music icon next to Search

Tracks with track_id=null render greyed-out + struck-through (matches
web's PlaylistTrackRow behavior). Tap a row plays from that position;
top-level Play button plays the full playable subset from track 0.
2026-05-08 14:33:53 -04:00
bvandeusen 147d6e280e feat(flutter): bump deps to latest + Search screen slice
Major version bumps (riverpod 2->3, go_router 14->17, just_audio 0.9->0.10,
flutter_secure_storage 9->10, google_fonts 6->8, flutter_lints 4->6).
package_info_plus held at 8.x due to win32 conflict with secure_storage 10.x.

Riverpod 3 breaks: AsyncValue.valueOrNull -> .value, StateProvider replaced
with Notifier subclass. just_audio 0.10: ConcatenatingAudioSource -> setAudioSources.

Search slice:
- models/page.dart (generic Page<T> envelope)
- models/search_response.dart (3-facet wrapper)
- api/endpoints/search.dart
- search/search_provider.dart (debounced 250ms)
- search/search_screen.dart (TextField + 3 horizontal/vertical sections)
- Wired /search route + search button on home AppBar
2026-05-08 14:30:01 -04:00
bvandeusen c0785cf894 feat(flutter/models): User, ArtistRef, AlbumRef, TrackRef, HomeData
Field names mirror web/src/lib/api/types.ts and the server JSON shape
(internal/api/types.go's HomePayload). Hand-written DTOs per spec
section 10 -- codegen revisits at ~30 models.

Adopts server contract over plan-body mock:
- cover_url (not cover_art_url) for artist/album refs
- duration_sec (not duration_ms) for albums and tracks
- stream_url, disc_number on TrackRef
- sort_name/album_count on ArtistRef; sort_title/year/track_count on AlbumRef

HomeData section keys match server: recently_added_albums,
rediscover_albums, rediscover_artists, most_played_tracks,
last_played_artists.
2026-05-02 17:18:12 -04:00