Compare commits

...

133 Commits

Author SHA1 Message Date
bvandeusen 42abb7adff Merge pull request 'ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into CI_TOKEN' (#36) from dev into main 2026-05-11 01:45:01 +00:00
bvandeusen e4578593d1 ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into a single CI_TOKEN secret
Both prior secrets did related work (one for registry push, one for
release asset I/O) and required separate Forgejo PATs to manage.
Replacing with a single CI_TOKEN keyed off a single PAT with the
union of scopes (write:repository + write:package).

- flutter.yml: APK attach step → CI_TOKEN
- release.yml: docker login → CI_TOKEN; APK fetch step → CI_TOKEN

No behavior change — same calls, single secret name. Operator needs
to set CI_TOKEN in repo settings (Actions → Secrets) with a PAT that
has write:repository + write:package; the prior REGISTRY_TOKEN /
RELEASE_TOKEN secrets can be removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:33:10 -04:00
bvandeusen 04933f2d9f Merge pull request 'M5–M7 sweep + offline cache + in-app updates + DRY pass' (#35) from dev into main 2026-05-11 01:09:36 +00:00
bvandeusen 154f415f92 fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:27 -04:00
bvandeusen 2435ef7961 feat(web): MobileAppDownload component on login + settings (#397 follow-up)
Closes the discoverability gap on the in-app update flow — the
/api/client/apk endpoint exists but had no web UI surface to find
it. User asked to be able to "visit the site on my phone and
download the apk from there."

- web/src/lib/components/MobileAppDownload.svelte — fetches
  /api/client/version once on mount; renders a download link with
  version + size when 200; renders nothing on 404 (no APK bundled,
  graceful degradation in dev environments and pre-CI-wiring images).
- Mounted on the login page (below the Register link) so the link
  is discoverable without authentication. The /api/client/* endpoints
  are themselves unauthed, so the flow works end-to-end for any
  visitor on a phone.
- Also mounted in Settings → Mobile app section for logged-in users
  who want to grab the matching APK for sideloading on a different
  device.

Browser handles the download via the server's existing
Content-Disposition: attachment; filename="minstrel.apk" header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:23:56 -04:00
bvandeusen f6e27cd39a fix(flutter/test): isVersionNewer date-style assertion was wrong
pub_semver is permissive with leading zeros — '2026.05.10' parses as
2026.5.10, so date-style tags get strict semver ordering, not the
unparseable-fallback path. The test was asserting "any difference =
newer" for date strings, which is incorrect; the actual behavior
(and the right behavior) is proper ordering.

Split the test group into two:
- date-style (parses as semver): newer/older/equal assertions
- truly unparseable (e.g. branch names like 'main', 'dev'): the
  fallback's "any difference = newer" semantics still apply

Implementation in client_update_provider.dart unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:53 -04:00
bvandeusen f3378dd610 fix(flutter): apply Riverpod 3 NotifierProvider conversion missed in 14d7678
The previous fix-forward intended this change but the Edit didn't
make it into the commit (only the Go side landed). Re-applying the
StateProvider → NotifierProvider conversion so flutter analyze stops
failing on the undefined-function for StateProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:02:29 -04:00
bvandeusen 14d7678624 fix: CI lint errors after #397 commits
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
  _dismissedVersionsProvider with a small Notifier<Set<String>>
  + NotifierProvider, mutating via an `add(version)` method.
  Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
  unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
  os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
  on test cleanup, no manual Unsetenv needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:01:43 -04:00
bvandeusen e57a53a92e ci(server): bundle Android APK into image on tag releases (#397 phase 3)
Final phase of the in-app update flow. release.yml on tag pushes
fetches the APK that flutter.yml is attaching to the same release,
drops it into client/ in the build context, and the Dockerfile's
COPY client/ /app/client/ bakes it into the image.

### How it sequences

flutter.yml and release.yml both trigger on tag pushes and run in
parallel on different runners (flutter-ci vs go-ci). flutter.yml
typically finishes APK build + release attachment in 2-5 min.
release.yml polls the release page for up to 15 min for the APK to
appear, then proceeds. If the APK never lands (flutter.yml failure,
network hiccup), release.yml emits a warning and ships the image
without the bundled APK — server returns 404 from /api/client/version,
banner stays hidden, manual download from the release page still
works. Graceful degradation, not a blocker.

### Repo shape

- client/.gitkeep + client/README.md so the directory exists in git
  and the COPY in the Dockerfile always finds something to copy
- .gitignore excludes client/minstrel.apk + .version so accidental
  commits don't bloat the repo
- Dockerfile: COPY --chown=minstrel:minstrel client/ /app/client/

### Why polling vs cross-workflow trigger

Forgejo Actions' workflow_run support varies by runner version; the
polling approach is universally compatible. If/when we standardize on
a runner that handles workflow_run cleanly, the polling step can
become a `needs:` dependency.

Closes #397.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:44:08 -04:00
bvandeusen 02d9f39845 feat(flutter): in-app update banner + Android install intent (#397 phase 2)
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.

### Dart side

- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
  polling on app start + every 24h. Compares semver via pub_semver
  with non-semver string-equality fallback. Server 404 = "no update
  channel" = silent. Companion shouldShowUpdateBannerProvider gates
  on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
  the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
  downloading (with progress) / error stages. Mounted in
  _ShellWithPlayerBar above the route content; SizedBox.shrink()
  when no update available so non-shell routes (login, server-url)
  see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
  semver, prerelease ordering, leading-v normalization, and
  non-semver fallback (date-tag style).

### Android side

- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
  FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
  downloaded APK can be served via content:// URI (file:// is
  blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
  URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
  so the system installer can read across the process boundary.

Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:42:04 -04:00
bvandeusen a2bea8601a feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."

- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
  Both unauthenticated (matches /healthz) so install flow doesn't
  depend on a live session. APK served with proper
  application/vnd.android.package-archive Content-Type +
  http.ServeContent so Range requests work for resumable downloads
  on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
  env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
  sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
  both present (200 with correct shape), apk stream (200 with
  correct Content-Type + body bytes).

Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:35:24 -04:00
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -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 861dd8ef17 feat(web): auto-poll request progress when ingests are in flight (#369)
Half of #369 — the auto-poll piece. Inbox deferred (separate task).

Both /requests (user) and /admin/requests (admin, 'approved' tab only)
now refetch every 12s while at least one row has status='approved'.
Stops automatically when all rows settle to pending/completed/rejected.
TanStack Query's default refetchIntervalInBackground=false handles
the visibility behavior — polling pauses when the tab is hidden and
resumes on focus.

Predicate hasInFlightRequest() is exported + tested so the polling
logic is auditable independent of TanStack Query's internals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:59:38 -04:00
bvandeusen 7bc254e17e feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.

- preload="metadata" → "auto" on the main audio element so the
  browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
  events. Schedules a reload-from-current-position after delayMs
  (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
  triggers immediate retry. Bounded by maxRetries (3 default) to
  prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
  resolve(track) → URL + optional prefetch(track) hint. Default
  returns track.stream_url; the planned Tauri shell can swap via
  setAudioLoader() to return blob URLs backed by a Rust cache,
  mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
  start loading queue[index+1]. On track-end the swap is gap-free
  via browser HTTP cache + audio buffer warm-up.

Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:43:41 -04:00
bvandeusen 1daf0b7fdf feat(web): close #358 invites RowActionsMenu wiring
The mobile-responsive design called for all 4 admin tables to use
RowActionsMenu. Requests + Quarantine + Users users-section landed
in earlier commits; the invites section in the same users page was
still rendering inline Copy/Revoke buttons. Now uses RowActionsMenu
with primary Copy + secondary Revoke (danger).

Extend expiry from the design's secondary list deferred — no
server endpoint exists yet (lib/api/admin.ts has no
extendInviteExpiry).

Closes the in-code portion of #358. Real-device verification
walkthrough at 375/414/768 + grid/safe-area checks remain
operator-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:14:18 -04:00
bvandeusen 308202a1df refactor(web): extract CardActionCluster (#375 item #8 — narrowed scope)
The MediaCard consolidation in the discovery doc would have needed
~14 props to cover the differentiating bits (wrapping element a vs
button, art-shape, art-fallback strategy, click semantics, title
align/size, subtitle count). That's a god-prop blob that's harder to
reason about than three explicit cards.

Narrowed to the actually-drifted bit: the action cluster
(LikeButton + Plus + bottom-right menu slot, including the
stopPropagation defensive wrappers). One small component covers
AlbumCard / ArtistCard / CompactTrackCard so the focus-ring and
button-styling drift can't recur.

Each card keeps its own data-fetching, art container, and wrapping
element since those have load-bearing differences. The consolidation
addresses drift, not LOC for its own sake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:58:17 -04:00
bvandeusen 9b4f907db6 refactor(web): sweep 24 test files to use makeTrack/makeTracks fixtures (#375)
Replaces inline TrackRef literal redefinitions with calls to the
test-utils helper. Tests that asserted on default field values
(e.g. track titles, artist names rendered in the DOM) keep explicit
overrides; tests that only need a stub for shape now use makeTrack()
with no overrides.

PlaylistCard.test.ts, PlaylistTrackRow.test.ts, and playlist.test.ts
SKIPPED — they use PlaylistTrack (with track_id/added_at), not TrackRef.
ArtistCard.svelte and +page.svelte route files (matched by initial grep)
SKIPPED — live code, not test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:58:14 -04:00
bvandeusen 993bcc6a14 fix(web/test): update playlist API tests for api.* call surface (#375)
After e8eff1b migrated playlists.ts from raw apiFetch to the api.*
wrapper, three test files mocked the wrong surface:

- playlists.test.ts: GET case asserted init.method === 'GET' but
  api.get omits init.method entirely (fetch defaults to GET). Fall
  back to 'GET' when init.method is undefined.
- playlists.refresh-discover.test.ts: re-mock ./client to expose
  `api: { post: vi.fn() }` instead of `apiFetch`; assertions check
  api.post call args.
- playlists.refresh-foryou.test.ts: same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:48:04 -04:00
bvandeusen 2ec54bc429 refactor(web): sweep playlist.test.ts to use emptyQuarantineMock() (#375)
Replaces the hand-rolled { subscribe, data } quarantine stub with the
emptyQuarantineMock() helper. readable() satisfies the same store
contract the page actually uses (subscription via $store), and the
playlist tests don't assert on quarantine state — the mock is purely
a transitive module-load satisfier (PlaylistTrackRow → TrackMenu).

Companion to commit 7e8d196 (likes sweep), which initially skipped
this file because it shared the hand-rolled stub shape with the likes
mock; the quarantine half can safely move to the helper.

Five other files in the original 8-file batch (PlaylistTrackRow,
TrackMenu, TrackRow, PlayerBar, CompactTrackCard) were absorbed into
that likes-sweep commit; search/tracks was absorbed into commit
dd67f28 (pageUrl sweep). hidden.test.ts SKIPPED — it imports
createMyQuarantineQuery as a vi.fn() to mockReturnValue per-test and
asserts on unflagTrack call args; the helper would defeat both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:57 -04:00
bvandeusen 7e8d19606a refactor(web): sweep test files to use empty likes/quarantine mocks (#375)
Replaces duplicated inline vi.mock('$lib/api/likes', ...) blocks with
the emptyLikesMock() helper, and (where previously left in working
tree by a parallel quarantine sweep) rolls in the matching
emptyQuarantineMock() switchovers in the same files.

Together with commit dd67f28 — which already swept the four search/*
test files for likes — this completes the 18 likes-mock conversions
called out in #375.

LikeButton.test.ts kept inline — it uses a state-driven mock to
assert the toggle behavior.

SKIPPED:
- liked.test.ts: re-exports createLikedTracksInfiniteQuery and the
  album/artist variants that emptyLikesMock() doesn't provide.
- playlist.test.ts: hand-rolled { subscribe, data } stub instead of
  readable(...) — different store contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:41:15 -04:00
bvandeusen dd67f28745 refactor(web): sweep 8 test files to use pageUrlModule() (#375)
Centralizes the inline { page: { get url() { return state.pageUrl } } }
mock shape via test-utils/mocks/appState.ts. The vi.hoisted state
declaration remains per-file (vitest mock-hoisting requires module-
level declaration). Adds a $test-utils alias to svelte.config.js so
the helper can be imported without ../../../ chains.

SKIPPED (mock exposes more than url):
- Shell.test.ts, MobileNavDrawer.test.ts, admin.test.ts: static
  page: { url: ... } shape, no hoisted state to centralize.
- album.test.ts, artist.test.ts: page exposes both params and url.
- playlist.test.ts, reset-password.test.ts: page exposes params only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:39:25 -04:00
bvandeusen 6e0223a9b1 refactor(web): runMutation helper for generic-toast admin handlers (#375)
Wraps the dominant pattern (try { await fn } catch { pushToast(errMessage,
'error') }) for the 5 admin/+page.svelte handlers that surface errMessage
directly: onApprove, onReject, onResolve, onDeleteFile, onDeleteLidarr.

Tighter scope than originally planned — the 3 handlers in
admin/users/+page.svelte (onToggleAutoApprove, onGenerateInvite,
onRevokeInvite) use errCode + verb prefix ("Generate failed: ${code}")
which is structurally different and would change UX wire output if
forced through the helper. Skipping them keeps behavior identical.

The helper SWALLOWS errors so callers no longer need try/finally for
busy-state — `saving = true; await runMutation(...); saving = false;`
is correct because runMutation can't throw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:39:00 -04:00
bvandeusen 0eaaf66748 refactor(web): playlistTrackToRef helper to dedupe TrackRef
reconstruction (#375)

PlaylistTrackRow.svelte and PlaylistCard.toTrackRefs both rebuilt
TrackRef from PlaylistTrack with the same field-by-field literal.
Server adding a new TrackRef field would have needed both sites
updated; now one helper owns the mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:37:11 -04:00
bvandeusen 633406c05b refactor(web): safeLocalStorage helper for store persistence (#375)
Three nearly-identical try/catch wrappers across theme + player stores
collapse to read()/write()/remove() in lib/util/safeLocalStorage.ts.
Sets the pattern for future stores. Caller still does parse/serialize
since the existing call sites store strings (theme preference, volume
number) — no JSON wrapper needed yet.

persisted.ts left alone — its JSON-payload + per-key-suffix shape is
distinct enough to keep self-contained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:45 -04:00
bvandeusen 2357df83be refactor(web): add test-utils helpers for likes/quarantine/track/appState mocks (#375)
Pre-sweep checkpoint. Adds:
- fixtures/track.ts        — makeTrack() / makeTracks(n) (18 files duplicate today)
- mocks/likes.ts           — emptyLikesMock() (19 files duplicate today)
- mocks/quarantine.ts      — emptyQuarantineMock() (9 files duplicate today)
- mocks/appState.ts        — pageUrlModule(state) (8 files duplicate today)

The sweep commits that follow update test files to use these helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:31:53 -04:00
bvandeusen e8eff1baa1 refactor(web): migrate playlists.ts to api.* wrapper (#375)
Drop 9 hand-rolled apiFetch calls with redundant Content-Type
headers + manual JSON.stringify. The api.* wrapper in client.ts
already handles both. Identical wire shape; existing tests cover
all endpoints.

Also makes api.del generic so DELETE endpoints with typed returns
(playlist track removal) can use the wrapper instead of raw
apiFetch. Default `T = null` preserves existing callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:30:34 -04:00
bvandeusen 714039bae5 refactor(web): drive vite-injected theme-color from tokens.json (#375)
Closes the last theme-color hardcode. Runtime side already imports
tokens.colors.{dark,light}.obsidian via applyMetaThemeColor; build
side now matches via JSON import-attributes. Drops the // TODO(#375)
tripwire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:29:48 -04:00
bvandeusen 65057473c6 refactor(web): break auth ↔ player import cycle via session-end signal (#375)
auth/store.svelte.ts no longer imports from $lib/player. Logout
broadcasts via auth/sessionEnd.svelte.ts (a tiny tick + outgoing
userId pair); player subscribes through $effect.root and owns the
player-specific teardown (clearPersistedQueue + playQueue([]) +
closeQueueDrawer()).

Cycle fully inverted — auth is now a pure leaf for player; future
session-end consumers (download cache, recent-search history) plug
in by adding their own effect on the same signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:29:30 -04:00
bvandeusen 94727f40bb fix(flutter): analyze errors after Plan C provider migrations
- cache_first.dart: backtick-fence List<T> doc comment to dodge
  unintended_html_in_doc_comment.
- library_providers.dart:163: switch single-row insert to
  insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict*
  variant.
- 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>,
  not Create<Future<T>>. Wrap data emissions in Stream.value(...).
- artist_detail_screen_test: add models/album.dart import for AlbumRef
  type annotation.
- track_actions_sheet_test: drop _StubLiked extends LikedIdsController
  (notifier no longer exists post-migration); override with
  Stream.value(LikedIds(...)) instead.
- like_button_test: rollback assertion now requires drift writes via
  LikesController. Skip under _skipDrift until libsqlite3-dev lands on
  the runner image (Fable #399). Replace stale .notifier reference
  with likesControllerProvider for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:27 -04:00
bvandeusen 98fdd9dcdf feat(flutter/cache): migrate playlist providers to drift-first (#357 plan C)
playlistsListProvider — StreamProvider over cached_playlists. Returns
all user-owned + others' public playlists. The 'kind' family arg only
affects the REST cold-cache fetch (server-side filter); drift can't
distinguish 'user' from 'system' kind because systemVariant isn't
persisted. v1 limitation: add-to-playlist sheet may briefly show
system playlists too. Follow-up: add systemVariant column + schema bump.

playlistDetailProvider — async* over the playlist watch stream + a
one-shot tracks query per emission (joins playlist_tracks → tracks →
artists → albums for snapshot fields). Cold-cache fallback inserts
playlist + playlist_tracks rows in one batch, then awaits re-emission.

Pull-to-refresh on these screens now triggers re-subscription rather
than forcing a REST fetch — drift is the source of truth and stays
fresh via SyncController's delta sync. Documented behaviour shift; if
operator wants force-refetch we can add it via a sync trigger later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:27:49 -04:00
bvandeusen 08c16d6d86 fix(flutter): point like consumers at likesControllerProvider
Two consumer updates left out of 8a6c926: like_button.dart and
track_actions_sheet.dart still referenced the removed
likedIdsProvider.notifier API. Switched to the new
likesControllerProvider.toggle().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:21:47 -04:00
bvandeusen 8a6c926ea7 feat(flutter/cache): migrate likedIdsProvider to drift-first; LikesController for mutations
Splits the previous LikedIdsController into two:

- likedIdsProvider — StreamProvider reading from cached_likes via
  drift watch(). Reactive: SyncController writes propagate
  automatically. Empty + online triggers REST cold-cache fallback
  that populates cached_likes via insertOrIgnore (sync may have
  already written some rows).

- likesControllerProvider (new) — exposes toggle(LikeKind, id).
  Optimistic: writes drift first (UI updates instantly via the
  watch stream), then REST. Rolls back drift on REST failure.

Two consumer updates: like_button.dart + track_actions_sheet.dart
switch from likedIdsProvider.notifier.toggle to
likesControllerProvider.toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:20:59 -04:00
bvandeusen fc83c7b514 feat(flutter/cache): migrate artistAlbums/artistTracks/album to drift-first
Three providers all become StreamProviders driven by drift watch():

- artistAlbumsProvider: cacheFirst over the join of cached_albums +
  cached_artists for artist_name on each row.
- artistTracksProvider: cacheFirst over the join of cached_tracks +
  cached_artists + cached_albums for snapshot fields.
- albumProvider: composite ({album, tracks}) shape, so uses async*
  with two queries (album + tracks) for cleaner reactive behavior.
  Cold-cache fallback inlined; populates both album and tracks tables
  in one batch.

.future call site in artist_detail_screen.dart left as-is —
StreamProvider.future returns the next emission with the same
signature, so no consumer change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:19:47 -04:00
bvandeusen 947d4401a6 feat(flutter/cache): migrate artistProvider to drift-first (#357 plan C)
artistProvider becomes a StreamProvider backed by drift's cached_artists
watch(). Empty + online triggers REST fallback; populates drift; watch
re-emits. Empty + offline yields a sentinel ArtistRef(id: '', name: '');
surfaces should already check state.value?.id.isNotEmpty.

AsyncValue<ArtistRef> shape unchanged so .when callsites keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:59 -04:00
bvandeusen 4eb9935f8e feat(flutter/cache): cacheFirst helper + drift↔model adapters (#357 plan C)
Foundation for the provider migrations. cacheFirst<D, T> wraps the
drift.watch() + REST cold-cache fallback pattern: yields cached rows
when present, kicks off REST fetch + drift populate when empty +
online, yields empty when offline. REST failures swallow to empty so
callers can surface errors via toast.

adapters.dart adds CachedX → XRef extension methods + reverse
XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters
accept some loss of server-derived fields (coverUrl, streamUrl,
ownerUsername) — UI already handles empty values; cold-cache fallback
briefly shows the real values before drift takes over.

cache_first_test covers all 4 branches (non-empty, empty+online,
empty+offline, REST failure). adapters_test covers basic round-trips.
Both safe to run on CI runner — no drift open required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:29 -04:00
bvandeusen 2940e95b64 fix(flutter/test): skip TrackRow smoke test under the drift cohort
Pending-timer failure: CachedIndicator (now in TrackRow) reads
audioCacheManagerProvider, which constructs AppDb via drift_flutter's
driftDatabase(), which calls libsqlite3 — missing on the flutter-ci
runner. The async chain leaves a pending timer at test teardown.

Skip cohort matches sync_controller / audio_cache_manager / storage_section
tests; all re-enable once Fable #399 lands the runner image fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:49:03 -04:00
bvandeusen a3db6c9453 fix(flutter/test): testWidgets skip takes bool, not String
CI analyze flagged storage_section_test.dart's `skip: _skipDrift` calls.
testWidgets has signature `skip: bool?`; only test() takes `String?` as
the skip-reason. Changed _skipDrift in this file to `const bool = true`
and moved the rationale into a comment. The other two drift-cohort
files (sync_controller_test, audio_cache_manager_test) use test() so
their String const is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:41:48 -04:00
bvandeusen 1b223d3891 fix(flutter/test): skip drift tests pending libsqlite3 in CI runner image
CI's flutter-ci runner doesn't ship libsqlite3.so. drift's NativeDatabase
fails at first use ("Failed to load dynamic library 'libsqlite3.so'").
Affected files:
  - test/cache/sync_controller_test.dart (4 tests)
  - test/cache/audio_cache_manager_test.dart (5 tests)
  - test/settings/storage_section_test.dart (2 tests, was silently
    succeeding because the drift call was best-effort but emitted
    multiple-AppDb warnings)

All 11 marked with skip: '...' + a top-level @Tags(['drift']) library
declaration so they can be re-enabled by tag once the runner image has
libsqlite3-dev installed (or once we move VM tests to sqlite3/wasm).
On-device verification covers the actual cache + sync logic.

Plus two collateral fixes:
  - test/cache/connectivity_provider_test.dart: connectivity_plus needs
    a platform channel that doesn't exist in unit tests; reduced to a
    non-null import smoke check.
  - test/library/widgets_smoke_test.dart: TrackRow now contains
    CachedIndicator (ConsumerWidget); wrapped TrackRow test in
    ProviderScope.

Filing follow-up for the runner image fix in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:27:49 -04:00
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00
bvandeusen 5114a8118c feat(flutter): CachedIndicator + Download buttons + app startup wiring
CachedIndicator (lib/library/widgets/cached_indicator.dart) — small
download glyph rendered next to a track row when AudioCacheManager
reports the track as cached. FutureBuilder one-shot.

Wiring:
  - track_row.dart: render CachedIndicator before the duration label
  - playlist_detail: 'Download' OutlinedButton next to Play; pins all
    playable tracks with source: autoPlaylist + SnackBar feedback
  - album_detail: 'Download' IconButton in the header; same pin pattern
  - app.dart: now ConsumerStatefulWidget — initState fires the initial
    sync + activates the prefetcher provider

Together these complete the operator-facing surfaces of the offline
slice: visible cache state, explicit download trigger, automatic
sync + queue-ahead prefetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:23:26 -04:00
bvandeusen fd4ce402d7 feat(flutter/settings): Storage section UI — usage, cap, prefetch, controls
New StorageSection widget mounted in SettingsScreen between Appearance
and Password sections. Surfaces:

  - Cache usage display (live, refreshed on cap change + on Clear)
  - Cache size limit dropdown (1 / 5 / 10 / 25 GB / Unlimited)
  - Pre-fetch ahead dropdown (1 / 3 / 5 / 7 / 10 tracks)
  - Cache liked tracks switch
  - Clear cache button (confirm dialog, then clearAll)
  - Sync now button (triggers SyncController.sync(), spinner while inflight)

Cap change triggers immediate eviction. Clear cache wipes EVERYTHING
(manual-pinned tracks too) per the operator-facing copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:34 -04:00
bvandeusen 153e931490 feat(flutter/player): audio_handler reads from cache first; LockCaching fallback
_buildAudioSource is now async and cache-aware:
1. Cache hit  → AudioSource.uri(file://path)
2. Cache miss with manager → LockCachingAudioSource (cache-as-you-play)
3. No manager configured → plain AudioSource.uri (legacy fallback)

playerActionsProvider.playTracks now passes audioCacheManager into
configure() alongside coverCache. setQueueFromTracks awaits the source
build (Future.wait over the track list).

Out of scope: registering an index row when LockCachingAudioSource
finishes downloading (no clean hook from just_audio). Prefetcher /
Download buttons cover the index path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:20:24 -04:00
bvandeusen 536350380f feat(flutter/cache): Prefetcher — queue-ahead pre-download window
Listens to mediaItemProvider + cacheSettingsProvider. On track change
(or settings change), computes the [currentIdx, currentIdx + N] window
in the queue and pins each uncached track via AudioCacheManager with
source: autoPrefetch.

Window N comes from cacheSettingsProvider.prefetchWindow (default 5,
configurable in Settings → Storage card).

Smoke-test only at the unit level — real coverage via on-device pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:19:01 -04:00
bvandeusen ab58f3ffcb feat(flutter/cache): SyncController — token-based delta sync
Drives /api/library/sync against drift:
  - reads cursor from SyncMetadata (default 0 = full snapshot)
  - 204 → just bump lastSyncAt, return zeroes
  - 410 → wipe all cached entities + retry from cursor=0
  - 200 → apply upserts + deletes per entity type, advance cursor

Handles all 8 entity types (artist/album/track + like_track/like_album/
like_artist + playlist/playlist_track) for both upsert and delete paths.
Composite-key entities use the "<a>:<b>" string format the server emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:18:15 -04:00
bvandeusen 16669a0365 feat(flutter/cache): AudioCacheManager — pin/unpin/evict + tiered LRU
API:
  - isCached(trackId), pathFor(trackId)
  - pin(trackId, source) — dio.download → drift index entry
  - unpin(trackId), clearAll()
  - usageBytes(), evict(targetBytes)

Eviction order: incidental → autoPrefetch → autoPlaylist → autoLiked.
'manual' never evicts via cap-driven eviction; only clearAll() removes
operator-pinned tracks.

Two providers:
  - appDbProvider — singleton AppDb per run
  - audioCacheManagerProvider — wraps the AppDb + dioProvider

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:17:14 -04:00
bvandeusen 2bcbb53c49 feat(flutter/cache): connectivityProvider + cacheSettingsProvider
connectivityProvider — StreamProvider<bool>; true when any connectivity
result is non-none. No Wi-Fi gate per operator decision.

cacheSettingsProvider — AsyncNotifier<CacheSettings> persisting
capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage.
Defaults: 5 GB cap, 5-track prefetch, cache liked = on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:15:17 -04:00
bvandeusen 326b7008a9 feat(flutter/cache): drift schema + new deps + CI codegen step (#357 plan B)
Adds the offline-mode foundation:
- pubspec deps: drift, drift_flutter, sqlite3_flutter_libs,
  connectivity_plus + drift_dev/build_runner (dev)
- lib/cache/db.dart with 8 tables: CachedArtists/Albums/Tracks/Likes/
  Playlists/PlaylistTracks + AudioCacheIndex + SyncMetadata
- *.g.dart added to .gitignore (build_runner regenerates per build)
- CI workflow gains a 'Codegen (drift)' step between pub get and
  analyze so the generated symbols exist when analyze runs

CacheSource enum drives tiered LRU eviction (manual/autoLiked/
autoPlaylist/autoPrefetch/incidental). Subsequent commits add the
sync controller, audio cache manager, prefetcher, settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:07:34 -04:00
bvandeusen 8d5c90e0ed fix(server): wrap defer tx.Rollback for errcheck + gofmt -s alignment
golangci-lint surfaced both on 9c7dec6:
- errcheck on bare 'defer tx.Rollback(ctx)' in 2 test files
- gofmt -s wanted tighter map-key alignment in library_sync.go +
  library_sync_test.go (auto-fixed by 'gofmt -s -w')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:47:25 -04:00
bvandeusen 9c7dec60a9 fix(server): go mod tidy after sync_test brings testify direct
internal/sync/changes_test.go imports github.com/stretchr/testify/require,
moving testify from indirect to direct. Also pulled in testify's own
indirects (go-spew, go-difflib) and promoted pgerrcode to direct.

Caught by CI go vet on 6fb6729.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:45:15 -04:00
bvandeusen 6fb6729beb feat(server): playlists service writes library_changes
Five sites wired:

- Create / Update / Delete playlist: pool-bound LogChange after success
- AppendTracks: tx-bound LogChange per inserted playlist_track row
- RemoveTrack: tx-bound LogChange after the delete + lookup the
  track_id pre-delete so the composite key is still resolvable

The two tx-bound paths (AppendTracks, RemoveTrack) treat LogChange as
required — failure rolls back the playlist mutation too. The pool-bound
paths Warn on failure to match the scanner / likes pattern (mutation
already committed; missed log row recovers on next mutation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:41:59 -04:00
bvandeusen e0c5789cee feat(server): like/unlike handlers write library_changes
All 6 like/unlike sites (track/album/artist × like/unlike) now emit a
library_changes row via the shared logLikeChange helper. Best-effort:
LogChange failures Warn but don't fail the HTTP response — a missed
log row is recovered at the next mutation on the same entity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:40:56 -04:00
bvandeusen 0fa7dc7982 feat(server): scanner + DeleteTrackFile write library_changes
Wires sync.LogChange into the library mutation sites so /api/library/sync
reflects upserts and deletes.

Architectural pivot: LogChange's signature is now (ctx, dbq.DBTX, ...) so
it works with both *pgxpool.Pool and pgx.Tx. The scanner doesn't run
mutations in explicit transactions, so it pool-binds; delete.go matches.
Tx-bound callers (likes/playlists in subsequent commits) keep atomicity.

Also: sync.FormatUUID centralizes the pgtype.UUID → canonical string
conversion that both the scanner and the sync handler need; library_sync.go
now uses it instead of a local copy.

Best-effort logging on scanner failures (Warn, don't fail the scan): a
LogChange error after a successful upsert is rare and self-healing — the
next scan that touches the entity re-emits the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:39:58 -04:00
bvandeusen 6bd8a15c7a feat(server): mount /api/library/sync route
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:46 -04:00
bvandeusen 9bf4b504b3 feat(server): GET /api/library/sync endpoint
Returns batched upserts + deletes since the supplied cursor. Empty cursor
returns full snapshot; subsequent calls pull deltas. Per-user entities
(likes, playlists) are scoped to the authed user. Composite-key entities
(likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID
/ sync.EncodePlaylistTrackID.

Behavior:
  204 No Content - no changes since cursor
  200 OK         - JSON syncResponse {cursor, upserts, deletes}
  410 Gone       - cursor older than oldest log row; client must reset
  401 / 500      - standard envelope errors

Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to
mirror the existing GetArtistsByIDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:26 -04:00
bvandeusen 3959c85111 feat(server): sync.LogChange helper + EntityType/Op constants
New package internal/sync. LogChange writes a library_changes row inside
the supplied tx. Encode helpers produce stable composite ids for like_*
and playlist_track entries. Subsequent commits wire LogChange into the
scanner / likes / playlists services.

Also: dbtest.dataTables now includes library_changes so test isolation
holds across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:29:51 -04:00
bvandeusen cb35133843 feat(server): library_changes log table + sqlc queries
Append-only change log for library entities. Every mutation on
artists/albums/tracks/likes/playlists/playlist_tracks will write a row
in the same transaction as the mutation itself (wired in subsequent
commits). Powers the Flutter delta-sync endpoint (#357).

- 0025_library_changes migration (up + down)
- internal/db/queries/library_changes.sql (Insert, GetSince, MaxCursor, MinCursor)
- regenerated dbq from sqlc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:28:26 -04:00
bvandeusen 084c202654 fix(web/test): MobileNavDrawer inert + integrations Save mocks Test first
MobileNavDrawer: Svelte 5 + jsdom binds `inert` as a property, not always
an attribute. Mirror the QueueDrawer.test.ts pattern that accepts either.

Integrations Save tests: Per commit bca8622 (Save runs Test first), the
two existing Save tests need to mock testLidarrConnection before clicking
Save — otherwise the Test step returns undefined, fails the ok check,
and putLidarrConfig is never reached. The newer 'Lidarr first-time
setup' suite already does this; just bringing the older two tests in
line. Also wrapped the assertions in waitFor since the put-config call
now resolves async after the test promise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:04 -04:00
bvandeusen 3ffd9beca0 fix(web): preserve per-row aria-labels through RowActionsMenu + test fixes
CI vitest run on 3f8a2c5 surfaced 17 failures across 5 test files; this
commit addresses 15 that are this batch's responsibility.

1. RowActionsMenu now accepts ariaLabel on RowAction. Defaults to label.
   Admin pages (requests/quarantine/users) pass per-row aria-labels
   matching the pre-batch buttons ("Approve Geogaddi", "Resolve Roygbiv",
   "Make alice admin", etc.) so screen readers + tests find them.

2. PlayerBar.test.ts — anchored regex /^(play|pause)$/i so the new
   "Player options" overflow ⋮ doesn't also match /play|pause/i.

3. MobileNavDrawer.test.ts — added vi.mock for $app/state, $app/navigation,
   and $lib/auth/store.svelte (mirrors Shell.test.ts pattern). Without
   these, SvelteKit's notifiable_store helper isn't bootstrapped in
   vitest and the suite fails to load.

The 2 remaining vitest failures are in /admin/integrations Save flow
(putLidarrConfig spy not called). Untouched by this batch and the
recent "Save runs Test first" refactor (bca8622) appears related —
flagging for operator verification, not chasing as a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:16:48 -04:00
bvandeusen 3f8a2c511a fix(web): MobileNavDrawer focus uses querySelector, not conditional bind:this
bind:this requires an Identifier or MemberExpression — a ternary
like {i === 0 ? firstNavLink : undefined} is invalid. Replaced
with a single bind on the <aside> root, then querySelector('nav a')
to locate the first nav link at focus time. Same behaviour, valid
Svelte.

Caught by CI svelte-check on 268e12a (failed before the @const
fix landed in 1536860):
  src/lib/components/MobileNavDrawer.svelte:91:13
  https://svelte.dev/e/bind_invalid_expression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:44 -04:00
bvandeusen 1536860e59 fix(web): move {@const} blocks to immediate children of {#each}
Svelte 5 requires {@const} to be a direct child of certain blocks
({#snippet}, {#if}, {#each}, etc.). Placing them inside <li>
bodies broke the build with const_tag_invalid_placement. Moved
the RowAction const declarations up so they sit between {#each}
and the <li> opener; the each-binding (u/r) is in scope for the
entire each block body, so the consts behave identically.

Caught by local docker build on dev:
  src/routes/admin/users/+page.svelte:259:12
  https://svelte.dev/e/const_tag_invalid_placement

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:04 -04:00
bvandeusen 268e12a5eb feat(web): Modal overlay gets p-4 so max-w-md modals fit on phone
Without padding, max-w-md (448px) modals touch the viewport edges
on a 375px screen. p-4 on the overlay clamps them inside the safe
area. Covers all routes that use the shared <Modal> component
(discover track-confirm, quarantine typed-DELETE, users
password-reset, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:52 -04:00
bvandeusen 16e0e98943 feat(web): mobile mechanical sweep — pointer-aware arrows, h3 dividers, modal safe-area
- HorizontalScrollRow arrows bump 32px → 40px on coarse pointers
  (touch screens) for finger-friendly hits; mouse pointers unchanged.
- AlphabeticalGrid divider letter is now <h3> so screen readers
  announce it as a section heading. Margin reset to preserve layout.
- Modal overlay gets p-4 so max-w-md modals stay inside the safe
  area at 375px viewport. Covers all routes that use the shared
  <Modal> component (discover track-confirm, quarantine
  typed-DELETE, users password-reset, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:38 -04:00
bvandeusen f03a0042e8 feat(web): wire RowActionsMenu in users admin page
Primary: toggle admin (most semantically distinct per-user action).
Secondary: toggle auto-approve, reset password, delete (danger).
'Delete' was previously styled as a regular border button — now
correctly marked danger. No Edit action existed; not adding one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:17 -04:00
bvandeusen e266a0f2dc feat(web): wire RowActionsMenu in requests + quarantine
Requests: primary Approve, secondary [Override, Reject].
Quarantine: primary Resolve, secondary [Play, Delete file]; the
Delete-via-Lidarr conditional stays inline above md (preserves the
disabled-with-tooltip semantics) and hides below md to avoid
crowding — the operator can still trigger it from the desktop view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:20:43 -04:00
bvandeusen e0cf527304 feat(web): RowActionsMenu generic primary + ⋮ for admin tables
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:19:09 -04:00
bvandeusen ed6d0936ca feat(web): PlayerBar reflows to two-row layout below md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:18:32 -04:00
bvandeusen d5983433c5 feat(web): PlayerOverflowMenu — shuffle/repeat/queue/volume in a menu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:18:10 -04:00
bvandeusen df4cb434b2 feat(web): hamburger trigger + mobile drawer mount in Shell
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:35 -04:00
bvandeusen 857a8621ec feat(web): MobileNavDrawer off-canvas nav for <md viewport
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:17 -04:00
bvandeusen 7f395024a0 feat(web): mobileNav store for off-canvas drawer state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:16:38 -04:00
bvandeusen 84a638a41f fix(flutter): clear analyze --fatal-infos issues
Three classes:

1. album_cover_cache.dart docstring used <applicationCacheDirectory>
   and <albumId> which the analyzer reads as unintended HTML. Wrapped
   the path in backticks and used {} placeholders.

2. settings AppearanceSection used RadioListTile.groupValue + onChanged,
   deprecated in Flutter 3.32+. Wrapped the radios in a RadioGroup
   ancestor (the new API) so individual tiles only declare value +
   activeColor. Test updated to read groupValue off the RadioGroup
   ancestor.

3. theme_extension.dart factories built FabledSwordTheme(...) without
   const, even though all args are static const tokens. Added const to
   the outer constructor calls in both .dark() and .light() — this
   propagates const to the inner TextStyle(...) calls automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:16:35 -04:00
bvandeusen dd848e76e8 feat(flutter/playlists): empty-state hint on empty playlist detail
When a playlist has zero tracks, the detail screen previously showed
just the header with no indication of what to do next. Adds an inline
hint mirroring the web's copy: "No tracks yet. Add some via the
\"Add to playlist…\" entry on any track row."

Implementation: itemCount goes from tracks.length + 1 to
(tracks.isEmpty ? 2 : tracks.length + 1) and the builder emits the
hint at index 1 when tracks is empty. Header still renders at index 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:07:41 -04:00
bvandeusen 0dbb662b4c feat(flutter): search no-results copy mirrors design-system voice
Follow-up to ccebd98 — the search_screen edit failed in that commit
because the file hadn't been read in the session. "No matches." →
"No matches for that query." Same scope as the larger sweep commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:07:19 -04:00
bvandeusen ccebd98735 feat(flutter): empty-state copy mirrors web design-system voice
Five terse "No X." strings replaced with the longer actionable forms
the web SPA uses on equivalent surfaces:

- Library Artists/Albums tabs: "No artists yet — scan a library folder
  via the server's config." (mirrors web /library/artists, /albums)
- Library Liked tab: "No liked artists, albums, or tracks yet."
  (consolidates web's per-section copy since Flutter shows all three
  in one tab)
- Search no-results: "No matches for that query." (web has per-section
  copy that doesn't fit Flutter's combined view)
- Discover no-results: "Nothing to add for that search yet." (mirrors
  web /discover)

Hidden tab keeps its current actionable hint (web's "Nothing hidden
yet." is terser but lacks the "use a track's menu to flag" guidance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:58 -04:00
bvandeusen 231f1ddf9a test(flutter/theme): cover .dark()/.light() factories + back-compat alias
Four cases: dark factory pulls from FabledSwordDarkTokens, light from
FabledSwordLightTokens, flat tokens (accent / moss / etc.) are shared
between both, and the fromTokens() back-compat alias returns the dark
theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:51 -04:00
bvandeusen 143130eaae feat(flutter/settings): Appearance section with System/Light/Dark picker
Three RadioListTiles between Profile and Password sections. Tap →
themeModeProvider.notifier.set(...) → MaterialApp rebuilds with the
new theme via the existing themeMode wiring.

System has the only subtitle ("Match the device setting") — the
light/dark options are self-explanatory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:29 -04:00
bvandeusen 56f0557d94 feat(flutter): MinstrelApp watches themeMode + offers darkTheme
MaterialApp now provides both light and dark themes plus a
themeMode bound to themeModeProvider. ThemeMode.system rebuilds
the tree automatically when the OS toggles brightness — no manual
listener needed.

While themeModeProvider is loading (storage read in flight), falls
back to AppThemeMode.system, which inherits the OS setting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:41 -04:00
bvandeusen 6dc488652e feat(flutter/theme): themeModeProvider with secure-storage persistence
AsyncNotifier wrapping flutter_secure_storage's "theme_mode" key.
Returns AppThemeMode.system as the default. set(mode) writes the
value + updates state.

Used by MinstrelApp (next commit) to drive MaterialApp.themeMode and
by the Settings appearance section to persist the operator's pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:00 -04:00
bvandeusen caa19f2d91 feat(flutter/theme): buildDarkTheme + buildLightTheme
Both builders construct a ThemeData with the matching brightness +
ColorScheme.dark/.light + the corresponding FabledSwordTheme
extension. buildThemeData() stays as a back-compat alias returning
the dark theme so the existing widget-test suite keeps using it
without churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:37 -04:00
bvandeusen 66ddd3c366 feat(flutter/theme): FabledSwordTheme.dark() + .light() factories
Both factories produce the same field shape (semantic role-based
names like obsidian/iron/parchment) with palette values from the
new FabledSwordDarkTokens or FabledSwordLightTokens generated
classes. fromTokens() stays as a back-compat alias returning the
dark theme so existing call sites keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:01 -04:00
bvandeusen b1f62a0733 feat(flutter/theme): codegen emits dark + light + flat token classes
gen_tokens.dart now produces FabledSwordDarkTokens (dark surface),
FabledSwordLightTokens (light surface), and FabledSwordFlatTokens
(colors that don't change + radii + fonts). The original
FabledSwordTokens class stays as a back-compat alias re-exporting
the dark surface + flat values; any code that reads
FabledSwordTokens.obsidian etc. continues to work during migration.

Source: shared/fabledsword.tokens.json (already had dark/light/flat
splits — this was the generator catching up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:45:40 -04:00
bvandeusen 25ff7afc33 feat(flutter): TrackRow renders TrackActionsButton by default
Adds optional actions: bool = true param. When true (default), renders
the 3-dot menu trigger at the end of the row. Album detail / Search /
History / Liked tabs all consume TrackRow so they pick up the menu
transitively.

Pair with c8baaa5 which wired the button into the other 3 surfaces;
this commit completes the wire-up by including TrackRow itself (its
write was missed in the previous commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:43:40 -04:00
bvandeusen c8baaa5329 feat(flutter): wire TrackActionsButton into 4 surfaces
- TrackRow gains an actions: bool = true param; renders the button
  after duration. Album detail + Search + History/Liked tabs all
  consume TrackRow so they pick up the menu transitively.
- CompactTrackCard (home Most-played) gets the button at the end of
  its inner row.
- _PlaylistTrackRow in playlist_detail_screen gets the button next
  to duration; skipped for unavailable rows (no track id).
- NowPlayingScreen renders the button next to the title with
  hideQueueActions: true (Play next / Add to queue suppressed since
  the menu's track IS the playing one). artistId is left empty since
  it's not stashed in MediaItem.extras — Go-to-artist will route to
  /artists/ which is benign for v1; a follow-up could stash it.

Closes Tier 1 follow-up #2 (track-level actions menu).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:43:12 -04:00
bvandeusen 36006d703d feat(flutter/track-actions): AddToPlaylistSheet picker
Modal bottom sheet listing the caller's user-created playlists (system
playlists like For-You are filtered out — they're not user-mutable).
Tap a row to pop with the playlist id; consumer (TrackActionsSheet)
then calls PlaylistsApi.appendTracks.

Empty-state copy: "You haven't created any playlists yet."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:42:19 -04:00
bvandeusen 2944288050 feat(flutter/track-actions): HideTrackSheet for reason + notes pick
Modal bottom sheet with five reason chips (mirroring server vocabulary
bad_rip / wrong_file / wrong_tags / duplicate / other) plus an optional
notes field. Returns (reason, notes) on Hide, null on Cancel. Default
selection is bad_rip.

Used by TrackActionsSheet's Hide action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:41:48 -04:00
bvandeusen 5f239f05a5 feat(flutter/track-actions): TrackActionsButton + TrackActionsSheet
The 3-dot trigger and the modal-bottom-sheet menu it opens. 7 actions:
Play next, Add to queue, Like/Unlike, Add to playlist, Go to album,
Go to artist, Hide/Unhide. hideQueueActions: true suppresses the
first two for the Now Playing surface.

Sub-sheets (HideTrackSheet, AddToPlaylistSheet) land in subsequent
commits — this commit imports them speculatively, so CI between this
and the next two commits will fail. Resolved by Tasks 5 + 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:41:16 -04:00
bvandeusen 2499449c0b feat(flutter/player): playNext + enqueue + PlaylistsApi.appendTracks
Three small data-layer additions for the upcoming track-actions menu:

- PlaylistsApi.appendTracks(playlistId, trackIds) wraps
  POST /api/playlists/{id}/tracks for the "Add to playlist" action.
- audio_handler gains playNext (insertAudioSource at currentIndex+1)
  and enqueue (addAudioSource) — both also push the audio_service
  queue notifier so the queue-screen UI stays in sync.
- The AudioSource construction was extracted into a private
  _buildAudioSource helper so setQueueFromTracks / playNext / enqueue
  share one source-building path.
- PlayerActions exposes playNext / enqueue for menu use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:40:17 -04:00
bvandeusen 5159bcd3f4 feat(flutter/quarantine): MyQuarantineController + provider
AsyncNotifier wrapping /api/quarantine/mine with optimistic flag /
unflag mutations and an isHidden(trackId) convenience for menu state.
Mirrors the LikedIdsController pattern: optimistic update, rollback
on server error.

Used by the next slice's TrackActionsSheet to power Hide / Unhide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 -04:00
bvandeusen fdaa1a0472 feat(flutter/api): QuarantineApi for flag/unflag
Dio client for /api/quarantine — flag a track with a reason + optional
notes, unflag by track id. Mirrors the server's user-scoped quarantine
endpoints (separate from /admin/quarantine).

Used by the track-actions menu's Hide/Unhide action in the next slice
of commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:38:52 -04:00
bvandeusen 534dafb044 feat(flutter/player): set MediaItem.artUri from local cover cache
Audio handler accepts an AlbumCoverCache via configure() and uses it
to populate MediaItem.artUri with a file:// URI to a locally cached
album cover. Lock screen, Bluetooth car displays, Wear OS, and CarPlay
now show the album cover for the currently playing track instead of
the system's generic music icon.

Flow:
- _toMediaItem stashes album_id in MediaItem.extras
- After setQueueFromTracks pushes the queue + initial mediaItem, fires
  _loadArtForCurrentItem async (doesn't block playback)
- Subscribes to _player.currentIndexStream so track advances trigger
  the same loader for the new current item
- _loadArtForCurrentItem early-returns if cache is null, no current
  item, no album_id, or artUri already set; otherwise calls
  cache.getOrFetch and pushes an updated MediaItem with artUri set
- Race guard: if the user skipped to another track while the fetch was
  in flight, the result is discarded

Closes the visible "generic music icon on lock screen" gap operator
flagged when first-testing on a real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:54:47 -04:00
bvandeusen d70075c426 feat(flutter/player): albumCoverCacheProvider + pass cache through configure
Adds the Riverpod provider (constructed with dioFactory pulling from
the authenticated dioProvider) and extends PlayerActions.playTracks
to forward the cache to audio_handler.configure(). The handler
signature change lands in the next commit.

Intentional intermediate-broken state: audio_handler.configure
doesn't yet accept coverCache. Resolved by the next task in the same
push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:54:05 -04:00
bvandeusen b6d6f22598 feat(flutter/player): AlbumCoverCache for lock-screen artwork
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.

Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.

No eviction — covers are tiny, OS clears cache when space is tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:20 -04:00
bvandeusen 78ad8a31e7 chore(flutter): promote path_provider to direct dep
Already a transitive dep via flutter_secure_storage and flutter_cache_manager.
Promoting to direct so we can import it from album_cover_cache (next
commit) for the lock-screen artwork cache directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:52:44 -04:00
bvandeusen d3a05520d3 fix(flutter): clear analyze --fatal-infos issues
Three info-level lints from CI:

- playlist_card.dart doc comment: "/playlists/<id>" → "/playlists/{id}"
  in backticks, since `<id>` was flagged as unintended HTML.
- compact_track_card_test.dart: home: Scaffold(...) is now const, which
  propagates const to CompactTrackCard and makes the inner [_track]
  implicitly const.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:05:02 -04:00
bvandeusen 17ff69780d test(flutter/home): cover new home parity layout
Expands the existing single test into four cases:
- 4 placeholder cards in Playlists row when no system playlists exist
- Empty-state copy for each section verbatim against the strings
  in HomeScreen
- Real For-You card renders with the system badge when present
- Existing rollups (Recently added / Rediscover) still render correctly
  alongside the new providers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:40:46 -04:00
bvandeusen a2f6bdb44f feat(flutter/home): mirror web home structurally
Rewrites HomeScreen body to match the web home layout:
- Playlists row: For-You + 3 Songs-like + user playlists, with
  PlaylistPlaceholderCard for empty slots driven by
  systemPlaylistsStatusProvider
- Recently added: 50 albums in 2 rows of 25 sharing a scroll controller
- Rediscover: separate scrollers for albums (square) and artists
  (circular) — same condition as web's two-scroller branch
- Most played: 75 tracks in 3 rows of 25 using new CompactTrackCard
- Last played: existing single row layout preserved

Empty states use design-system understated-mythic copy mirrored
verbatim from web. Loading state uses DelayedLoading so brief network
blips don't flash a spinner.

Closes the visible parity gap the operator hit when first opening the
Flutter app on a real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:40:12 -04:00
bvandeusen 8fb82d473d feat(flutter/widgets): HorizontalScrollRow skips header when title empty
Lets multiple stacked HorizontalScrollRow instances render under a
single visible heading by passing title: "" to the second-row-onward
instances. Combined with the existing shared-controller support, this
gives us multi-row scrollers without a new widget.

Used in the next commit's home rewrite for Recently added (2 rows)
and Most played (3 rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:57 -04:00
bvandeusen a7fcafadda feat(flutter): DelayedLoading wrapper for skeleton-with-delay
Renders nothing for the first [delay] (default 200ms) of sustained
loading, then renders the [whileDelayed] child until loading completes.
Mirrors the web useDelayed hook so a fast network response doesn't
flash a skeleton.

Used in the next commit's home screen rewrite; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:42 -04:00
bvandeusen 3ac8cc9363 feat(flutter/widgets): PlaylistCard + PlaylistPlaceholderCard
Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.

PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"

Used in the next commit's home Playlists row; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:19 -04:00
bvandeusen dccdb00bce feat(flutter/widgets): CompactTrackCard for dense home Most-played row
Mirrors the web CompactTrackCard — small horizontal cell with cover
art (48dp), title, artist. Tap plays from this track in the section.
Width 176dp matches web's compact density so 25 tracks per row scroll
naturally on phone widths.

Cover URL is derived from track.albumId (/api/albums/<id>/cover) since
TrackRef itself doesn't carry a cover field — same pattern as web's
coverUrl() helper.

Used in the next commit's home Most-played section (3 rows × 25);
otherwise unused this slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:37:40 -04:00
bvandeusen 078520f504 feat(flutter): systemPlaylistsStatusProvider
Riverpod FutureProvider that reads the caller's system-playlists
build state. Consumed by the home Playlists row to decide between
real and placeholder cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:36:29 -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 5613fec5ef fix(flutter): PlaylistsApi.list parses {owned, public} envelope
The server has always returned an enveloped response from GET
/api/playlists; the existing client decoded it as a flat List which
fails at runtime against the actual Map shape. Existing playlists
list screen would have been broken on any production instance.

list() now returns PlaylistsList { owned, public }. The existing
list screen consumes lists.all (owned + public flattened) — same
visible output as the previous flat-list assumption.

This unblocks the home parity slice which needs the same endpoint
for the new Playlists row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:35:34 -04:00
bvandeusen 28b07c7ef4 fix(flutter/android): permit cleartext to 127.0.0.1 for just_audio loopback proxy
Audio playback failed against the prod HTTPS server with
"Cleartext HTTP traffic to 127.0.0.1 not permitted". Diagnostic
logging confirmed _baseUrl was correctly set to the prod URL — the
127.0.0.1 wasn't coming from the server or our URL construction.

Root cause: just_audio + audio_service spin up a local HTTP proxy on
loopback to inject the Authorization header, because Android's
MediaSession API can't pass headers down to ExoPlayer directly.
ExoPlayer connects to http://127.0.0.1:<random-port>; the proxy adds
the Bearer header and forwards to the real upstream HTTPS URL.
Loopback traffic doesn't leave the device.

This is the documented just_audio happy path — see
https://pub.dev/packages/just_audio#a-note-on-android-cleartext-traffic
— not a workaround for a plugin bug.

Adds network_security_config.xml that scopes cleartext permission to
127.0.0.1 ONLY. The base config keeps cleartextTrafficPermitted=false
so any actual remote endpoint must still be HTTPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:24:47 -04:00
bvandeusen 83df3773ae fix(flutter): cover URLs send Bearer auth + audio handler diag logging
Two issues from on-device testing against prod HTTPS:

1. ServerImage was resolving cover URLs correctly
   (https://minstrel.fabledsword.com/api/albums/.../cover) but the server
   returned 401 because Image.network doesn't carry the session token
   automatically the way the browser sends cookies. Forwards the stored
   session token as an Authorization: Bearer header. No-op when no token
   is present.

2. The "Cleartext HTTP traffic to 127.0.0.1" audio error reproduced even
   after the previous defensive check landed, which means the URL handed
   to ExoPlayer has a valid scheme+host (just the wrong host). The check
   only catches scheme-less URLs, so it didn't fire. Added debugPrint
   logging at configure() and setQueueFromTracks() time to show the
   actual baseUrl + per-track resolved URL on the next reproduction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:15:56 -04:00
bvandeusen 088e8f4bf8 test(web/admin): align Lidarr bootstrap tests with file conventions
Refactors the new describe block to use the existing setupFresh()
helper pattern (mirrors setup() but with empty profile/folder lists
so we can verify the test response is what populates the dropdowns),
the lidarrSection() scoping helper (the cover-providers and SMTP
panels also have "Save changes" buttons that match unscoped queries),
and the correct mockQuery({ data: ... }) shape used elsewhere in the
file. Drops the duplicate afterEach (the file already has a global
one at line 131).

No behavior change in the tests themselves — same three cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:11:35 -04:00
bvandeusen c47ec6093f test(web/admin): cover Test-then-Save flow on /admin/integrations
Three new cases:
- Save on a fresh form calls test first, fills auto-defaults from the
  response, then put-config with non-zero defaults
- Save aborts when test fails and surfaces the error code
- An operator-picked default value survives the test → save sequence
  (auto-default $effect only fills when current value is 0/'')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:10:37 -04:00
bvandeusen bca8622640 feat(web/admin): Save runs Test first, dropdowns honor test response
Click sequence on a fresh Lidarr config: enter URL + API key → click
Save. The page now calls testLidarrConnection first, stashes the
returned profiles/folders into local state, lets the existing
auto-default $effects pick first-of-each, then sends the put-config
request with non-zero defaults. If the test fails, the save aborts
and the error surfaces in saveError.

The explicit Test connection button keeps working and additionally
populates the dropdowns from the same response so an operator can
inspect/adjust before saving.

Each dropdown reads from its test-state array when present, falling
back to the existing query data (which only works after the first
successful save). list_errors entries surface as inline notes under
their respective dropdowns.

Closes the chicken-and-egg where saving required defaults that could
only be fetched after saving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:58 -04:00
bvandeusen abb384f479 feat(web/admin): LidarrTestResult success branch carries profile + folder lists
Mirrors the server-side extension of POST /api/admin/lidarr/test. The
integrations page consumes these in the next commit to pre-fill the
quality/metadata/root-folder dropdowns on first-time Lidarr setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:02 -04:00
bvandeusen 22f03a5fe8 test(server/admin): cover new test endpoint response shape
- Updates the happy-path test stub to handle the three list endpoints
  Lidarr exposes (qualityprofile / metadataprofile / rootfolder) and
  asserts profiles + folders make it through to the response.
- Adds a partial-failure case where one list endpoint 5xxs; verifies
  ok=true, the failing list is omitted, and list_errors carries its
  bucket code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:47 -04:00
bvandeusen 0bc17a85fb feat(server/admin): test endpoint returns profiles + folders on success
Extends POST /api/admin/lidarr/test response to include quality_profiles,
metadata_profiles, root_folders, and an optional list_errors map when the
Lidarr connection succeeds. The three list fetches run in parallel after
the ping; any per-list failure goes into list_errors so the response can
still report ok=true with whatever data did come back. Failed-ping
response shape is unchanged.

This lets /admin/integrations populate its dropdowns on a single
round-trip during first-time setup, fixing the chicken-and-egg where
the dropdowns previously gated on cfg.Enabled (which can't be true
until the first save, which itself needs non-zero defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:15 -04:00
bvandeusen 602ef3bfdf fix(flutter): cover URLs resolve against server baseUrl + audio stream URL guard
Cover-art Image.network calls were passing server-relative URLs
(/api/albums/<id>/cover) straight to NetworkImage, which interprets
"no scheme" as file:/// and crashes with "No host specified in URI".
Same root cause regardless of HTTPS or HTTP server.

ServerImage wraps Image.network with a Riverpod read of
serverUrlProvider and prefixes the configured base URL. Absolute URLs
(e.g. discover screen's Lidarr image_urls) pass through unchanged.

Three call sites updated: album_card, artist_card, playlists_list_screen.
discover_screen left as-is — its row.imageUrl is already absolute (Lidarr
returns full URLs from MusicBrainz / Spotify metadata) and it has a
meaningful errorBuilder that ServerImage doesn't expose.

Also adds a defensive check in audio_handler.setQueueFromTracks: if
the constructed stream URL ends up scheme-less, throw a StateError
naming baseUrl + track.streamUrl + track.id instead of letting it
fall through to ExoPlayer which surfaces a confusing "Cleartext HTTP
traffic to 127.0.0.1 not permitted" error (Android's URL parser
defaults a scheme-less URI to localhost). User reported this exact
confusing error against an HTTPS prod server; the better message
will pinpoint where the empty baseUrl comes from on next reproduction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:47:25 -04:00
bvandeusen 751c2878f7 fix(flutter/android): production manifest + AudioServiceActivity
Two related Android setup gaps caught when running on a clean Pixel 6
emulator:

1. MainActivity extended FlutterActivity, but the audio_service
   plugin requires AudioServiceActivity. AudioService.init() threw
   PlatformException("The Activity class declared in your
   AndroidManifest.xml is wrong"), and the partial-engine-reinit that
   followed cascaded into a flutter_cache_manager SQLite EXCLUSIVE
   lock crash. Both errors clear with the correct base class.

2. The main manifest was missing two production permissions:
   - POST_NOTIFICATIONS — Android 13+ runtime requirement for the
     media notification (now-playing tile in the system tray).
     audio_service auto-prompts at init() time once declared.
   - ACCESS_NETWORK_STATE — used by ConnectionErrorBanner +
     flutter_cache_manager for connectivity awareness.

Note for future contributors: src/main/AndroidManifest.xml IS the
production manifest. The src/debug and src/profile variants only add
dev-only entries (e.g. INTERNET re-declared in debug for hot-reload).
There is no separate "release" manifest in Flutter's convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:30 -04:00
bvandeusen 626fc7502c Merge pull request 'refactor(server): remove bootstrap admin path' (#34) from dev into main 2026-05-09 02:19:40 +00:00
bvandeusen c4cccea775 refactor(server): remove bootstrap admin path
The bootstrap-admin-from-env-vars flow was a holdover from before
self-registration could create the first admin. Now that handleRegister
+ CreateUserFirstAdminRace promotes the first user to register on an
empty users table (verified shipped in v2026.05.08.2 / #376), the
bootstrap path is just a second source of truth that confuses operators
(and leaves an "admin" account in the DB that nobody asked for).

Removes:
- internal/auth/bootstrap.go + its test
- The auth.Bootstrap call from cmd/minstrel/main.go
- AuthConfig + AdminBootstrapConfig structs from config
- MINSTREL_AUTH_ADMIN_USERNAME / _PASSWORD env reads + their test
- The auth: block from config.example.yaml
- Bootstrap-related comments in docker-compose.yml + README.md

The README quickstart now points operators at /register on first start
instead of "watch the logs for a one-time password."

Existing instances keep their bootstrap-admin row in the DB; operators
who want it gone can register a new admin via /register, promote them
in /admin/users, then delete the old bootstrap user (last-admin guard
will require the new admin to be promoted first). No migration needed.

Recovery story for forgotten admin passwords now hinges on Fable #321
(admin password reset CLI) — currently the only path back in if no
other admin exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:14:33 -04:00
bvandeusen 9bf3b8a2f2 Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main 2026-05-09 02:07:59 +00: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 9f6ceb1731 feat(flutter/admin): users screen with users + invites sections
Single-scroll layout with two _SectionHeader-anchored blocks. Users
rows tap into AdminUserEditSheet. Invites row shows token (monospace)
with copy button, optional note, and a redeemed badge if applicable.

"Generate invite" opens a small dialog with an optional note field
(server hardcodes 24h TTL — admins can't customise expiry). On
success the new token is shown in a copy-once result dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:53 -04:00
bvandeusen afcaf56b9c feat(flutter/admin): user row + edit sheet (toggles, reset, delete)
AdminUserRow shows username + admin/auto-approve badges; tap opens
AdminUserEditSheet bottom sheet.

Edit sheet: SwitchListTile pair for is_admin and auto_approve_requests
(both surface server errors via SnackBar, including the last-admin
guard). Reset password takes a typed-input dialog (>=8 chars enforced
client-side; admin supplies the new password). Delete routes through
TypedConfirmSheet ("DELETE") and pops the sheet on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:10 -04:00
bvandeusen 7d207e56f5 feat(flutter/admin): users + invites mutations
Extends AdminUsersController (build-only since slice 2) with setAdmin,
setAutoApprove, delete, resetPassword. is_admin/auto_approve toggles
optimistically swap the row and roll back on server error (including
the last-admin guard 4xx). resetPassword is admin-supplied; no
auto-generation mode exists server-side.

Adds AdminInvitesController + adminInvitesProvider with create
(prepends new invite to the list, returns it for the copy-once
dialog) and revoke (optimistic remove + rollback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:56:34 -04:00
bvandeusen 3e905db906 feat(flutter/admin): quarantine screen + typed-confirm sheet
ExpansionTile per aggregated quarantine row — collapsed shows track,
artist · album, and "{N} reports: {top reason}". Expanded reveals the
nested per-user reports with their reasons + optional notes.

Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both
deletes route through TypedConfirmSheet which requires the user to
type "DELETE" before the action button enables.

Adds AdminQuarantineController with optimistic-removal + rollback,
mirroring AdminRequestsController's pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:56:00 -04:00
bvandeusen bd1bcab12b feat(flutter/admin): requests screen with approve/reject
AdminRequestsScreen renders rows from adminRequestsProvider, joining
requester user_id → username via adminUsersProvider for display
(falls back to UUID prefix when the users list hasn't loaded yet).
Approve fires immediately; Reject confirms via dialog. Both are
optimistic with rollback in the controller.

Adds AdminUsersController build-only (the read side) so the row join
works; mutation methods land in Slice 3 alongside the Users screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:54:58 -04:00
bvandeusen 8bb192fe54 feat(flutter/admin): adminRequestsProvider with optimistic approve/reject
AsyncNotifier removes the row optimistically on approve/reject; rolls
back to the pre-mutation list on server error. Invalidates
adminCountsProvider so the landing badge stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:54:05 -04:00
bvandeusen 408eed1f3d feat(flutter/settings): admin card visible to admins
Settings gains an _AdminSection that renders an "Admin" tile (Shield
icon → /admin) only when the current profile has is_admin = true.
Non-admins see the section as SizedBox.shrink() so the const ListView
children stay const.

Provides a discoverable second entry to the admin landing alongside
the kebab overflow shortcut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:53:35 -04:00
bvandeusen 6b5d12f3a1 feat(flutter/admin): landing screen with section cards + counts
ListView of three AdminSectionCard tiles (Requests / Quarantine /
Users) showing live counts from adminCountsProvider. Pull-to-refresh
re-fans-out to the three list endpoints. Admin/Quarantine/Users
sub-routes will land in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:53:03 -04:00
bvandeusen 372e7eca86 feat(flutter/admin): API providers + adminCountsProvider
Single landing-screen provider fans out to requests/quarantine/users
list endpoints in parallel and returns counts for the section-card
badges. Per-section AsyncNotifier controllers land alongside their
screens in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:52:12 -04:00
bvandeusen 79d00ba001 feat(flutter/api): admin endpoint clients
Four small Dio-backed API classes mirroring the server's actual
contract (verified against internal/api/admin_*.go):

- Requests/Quarantine return flat lists; Users/Invites are enveloped
  ({"users": [...]}, {"invites": [...]}) and unwrap here
- setAutoApprove sends {auto_approve: bool} — different from the
  response field name auto_approve_requests
- resetPassword takes admin-supplied {password: string}, returns 204
- Invite create takes optional {note: string}; expiry is server-fixed
  at 24h

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:52:00 -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 dfc08650e7 feat(flutter): wire admin routes + isAdmin gate
Adds /admin, /admin/requests, /admin/quarantine, /admin/users routes to
the ShellRoute (so PlayerBar persists). Redirect closure refuses any
/admin path when user.isAdmin is false. Server-side RequireAdmin
middleware remains the actual authority — this is UX.

Screens land in subsequent commits; this intermediate state won't build
until task 8/9 lands the AdminLandingScreen and the missing siblings
are stubbed out by task 11/13/16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:50:53 -04:00
bvandeusen fc0350cb96 feat(flutter): adopt MainAppBarActions in Search, Discover, Playlists, Settings
Search keeps its conditional clear-text button and gains the shared
nav widget after it. Discover/Playlists/Settings get an actions row
they didn't have before. The kebab is now reachable from every
top-level screen, which is the prerequisite for the Admin entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:50:29 -04:00
bvandeusen 5546787f78 feat(flutter): adopt MainAppBarActions in Home + Library
HomeScreen drops its 5 ad-hoc IconButton actions in favour of the shared
widget. LibraryScreen gains an actions row it didn't have before. Both
now consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:49:30 -04:00
bvandeusen 6564d37a2a feat(flutter): MainAppBarActions widget with admin-gated overflow
Shared AppBar actions for top-level screens — three primary icons
(Home/Library/Search, current screen suppressed) plus a kebab containing
Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the
ad-hoc per-screen IconButton lists; centralises admin-gating logic in
one place so /admin entry can't accidentally leak to non-admins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:48:52 -04:00
249 changed files with 12243 additions and 1383 deletions
+12 -5
View File
@@ -56,6 +56,12 @@ jobs:
- name: Pub get
run: flutter pub get
- name: Codegen (drift, etc.)
# build_runner generates *.g.dart files (drift schemas, etc.)
# which are gitignored. Must run before analyze + test so the
# generated symbols exist.
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze --fatal-infos
@@ -81,14 +87,15 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
# Look up the release ID for this tag (release must exist already;
# release.yml creates it as part of the server-side release flow).
# Look up the release ID for this tag. The release object must
# exist already — operator creates it (or release.yml will, in
# a future enhancement) before pushing the tag.
RELEASE_ID=$(curl -fsSL \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "${RELEASE_ID}" ]; then
@@ -96,6 +103,6 @@ jobs:
exit 1
fi
curl -fsSL \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
+29 -1
View File
@@ -60,9 +60,37 @@ jobs:
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
# In-app update flow (#397): on tag pushes only, fetch the APK
# that flutter.yml is attaching to this same release. flutter.yml
# runs in parallel; poll up to 15 min for the asset to appear.
# On main pushes (or if the APK never lands), client/ stays empty
# and the endpoints return 404 — graceful degradation.
- name: Fetch APK for bundled in-app update channel
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
for i in $(seq 1 30); do
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
-o client/minstrel.apk "$APK_URL"; then
echo "Got APK on attempt $i"
echo "${TAG}" > client/minstrel.apk.version
ls -lh client/
exit 0
fi
echo "APK not ready yet (attempt $i/30), sleeping 30s..."
sleep 30
done
echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel"
- name: Build and push
if: steps.guard.outputs.ready == 'true'
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
+5
View File
@@ -12,6 +12,11 @@
# Test binary, built with `go test -c`
*.test
# Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk
client/minstrel.apk.version
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
+7 -1
View File
@@ -33,9 +33,15 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
# A non-writable path at this location silently breaks every downstream
# cache, so we create + chown it once at image build. Operators mount a
# named volume on top to persist across container recreates.
RUN mkdir -p /app/data && chown -R minstrel:minstrel /app
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
WORKDIR /app
# In-app update channel (#397). client/ in the build context holds
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
# APK files aren't present, so non-tag images degrade gracefully.
COPY --chown=minstrel:minstrel client/ /app/client/
USER minstrel
EXPOSE 4533
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
+1 -2
View File
@@ -48,7 +48,7 @@ volumes:
docker compose up -d
```
Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings.
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
@@ -59,7 +59,6 @@ Most operators only need the env vars in the quickstart above. A few extras wort
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start.
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
View File
+35
View File
@@ -0,0 +1,35 @@
# Bundled client assets
Holds the Android APK (`minstrel.apk` + `minstrel.apk.version`) that the
server serves via `/api/client/version` and `/api/client/apk` for the
in-app update flow (#397).
## Production
CI populates this directory on tag releases:
1. `flutter.yml` builds `app-release.apk` and attaches it to the
Forgejo release as `minstrel-<TAG>.apk`.
2. `release.yml` waits for that asset to appear, downloads it into
this directory as `minstrel.apk`, writes the tag string to
`minstrel.apk.version`, and `docker buildx build` includes both
via `COPY client/ /app/client/`.
## Development
Empty directory works fine — the endpoints return 404 and the Flutter
update banner stays hidden ("no update channel" graceful degradation).
To smoke-test the update flow locally, drop a real APK + a version
file into this directory:
```sh
cp /path/to/app-release.apk client/minstrel.apk
echo "v0.2.0" > client/minstrel.apk.version
```
## Why a directory and not embed.FS?
The APK is 30-60 MB. Embedding bloats the Go binary and slows
`go build` for everyone, even when the APK isn't being changed.
File-on-disk also lets operators override at runtime via volume
mount on `/app/client/` if they want to ship their own build.
-5
View File
@@ -11,7 +11,6 @@ import (
"syscall"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
@@ -67,10 +66,6 @@ func run() error {
}
defer pool.Close()
if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
contact := cfg.Library.ContactEmail
-10
View File
@@ -22,16 +22,6 @@ log:
level: "info" # debug | info | warn | error
format: "text" # text | json
auth:
# One-shot admin bootstrap. Applied only when the users table is empty.
# Username defaults to "admin" when unset — you can omit this section
# entirely for a working bootstrap. Leave password blank to have the server
# generate one and log it to stderr on first start.
# Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD
admin_bootstrap:
username: "admin"
password: ""
library:
# Filesystem roots to scan for music. Each path is walked recursively.
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
-3
View File
@@ -39,9 +39,6 @@ services:
MINSTREL_LIBRARY_SCAN_PATHS: /music
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
MINSTREL_STORAGE_DATA_DIR: /app/data
# MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap
# generates one and prints it to stderr on first boot. Watch the logs
# of the minstrel service the first time you bring the stack up.
networks:
- minstrel
ports:
+4
View File
@@ -43,3 +43,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# drift codegen output (regenerated by build_runner; CI runs build_runner)
*.g.dart
@@ -1,12 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Used in all build flavours (debug / profile / release). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Android 13+ requires this to be declared AND prompted at runtime
for the media notification (now-playing tile) to appear. Audio
playback works without it; only the system-tray surface breaks. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- In-app update flow (#397). First update attempt prompts the
user to enable "Install unknown apps" for Minstrel in
Settings → Apps → Special access. One-time grant; persists. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="minstrel"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -49,6 +60,20 @@
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- FileProvider for the in-app update flow (#397). Maps the
cache directory (where dio.download writes the APK) to a
content:// URI so the PackageInstaller intent can read it
across the process boundary. file:// URIs are blocked
since Android 7. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -1,5 +1,70 @@
package com.fabledsword.minstrel
import io.flutter.embedding.android.FlutterActivity
import android.content.Intent
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
class MainActivity : FlutterActivity()
// Must extend AudioServiceActivity (not FlutterActivity) so the
// audio_service plugin can wire its FlutterEngine through. Without this
// the platform call from AudioService.init() throws PlatformException
// "The Activity class declared in your AndroidManifest.xml is wrong",
// which on first start cascades into a flutter_cache_manager sqlite
// EXCLUSIVE-lock crash because the engine partially re-initialises.
class MainActivity : AudioServiceActivity() {
companion object {
private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// In-app update channel (#397). Dart calls install(path) with
// the cache-dir APK path; we hand it to Android's
// PackageInstaller via FileProvider + ACTION_VIEW.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"install" -> {
val path = call.argument<String>("path")
if (path == null) {
result.error("missing_path", "path argument required", null)
return@setMethodCallHandler
}
try {
installApk(path)
result.success(null)
} catch (e: Exception) {
result.error("install_failed", e.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun installApk(path: String) {
val file = File(path)
if (!file.exists()) {
throw IllegalStateException("apk not found at $path")
}
val uri = FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
// NEW_TASK: PackageInstaller runs in its own task.
// GRANT_READ_URI_PERMISSION: hands the content:// URI to
// the installer process, which lacks our app's read perms
// by default.
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_GRANT_READ_URI_PERMISSION
}
startActivity(intent)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
FileProvider mappings for the in-app update flow (#397).
- cache-path/. exposes the app's getCacheDir() so the downloaded
APK at <cache>/minstrel-update.apk can be read by the
PackageInstaller via the content:// URI built in MainActivity.kt.
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="." />
</paths>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Permits cleartext HTTP to 127.0.0.1 ONLY. Required because just_audio +
audio_service spin up a local HTTP proxy on loopback to inject the
Authorization header (Android's MediaSession API can't pass headers down
to ExoPlayer directly). Without this exemption, ExoPlayer hits Android's
cleartext block when connecting to the local proxy and audio playback
fails with "Cleartext HTTP traffic to 127.0.0.1 not permitted".
All other hosts inherit cleartextTrafficPermitted="false" — actual
remote traffic (to your Minstrel server) must still be HTTPS.
Loopback traffic doesn't leave the device, so this exemption doesn't
reduce security for real network endpoints.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_section_card.dart';
class AdminLandingScreen extends ConsumerWidget {
const AdminLandingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final counts = ref.watch(adminCountsProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Admin', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin')],
),
body: SafeArea(
child: counts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (c) => RefreshIndicator(
onRefresh: () async => ref.refresh(adminCountsProvider.future),
child: ListView(
children: [
AdminSectionCard(
key: const Key('admin_card_requests'),
icon: Icons.inbox,
title: 'Requests',
subtitle: 'Approve or reject Lidarr requests',
count: c.requests,
onTap: () => context.push('/admin/requests'),
),
AdminSectionCard(
key: const Key('admin_card_quarantine'),
icon: Icons.warning_amber,
title: 'Quarantine',
subtitle: 'Resolve scan failures',
count: c.quarantine,
onTap: () => context.push('/admin/quarantine'),
),
AdminSectionCard(
key: const Key('admin_card_users'),
icon: Icons.people,
title: 'Users',
subtitle: 'Manage accounts and invites',
count: c.users,
onTap: () => context.push('/admin/users'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,237 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/admin_invites.dart';
import '../api/endpoints/admin_quarantine.dart';
import '../api/endpoints/admin_requests.dart';
import '../api/endpoints/admin_users.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/admin_quarantine_item.dart';
import '../models/admin_request.dart';
import '../models/admin_user.dart';
import '../models/invite.dart';
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
return AdminRequestsApi(await ref.watch(dioProvider.future));
});
final adminQuarantineApiProvider =
FutureProvider<AdminQuarantineApi>((ref) async {
return AdminQuarantineApi(await ref.watch(dioProvider.future));
});
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
return AdminUsersApi(await ref.watch(dioProvider.future));
});
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
return AdminInvitesApi(await ref.watch(dioProvider.future));
});
class AdminCounts {
const AdminCounts({
required this.requests,
required this.quarantine,
required this.users,
});
final int requests;
final int quarantine;
final int users;
}
/// Fans out to the three list endpoints in parallel and rolls them up
/// into `{requests, quarantine, users}` for the landing screen badges.
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
final usersApi = await ref.watch(adminUsersApiProvider.future);
final results = await Future.wait([
requestsApi.list(),
quarantineApi.list(),
usersApi.list(),
]);
return AdminCounts(
requests: (results[0] as List).length,
quarantine: (results[1] as List).length,
users: (results[2] as List).length,
);
});
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
@override
Future<List<AdminRequest>> build() async {
final api = await ref.watch(adminRequestsApiProvider.future);
return api.list();
}
Future<void> approve(String id) async {
await _decide(id, (api) => api.approve(id));
}
Future<void> reject(String id) async {
await _decide(id, (api) => api.reject(id));
}
Future<void> _decide(
String id,
Future<void> Function(AdminRequestsApi api) action,
) async {
final api = await ref.read(adminRequestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
state = AsyncData(current.where((r) => r.id != id).toList());
try {
await action(api);
// Refresh landing badge.
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminRequestsProvider =
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
AdminRequestsController.new);
/// Read side originally defined for the Requests screen's
/// requester-UUID → username join. Mutation methods (setAdmin,
/// setAutoApprove, resetPassword, delete) added alongside the Users
/// screen in Slice 3. resetPassword is admin-supplies; the server
/// has no auto-generation mode.
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
@override
Future<List<AdminUser>> build() async {
final api = await ref.watch(adminUsersApiProvider.future);
return api.list();
}
/// Optimistic flip of `is_admin` on the user row; rolls back on
/// server error (including the last-admin guard 4xx).
Future<void> setAdmin(String id, bool isAdmin) =>
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
(api) => api.setAdmin(id, isAdmin));
Future<void> setAutoApprove(String id, bool autoApprove) =>
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
(api) => api.setAutoApprove(id, autoApprove));
Future<void> delete(String id) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
state = AsyncData(current.where((u) => u.id != id).toList());
try {
await api.delete(id);
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
Future<void> resetPassword(String id, String newPassword) async {
final api = await ref.read(adminUsersApiProvider.future);
await api.resetPassword(id, newPassword);
}
Future<void> _patch(
String id,
AdminUser Function(AdminUser) mutate,
Future<void> Function(AdminUsersApi api) action,
) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
state = AsyncData([
for (final u in current) u.id == id ? mutate(u) : u,
]);
try {
await action(api);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
AdminUser(
id: u.id,
username: u.username,
displayName: u.displayName,
isAdmin: isAdmin ?? u.isAdmin,
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
createdAt: u.createdAt,
);
}
final adminUsersProvider =
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
AdminUsersController.new);
class AdminQuarantineController
extends AsyncNotifier<List<AdminQuarantineItem>> {
@override
Future<List<AdminQuarantineItem>> build() async {
final api = await ref.watch(adminQuarantineApiProvider.future);
return api.list();
}
Future<void> resolve(String trackId) =>
_act(trackId, (api) => api.resolve(trackId));
Future<void> deleteFile(String trackId) =>
_act(trackId, (api) => api.deleteFile(trackId));
Future<void> deleteViaLidarr(String trackId) =>
_act(trackId, (api) => api.deleteViaLidarr(trackId));
Future<void> _act(
String trackId,
Future<void> Function(AdminQuarantineApi api) action,
) async {
final api = await ref.read(adminQuarantineApiProvider.future);
final current = state.value ?? const <AdminQuarantineItem>[];
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
try {
await action(api);
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
List<AdminQuarantineItem>>(AdminQuarantineController.new);
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
@override
Future<List<Invite>> build() async {
final api = await ref.watch(adminInvitesApiProvider.future);
return api.list();
}
/// Returns the freshly-minted invite so the screen can show the
/// token in a copy-once dialog. The new invite is also prepended
/// to the local list so it shows up without a refresh.
Future<Invite> create({String? note}) async {
final api = await ref.read(adminInvitesApiProvider.future);
final invite = await api.create(note: note);
final current = state.value ?? const <Invite>[];
state = AsyncData([invite, ...current]);
return invite;
}
Future<void> revoke(String token) async {
final api = await ref.read(adminInvitesApiProvider.future);
final current = state.value ?? const <Invite>[];
state = AsyncData(current.where((i) => i.token != token).toList());
try {
await api.revoke(token);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminInvitesProvider =
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
AdminInvitesController.new);
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_quarantine_row.dart';
class AdminQuarantineScreen extends ConsumerWidget {
const AdminQuarantineScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final items = ref.watch(adminQuarantineProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Quarantine', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')],
),
body: SafeArea(
child: items.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('No quarantined tracks.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(adminQuarantineProvider.notifier);
return RefreshIndicator(
onRefresh: () async =>
ref.refresh(adminQuarantineProvider.future),
child: ListView.builder(
itemCount: rows.length,
itemBuilder: (_, i) => AdminQuarantineRow(
key: Key('admin_quarantine_row_${rows[i].trackId}'),
item: rows[i],
onResolve: () => notifier.resolve(rows[i].trackId),
onDeleteFile: () => notifier.deleteFile(rows[i].trackId),
onDeleteViaLidarr: () =>
notifier.deleteViaLidarr(rows[i].trackId),
),
),
);
},
),
),
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/admin_user.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_request_row.dart';
class AdminRequestsScreen extends ConsumerWidget {
const AdminRequestsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final requests = ref.watch(adminRequestsProvider);
// Best-effort lookup for requester usernames. If the users provider
// hasn't loaded yet, valueOrNull is null and rows fall back to the
// UUID prefix; no blocking spinner.
final users = ref.watch(adminUsersProvider).value ?? const <AdminUser>[];
final usersById = {for (final u in users) u.id: u};
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Requests', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/requests')],
),
body: SafeArea(
child: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('No pending requests.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(adminRequestsProvider.notifier);
return RefreshIndicator(
onRefresh: () async =>
ref.refresh(adminRequestsProvider.future),
child: ListView.builder(
itemCount: rows.length,
itemBuilder: (_, i) {
final req = rows[i];
final user = usersById[req.userId];
final display = user?.username ??
(req.userId.length >= 8
? req.userId.substring(0, 8)
: req.userId);
return AdminRequestRow(
key: Key('admin_request_row_${req.id}'),
request: req,
requesterDisplay: display,
onApprove: () => notifier.approve(req.id),
onReject: () => notifier.reject(req.id),
);
},
),
);
},
),
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_user_edit_sheet.dart';
import 'widgets/admin_user_row.dart';
import 'widgets/invite_row.dart';
class AdminUsersScreen extends ConsumerWidget {
const AdminUsersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final users = ref.watch(adminUsersProvider);
final invites = ref.watch(adminInvitesProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Users', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/users')],
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(adminUsersProvider);
ref.invalidate(adminInvitesProvider);
},
child: ListView(
children: [
const _SectionHeader(text: 'Users'),
users.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (rows) => Column(
children: rows
.map((u) => AdminUserRow(
key: Key('admin_user_row_${u.id}'),
user: u,
onTap: () => AdminUserEditSheet.show(context, u),
))
.toList(),
),
),
const Divider(),
_SectionHeader(
text: 'Invites',
trailing: TextButton.icon(
key: const Key('invite_generate_button'),
onPressed: () => _showGenerateInvite(context, ref),
icon: Icon(Icons.add, color: fs.parchment),
label: Text('Generate',
style: TextStyle(color: fs.parchment)),
),
),
invites.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (rows) => Column(
children: rows
.map((i) => InviteRow(
key: Key('invite_row_${i.token}'),
invite: i,
onRevoke: () => ref
.read(adminInvitesProvider.notifier)
.revoke(i.token),
))
.toList(),
),
),
],
),
),
),
);
}
Future<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Generate invite'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Token expires in 24 hours.'),
const SizedBox(height: 12),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Note (optional)',
helperText: 'e.g. "for alice"',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('Generate'),
),
],
),
);
if (note == null || !context.mounted) return;
try {
final invite = await ref
.read(adminInvitesProvider.notifier)
.create(note: note.isEmpty ? null : note);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Invite created'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Share this token with the new user:'),
const SizedBox(height: 8),
SelectableText(
invite.token,
style: const TextStyle(fontFamily: 'JetBrainsMono'),
),
],
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: invite.token));
Navigator.pop(ctx);
},
child: const Text('Copy'),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
text,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
const Spacer(),
if (trailing != null) trailing!,
],
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../models/admin_quarantine_item.dart';
import '../../theme/theme_extension.dart';
import 'typed_confirm_sheet.dart';
class AdminQuarantineRow extends StatelessWidget {
const AdminQuarantineRow({
super.key,
required this.item,
required this.onResolve,
required this.onDeleteFile,
required this.onDeleteViaLidarr,
});
final AdminQuarantineItem item;
final VoidCallback onResolve;
final VoidCallback onDeleteFile;
final VoidCallback onDeleteViaLidarr;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ExpansionTile(
key: Key('admin_quarantine_tile_${item.trackId}'),
title: Text(
item.trackTitle,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${item.artistName} · ${item.albumTitle}',
style: TextStyle(color: fs.ash)),
Text(
'${item.reportCount} '
'${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}',
style: TextStyle(color: fs.error, fontSize: 12),
),
],
),
trailing: PopupMenuButton<String>(
key: Key('admin_quarantine_menu_${item.trackId}'),
icon: Icon(Icons.more_vert, color: fs.parchment),
onSelected: (action) async {
switch (action) {
case 'resolve':
onResolve();
break;
case 'delete_file':
if (await TypedConfirmSheet.show(
context,
title: 'Delete file?',
message:
'Permanently delete the local file for "${item.trackTitle}". '
'Cannot be undone.',
)) {
onDeleteFile();
}
break;
case 'delete_lidarr':
if (await TypedConfirmSheet.show(
context,
title: 'Delete via Lidarr?',
message:
'Ask Lidarr to delete the file for "${item.trackTitle}".',
)) {
onDeleteViaLidarr();
}
break;
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'resolve', child: Text('Resolve')),
PopupMenuItem(value: 'delete_file', child: Text('Delete file')),
PopupMenuItem(
value: 'delete_lidarr', child: Text('Delete via Lidarr')),
],
),
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
children: [
for (final report in item.reports)
ListTile(
dense: true,
title: Text(
'${report.username}${report.reason}',
style: TextStyle(color: fs.parchment, fontSize: 13),
),
subtitle: report.notes != null
? Text(report.notes!,
style: TextStyle(color: fs.ash, fontSize: 12))
: null,
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../models/admin_request.dart';
import '../../theme/theme_extension.dart';
class AdminRequestRow extends StatelessWidget {
const AdminRequestRow({
super.key,
required this.request,
required this.requesterDisplay,
required this.onApprove,
required this.onReject,
});
final AdminRequest request;
/// Pre-resolved username (or UUID-prefix fallback) for the requester.
final String requesterDisplay;
final VoidCallback onApprove;
final VoidCallback onReject;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final subtitle =
'${request.kind} · ${request.artistName} · requested by $requesterDisplay';
return ListTile(
title: Text(
request.displayName,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onApprove,
style: TextButton.styleFrom(foregroundColor: fs.moss),
child: const Text('Approve'),
),
TextButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject request?'),
content: Text('Reject "${request.displayName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Reject'),
),
],
),
);
if (ok == true) onReject();
},
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Reject'),
),
],
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
class AdminSectionCard extends StatelessWidget {
const AdminSectionCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.count,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final int count;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Card(
color: fs.iron,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: Icon(icon, color: fs.parchment),
title: Text(
title,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: fs.bronze,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500),
),
),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/admin_user.dart';
import '../../theme/theme_extension.dart';
import '../admin_providers.dart';
import 'typed_confirm_sheet.dart';
class AdminUserEditSheet extends ConsumerWidget {
const AdminUserEditSheet({super.key, required this.user});
final AdminUser user;
static Future<void> show(BuildContext context, AdminUser user) =>
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => AdminUserEditSheet(user: user),
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final users = ref.read(adminUsersProvider.notifier);
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
color: fs.iron,
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.username,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 20,
),
),
const SizedBox(height: 12),
SwitchListTile(
key: const Key('user_edit_is_admin'),
title: Text('Admin', style: TextStyle(color: fs.parchment)),
value: user.isAdmin,
onChanged: (v) async {
try {
await users.setAdmin(user.id, v);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
},
),
SwitchListTile(
key: const Key('user_edit_auto_approve'),
title: Text('Auto-approve requests',
style: TextStyle(color: fs.parchment)),
value: user.autoApproveRequests,
onChanged: (v) async {
try {
await users.setAutoApprove(user.id, v);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
},
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
key: const Key('user_edit_reset_password'),
onPressed: () => _resetPassword(context, users),
child: const Text('Reset password'),
),
const Spacer(),
TextButton(
key: const Key('user_edit_delete'),
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
onPressed: () => _deleteUser(context, users),
child: const Text('Delete'),
),
],
),
],
),
),
);
}
Future<void> _resetPassword(
BuildContext context, AdminUsersController users) async {
final controller = TextEditingController();
final newPw = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset password'),
content: TextField(
controller: controller,
obscureText: true,
decoration: const InputDecoration(
labelText: 'New password',
helperText: 'Minimum 8 characters',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
if (controller.text.length >= 8) {
Navigator.pop(ctx, controller.text);
}
},
child: const Text('Reset'),
),
],
),
);
if (newPw == null || !context.mounted) return;
try {
await users.resetPassword(user.id, newPw);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Password reset.')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
}
Future<void> _deleteUser(
BuildContext context, AdminUsersController users) async {
if (!await TypedConfirmSheet.show(
context,
title: 'Delete user?',
message:
'Permanently delete user "${user.username}" and all their data. '
'Cannot be undone.',
)) {
return;
}
try {
await users.delete(user.id);
if (context.mounted) Navigator.pop(context);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../../models/admin_user.dart';
import '../../theme/theme_extension.dart';
class AdminUserRow extends StatelessWidget {
const AdminUserRow({super.key, required this.user, required this.onTap});
final AdminUser user;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
title: Text(
user.username,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Wrap(
spacing: 6,
children: [
if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze),
if (user.autoApproveRequests)
_Badge(label: 'auto-approve', color: fs.moss),
],
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
onTap: onTap,
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(color: fs.obsidian, fontSize: 11),
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/invite.dart';
import '../../theme/theme_extension.dart';
class InviteRow extends StatelessWidget {
const InviteRow({
super.key,
required this.invite,
required this.onRevoke,
});
final Invite invite;
final VoidCallback onRevoke;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
title: Row(
children: [
Expanded(
child: SelectableText(
invite.token,
style: TextStyle(
color: fs.parchment,
fontFamily: 'JetBrainsMono',
fontSize: 13,
),
),
),
IconButton(
icon: Icon(Icons.copy, color: fs.ash, size: 18),
tooltip: 'Copy',
onPressed: () =>
Clipboard.setData(ClipboardData(text: invite.token)),
),
],
),
subtitle: Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('expires ${invite.expiresAt}',
style: TextStyle(color: fs.ash, fontSize: 12)),
if (invite.note != null)
Text('· ${invite.note}',
style: TextStyle(color: fs.ash, fontSize: 12)),
if (invite.isRedeemed)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: fs.ash,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'redeemed',
style: TextStyle(color: fs.obsidian, fontSize: 10),
),
),
],
),
trailing: IconButton(
icon: Icon(Icons.delete_outline, color: fs.oxblood),
tooltip: 'Revoke',
onPressed: onRevoke,
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Modal bottom sheet that requires the user to type [confirmWord]
/// (default "DELETE") before the confirm button enables. Used for
/// destructive actions like quarantine delete-file / delete-via-Lidarr
/// and user delete.
///
/// Returns true if the user confirmed, false (or null → false) otherwise.
class TypedConfirmSheet extends StatefulWidget {
const TypedConfirmSheet({
super.key,
required this.title,
required this.message,
this.confirmWord = 'DELETE',
this.confirmLabel = 'Delete',
});
final String title;
final String message;
final String confirmWord;
final String confirmLabel;
static Future<bool> show(
BuildContext context, {
required String title,
required String message,
String confirmWord = 'DELETE',
String confirmLabel = 'Delete',
}) async {
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => TypedConfirmSheet(
title: title,
message: message,
confirmWord: confirmWord,
confirmLabel: confirmLabel,
),
);
return ok ?? false;
}
@override
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
}
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
final _controller = TextEditingController();
bool _enabled = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
color: fs.iron,
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 20,
),
),
const SizedBox(height: 12),
Text(widget.message, style: TextStyle(color: fs.ash)),
const SizedBox(height: 16),
Text(
'Type ${widget.confirmWord} to confirm:',
style: TextStyle(color: fs.ash),
),
const SizedBox(height: 8),
TextField(
key: const Key('typed_confirm_input'),
controller: _controller,
autofocus: true,
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
decoration: const InputDecoration(border: OutlineInputBorder()),
onChanged: (v) =>
setState(() => _enabled = v == widget.confirmWord),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
key: const Key('typed_confirm_button'),
onPressed:
_enabled ? () => Navigator.pop(context, true) : null,
style: ElevatedButton.styleFrom(
backgroundColor: fs.oxblood,
foregroundColor: fs.parchment,
),
child: Text(widget.confirmLabel),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:dio/dio.dart';
import '../../models/invite.dart';
class AdminInvitesApi {
AdminInvitesApi(this._dio);
final Dio _dio;
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
Future<List<Invite>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
final raw = (r.data?['invites'] as List?) ?? const [];
return raw
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
/// Returns the bare invite (not enveloped).
Future<Invite> create({String? note}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/admin/invites',
data: {if (note != null && note.isNotEmpty) 'note': note},
);
return Invite.fromJson(r.data ?? const {});
}
Future<void> revoke(String token) async {
await _dio.delete<void>('/api/admin/invites/$token');
}
}
@@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import '../../models/admin_quarantine_item.dart';
class AdminQuarantineApi {
AdminQuarantineApi(this._dio);
final Dio _dio;
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
/// quarantine rows.
Future<List<AdminQuarantineItem>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
final raw = r.data ?? const [];
return raw
.map((e) =>
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> resolve(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
}
Future<void> deleteFile(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
}
Future<void> deleteViaLidarr(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
}
}
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../models/admin_request.dart';
class AdminRequestsApi {
AdminRequestsApi(this._dio);
final Dio _dio;
/// GET /api/admin/requests — flat list of pending requests.
/// Server defaults to ?status=pending; we don't override.
Future<List<AdminRequest>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
final raw = r.data ?? const [];
return raw
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> approve(String id) async {
await _dio.post<void>('/api/admin/requests/$id/approve');
}
Future<void> reject(String id) async {
await _dio.post<void>('/api/admin/requests/$id/reject');
}
}
@@ -0,0 +1,47 @@
import 'package:dio/dio.dart';
import '../../models/admin_user.dart';
class AdminUsersApi {
AdminUsersApi(this._dio);
final Dio _dio;
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
Future<List<AdminUser>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
final raw = (r.data?['users'] as List?) ?? const [];
return raw
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> setAdmin(String id, bool isAdmin) async {
await _dio.put<void>(
'/api/admin/users/$id/admin',
data: {'is_admin': isAdmin},
);
}
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
/// — different from the response field name. Verified against
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
Future<void> setAutoApprove(String id, bool autoApprove) async {
await _dio.put<void>(
'/api/admin/users/$id/auto-approve',
data: {'auto_approve': autoApprove},
);
}
/// Admin supplies the new password; server returns 204. There is no
/// server-generated-password mode.
Future<void> resetPassword(String id, String newPassword) async {
await _dio.post<void>(
'/api/admin/users/$id/reset-password',
data: {'password': newPassword},
);
}
Future<void> delete(String id) async {
await _dio.delete<void>('/api/admin/users/$id');
}
}
+11
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../models/history_event.dart';
import '../../models/quarantine_mine.dart';
import '../../models/system_playlists_status.dart';
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
class MeApi {
@@ -27,4 +28,14 @@ class MeApi {
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/me/system-playlists-status. Returns the caller's most
/// recent system-playlist build state. Used by the home Playlists
/// row to choose between real and placeholder cards.
Future<SystemPlaylistsStatus> systemPlaylistsStatus() async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/me/system-playlists-status',
);
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
}
}
+38 -10
View File
@@ -2,27 +2,55 @@ import 'package:dio/dio.dart';
import '../../models/playlist.dart';
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
/// the UI can present them as different sections; we preserve that split
/// to give the integrations page room to grow.
class PlaylistsList {
const PlaylistsList({required this.owned, required this.public});
final List<Playlist> owned;
final List<Playlist> public;
factory PlaylistsList.empty() =>
const PlaylistsList(owned: [], public: []);
/// Concatenated view for callers that don't care about the split.
List<Playlist> get all => [...owned, ...public];
}
class PlaylistsApi {
PlaylistsApi(this._dio);
final Dio _dio;
/// GET /api/playlists?kind=user|system|all. The server's default kind
/// is "user" — passing "all" returns user playlists + system mixes.
/// "system" returns just for-you / discover. The mobile UI defaults
/// to "all" so users see their generated mixes alongside their own.
Future<List<Playlist>> list({String kind = 'all'}) async {
final r = await _dio.get<List<dynamic>>(
/// GET /api/playlists?kind=user|system|all. Server returns
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
/// playlists, filtered by the kind param (default "user"). Public
/// is other users' shared playlists; not filtered by kind.
Future<PlaylistsList> list({String kind = 'user'}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/playlists',
queryParameters: {'kind': kind},
);
final raw = r.data ?? const [];
return raw
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
final body = r.data ?? const <String, dynamic>{};
List<Playlist> parse(String key) {
final raw = (body[key] as List?) ?? const [];
return raw
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
return PlaylistsList(owned: parse('owned'), public: parse('public'));
}
Future<PlaylistDetail> get(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
return PlaylistDetail.fromJson(r.data ?? const {});
}
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
/// playlist detail with the new rows.
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
await _dio.post<void>(
'/api/playlists/$playlistId/tracks',
data: {'track_ids': trackIds},
);
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
/// /api/quarantine — flag a track (with reason + optional notes) and
/// unflag it. Both endpoints are user-scoped: callers can only flag
/// their own quarantine entries; admins use a separate /admin/quarantine
/// surface.
class QuarantineApi {
QuarantineApi(this._dio);
final Dio _dio;
/// POST /api/quarantine. Server returns 201 with the row.
/// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other.
Future<void> flag(String trackId, String reason, {String notes = ''}) async {
await _dio.post<void>('/api/quarantine', data: {
'track_id': trackId,
'reason': reason,
'notes': notes,
});
}
/// DELETE /api/quarantine/{track_id}. Server returns 204.
Future<void> unflag(String trackId) async {
await _dio.delete<void>('/api/quarantine/$trackId');
}
}
@@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import '../../models/admin_request.dart';
/// User-side requests API — `/api/requests`. Server scopes results to
/// the caller; admins see only their own here, not all users'. The
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
///
/// Wire shape is identical to the admin endpoint, so the same
/// AdminRequest model is reused.
class RequestsApi {
RequestsApi(this._dio);
final Dio _dio;
/// GET /api/requests — caller's own requests, all statuses.
Future<List<AdminRequest>> listMine() async {
final r = await _dio.get<List<dynamic>>('/api/requests');
final raw = r.data ?? const [];
return raw
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// DELETE /api/requests/{id} — cancel a pending request. Server
/// returns the cancelled row body (not 204) so the caller can patch
/// local state without a refetch.
Future<AdminRequest> cancel(String id) async {
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
return AdminRequest.fromJson(r.data ?? const {});
}
}
+29 -3
View File
@@ -1,18 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cache/prefetcher.dart';
import 'cache/sync_controller.dart';
import 'shared/routing.dart';
import 'theme/theme_data.dart';
import 'theme/theme_mode_provider.dart';
class MinstrelApp extends ConsumerWidget {
class MinstrelApp extends ConsumerStatefulWidget {
const MinstrelApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
}
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
@override
void initState() {
super.initState();
// Activate offline-mode infrastructure once the first frame ships.
// SyncController.sync() is connectivity-aware (no-ops on no auth /
// no server URL), so calling it unconditionally is safe.
// Reading prefetcherProvider runs its constructor, which wires the
// queue + settings listeners.
WidgetsBinding.instance.addPostFrameCallback((_) {
// ignore: unawaited_futures
ref.read(syncControllerProvider.notifier).sync();
ref.read(prefetcherProvider);
});
}
@override
Widget build(BuildContext context) {
final router = ref.watch(routerProvider);
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
return MaterialApp.router(
title: 'Minstrel',
theme: buildThemeData(),
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: mode.materialMode,
routerConfig: router,
);
}
+110
View File
@@ -0,0 +1,110 @@
// Drift row → model adapters and reverse-write adapters for populating
// drift from REST responses (#357 plan C).
//
// The cache loses some server-derived fields:
// - ArtistRef.coverUrl (server computes from most-recent album)
// - AlbumRef.coverUrl (server-emitted derived path)
// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity)
// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache
// fallback briefly shows the real values before drift takes over.
import 'package:drift/drift.dart' as drift;
import '../models/album.dart';
import '../models/artist.dart';
import '../models/playlist.dart';
import '../models/track.dart';
import 'db.dart';
extension CachedArtistAdapter on CachedArtist {
ArtistRef toRef() => ArtistRef(
id: id,
name: name,
sortName: sortName,
);
}
extension ArtistRefDriftWrite on ArtistRef {
CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert(
id: id,
name: name,
sortName: sortName.isNotEmpty ? sortName : name,
);
}
extension CachedAlbumAdapter on CachedAlbum {
/// `artistName` is supplied by the joined CachedArtists row at query time.
AlbumRef toRef({String artistName = ''}) => AlbumRef(
id: id,
title: title,
sortTitle: sortTitle,
artistId: artistId,
artistName: artistName,
);
}
extension AlbumRefDriftWrite on AlbumRef {
CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert(
id: id,
artistId: artistId,
title: title,
sortTitle: sortTitle.isNotEmpty ? sortTitle : title,
);
}
extension CachedTrackAdapter on CachedTrack {
/// `artistName` and `albumTitle` come from joined rows.
TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef(
id: id,
title: title,
albumId: albumId,
albumTitle: albumTitle,
artistId: artistId,
artistName: artistName,
trackNumber: trackNumber,
discNumber: discNumber,
durationSec: durationMs ~/ 1000,
);
}
extension TrackRefDriftWrite on TrackRef {
CachedTracksCompanion toDrift() => CachedTracksCompanion.insert(
id: id,
albumId: albumId,
artistId: artistId,
title: title,
durationMs: drift.Value(durationSec * 1000),
trackNumber: drift.Value(trackNumber),
discNumber: drift.Value(discNumber),
);
}
extension CachedPlaylistAdapter on CachedPlaylist {
/// `ownerUsername` is server-derived; cache stores the userId only.
/// Pass empty unless a join supplies it.
Playlist toRef({String ownerUsername = ''}) => Playlist(
id: id,
userId: userId,
name: name,
description: description,
isPublic: isPublic,
systemVariant: systemVariant,
trackCount: trackCount,
coverUrl: '', // server-derived; cache doesn't persist
ownerUsername: ownerUsername,
createdAt: '',
updatedAt: '',
);
}
extension PlaylistDriftWrite on Playlist {
CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert(
id: id,
userId: userId,
name: name,
description: drift.Value(description),
isPublic: drift.Value(isPublic),
trackCount: drift.Value(trackCount),
systemVariant: drift.Value(systemVariant),
);
}
+166
View File
@@ -0,0 +1,166 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../library/library_providers.dart' show dioProvider;
import 'db.dart';
/// Owns the audio cache directory + drift index.
/// API:
/// - isCached(trackId)
/// - pathFor(trackId)
/// - pin(trackId, source) — downloads + indexes
/// - unpin(trackId)
/// - evict(targetBytes) — tiered LRU
/// - usageBytes()
/// - clearAll()
class AudioCacheManager {
AudioCacheManager({
required AppDb db,
required Future<Dio> Function() dioFactory,
Future<Directory> Function()? cacheDirFactory,
}) : _db = db,
_dioFactory = dioFactory,
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
final AppDb _db;
final Future<Dio> Function() _dioFactory;
final Future<Directory> Function() _cacheDirFactory;
Future<String> _tracksDir() async {
final base = await _cacheDirFactory();
final d = Directory('${base.path}/audio_cache');
if (!await d.exists()) await d.create(recursive: true);
return d.path;
}
/// True if the trackId has a complete file on disk.
Future<bool> isCached(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.getSingleOrNull();
if (row == null) return false;
return File(row.path).existsSync();
}
/// Returns the local file path if cached, else null.
Future<String?> pathFor(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.getSingleOrNull();
if (row == null) return null;
return File(row.path).existsSync() ? row.path : null;
}
/// Downloads the track's stream to disk and indexes it. Idempotent —
/// returns the existing path if already cached.
Future<String?> pin(String trackId, {required CacheSource source}) async {
final existing = await pathFor(trackId);
if (existing != null) return existing;
final dir = await _tracksDir();
final path = '$dir/$trackId.mp3';
final dio = await _dioFactory();
try {
await dio.download('/api/tracks/$trackId/stream', path);
} catch (_) {
final f = File(path);
if (f.existsSync()) await f.delete();
return null;
}
final size = await File(path).length();
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: trackId,
path: path,
sizeBytes: size,
source: source,
),
);
return path;
}
/// Removes a track from the index AND deletes the file.
Future<void> unpin(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.getSingleOrNull();
if (row == null) return;
final f = File(row.path);
if (f.existsSync()) await f.delete();
await (_db.delete(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.go();
}
/// Total bytes used by the cache.
Future<int> usageBytes() async {
final result = await _db.customSelect(
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
readsFrom: {_db.audioCacheIndex},
).getSingle();
return result.read<int>('total');
}
/// Evicts files until usage ≤ targetBytes. Eviction order:
/// incidental → autoPrefetch → autoPlaylist → autoLiked.
/// `manual` never evicts via this path; only clearAll() removes them.
Future<void> evict({required int targetBytes}) async {
final used = await usageBytes();
if (used <= targetBytes) return;
final order = [
CacheSource.incidental,
CacheSource.autoPrefetch,
CacheSource.autoPlaylist,
CacheSource.autoLiked,
];
int remaining = used - targetBytes;
for (final src in order) {
if (remaining <= 0) break;
final rows = await (_db.select(_db.audioCacheIndex)
..where((t) => t.source.equalsValue(src))
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
.get();
for (final row in rows) {
if (remaining <= 0) break;
final f = File(row.path);
if (f.existsSync()) await f.delete();
await (_db.delete(_db.audioCacheIndex)
..where((t) => t.trackId.equals(row.trackId)))
.go();
remaining -= row.sizeBytes;
}
}
}
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
/// button — `manual` source rows are removed here but only here.
Future<void> clearAll() async {
final dir = await _tracksDir();
final d = Directory(dir);
if (d.existsSync()) {
for (final entity in d.listSync()) {
if (entity is File) await entity.delete();
}
}
await _db.delete(_db.audioCacheIndex).go();
}
}
/// AppDb singleton. One per app run; ref.onDispose closes it.
final appDbProvider = Provider<AppDb>((ref) {
final db = AppDb();
ref.onDispose(db.close);
return db;
});
final audioCacheManagerProvider = Provider<AudioCacheManager>((ref) {
final db = ref.watch(appDbProvider);
return AudioCacheManager(
db: db,
dioFactory: () async => ref.read(dioProvider.future),
);
});
+49
View File
@@ -0,0 +1,49 @@
// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C).
//
// Subscribes to a drift watch() stream. On each emission:
// - non-empty → map to result type T and yield
// - empty + online → fetch via REST, populate drift, await re-emission
// - empty + offline → yield mapped empty result (UI shows empty state)
//
// The pattern lets every read provider trust drift as the source of
// truth. SyncController keeps drift fresh in the background; widget
// rebuilds happen automatically as drift writes propagate via watch().
import 'dart:async';
/// Wraps the watch + cold-cache fallback pattern. Generic over:
/// D — the drift row type (or TypedResult for joins)
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
///
/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()`
/// returns true. It must populate drift via its own side-effect; the
/// drift watch() stream will re-emit and this helper yields the
/// populated rows on the next iteration.
///
/// REST failures are swallowed — the helper falls through to yielding
/// the empty result. Caller is responsible for surfacing errors via
/// toast etc.
Stream<T> cacheFirst<D, T>({
required Stream<List<D>> driftStream,
required Future<void> Function() fetchAndPopulate,
required T Function(List<D>) toResult,
required Future<bool> Function() isOnline,
}) async* {
await for (final rows in driftStream) {
if (rows.isNotEmpty) {
yield toResult(rows);
continue;
}
if (await isOnline()) {
try {
await fetchAndPopulate();
// The drift watch() stream re-emits with the populated rows on
// the next loop iteration. Don't yield here.
} catch (_) {
yield toResult(rows); // empty result; caller surfaces error
}
} else {
yield toResult(rows); // empty result; offline
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
/// Operator-tunable cache settings (#357 plan B). Persisted via
/// flutter_secure_storage on the same device.
class CacheSettings {
const CacheSettings({
required this.capBytes,
required this.prefetchWindow,
required this.cacheLikedTracks,
});
/// 0 = unlimited.
final int capBytes;
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
final int prefetchWindow;
/// When true, every like_track event triggers a pin with source autoLiked.
final bool cacheLikedTracks;
CacheSettings copyWith({
int? capBytes,
int? prefetchWindow,
bool? cacheLikedTracks,
}) =>
CacheSettings(
capBytes: capBytes ?? this.capBytes,
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
);
static const defaults = CacheSettings(
capBytes: 5 * 1024 * 1024 * 1024,
prefetchWindow: 5,
cacheLikedTracks: true,
);
}
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
static const _kCap = 'cache_cap_bytes';
static const _kPrefetch = 'cache_prefetch_window';
static const _kCacheLiked = 'cache_liked_tracks';
late FlutterSecureStorage _storage;
@override
Future<CacheSettings> build() async {
_storage = ref.read(secureStorageProvider);
final cap = await _storage.read(key: _kCap);
final pre = await _storage.read(key: _kPrefetch);
final liked = await _storage.read(key: _kCacheLiked);
return CacheSettings(
capBytes: cap == null
? CacheSettings.defaults.capBytes
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
prefetchWindow: pre == null
? CacheSettings.defaults.prefetchWindow
: (int.tryParse(pre) ?? 5).clamp(1, 10),
cacheLikedTracks: liked == null
? CacheSettings.defaults.cacheLikedTracks
: liked == 'true',
);
}
Future<void> setCapBytes(int bytes) async {
await _storage.write(key: _kCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(capBytes: bytes));
}
Future<void> setPrefetchWindow(int n) async {
final clamped = n.clamp(1, 10);
await _storage.write(key: _kPrefetch, value: clamped.toString());
state = AsyncData(state.value!.copyWith(prefetchWindow: clamped));
}
Future<void> setCacheLikedTracks(bool on) async {
await _storage.write(key: _kCacheLiked, value: on.toString());
state = AsyncData(state.value!.copyWith(cacheLikedTracks: on));
}
}
final cacheSettingsProvider =
AsyncNotifierProvider<CacheSettingsController, CacheSettings>(
CacheSettingsController.new);
+13
View File
@@ -0,0 +1,13 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Online if at least one connectivity result is non-none.
/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator
/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and
/// pull/cache".
final connectivityProvider = StreamProvider<bool>((ref) {
final c = Connectivity();
return c.onConnectivityChanged.map(
(results) => results.any((r) => r != ConnectivityResult.none),
);
});
+152
View File
@@ -0,0 +1,152 @@
// Drift database for Minstrel's offline cache (#357 plan B).
//
// Two cache layers in one database:
// - Metadata cache (CachedArtists, CachedAlbums, CachedTracks,
// CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated
// by SyncController via /api/library/sync
// - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager
//
// Plus SyncMetadata holding the latest sync cursor.
//
// All entity ids are TEXT (server-side UUIDs serialized as strings).
// build_runner generates db.g.dart from this file; it's gitignored and
// regenerated in CI.
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'db.g.dart';
class CachedArtists extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get sortName => text()();
TextColumn get mbid => text().nullable()();
TextColumn get artistThumbPath => text().nullable()();
TextColumn get artistFanartPath => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedAlbums extends Table {
TextColumn get id => text()();
TextColumn get artistId => text()();
TextColumn get title => text()();
TextColumn get sortTitle => text()();
TextColumn get releaseDate => text().nullable()();
TextColumn get coverPath => text().nullable()();
TextColumn get mbid => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedTracks extends Table {
TextColumn get id => text()();
TextColumn get albumId => text()();
TextColumn get artistId => text()();
TextColumn get title => text()();
IntColumn get durationMs => integer().withDefault(const Constant(0))();
IntColumn get trackNumber => integer().nullable()();
IntColumn get discNumber => integer().nullable()();
TextColumn get filePath => text().nullable()();
TextColumn get fileFormat => text().nullable()();
TextColumn get genre => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedLikes extends Table {
TextColumn get userId => text()();
TextColumn get entityType => text()(); // 'track' | 'album' | 'artist'
TextColumn get entityId => text()();
DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {userId, entityType, entityId};
}
class CachedPlaylists extends Table {
TextColumn get id => text()();
TextColumn get userId => text()();
TextColumn get name => text()();
TextColumn get description => text().withDefault(const Constant(''))();
BoolColumn get isPublic => boolean().withDefault(const Constant(false))();
TextColumn get coverPath => text().nullable()();
IntColumn get trackCount => integer().withDefault(const Constant(0))();
IntColumn get durationSec => integer().withDefault(const Constant(0))();
/// Server's system_variant: null for user playlists, "for_you" /
/// "songs_like_artist" / "discover" for system-generated mixes.
/// Added in schemaVersion 2 to let the add-to-playlist sheet filter
/// out system playlists locally without a REST round-trip.
TextColumn get systemVariant => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedPlaylistTracks extends Table {
TextColumn get playlistId => text()();
TextColumn get trackId => text()();
IntColumn get position => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {playlistId, trackId};
}
/// One row per fully-downloaded audio file. `source` drives tiered LRU
/// eviction; `incidental` evicts first, `manual` last.
class AudioCacheIndex extends Table {
TextColumn get trackId => text()();
TextColumn get path => text()();
IntColumn get sizeBytes => integer()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get source => textEnum<CacheSource>()();
@override
Set<Column> get primaryKey => {trackId};
}
/// Single-row table holding the latest sync cursor.
class SyncMetadata extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
IntColumn get cursor => integer().withDefault(const Constant(0))();
DateTimeColumn get lastSyncAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
CachedArtists,
CachedAlbums,
CachedTracks,
CachedLikes,
CachedPlaylists,
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) {
// Schema 2: add CachedPlaylists.systemVariant. Existing
// rows get null and the next sync rebuilds them with the
// server's system_variant value.
await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant);
// Reset cursor so the next /api/library/sync re-emits all
// playlists; otherwise the new column would stay null on
// pre-existing rows until they happen to change server-side.
await customStatement('UPDATE sync_metadata SET cursor = 0');
}
},
);
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../player/player_provider.dart';
import 'audio_cache_manager.dart';
import 'cache_settings_provider.dart';
import 'db.dart';
/// Listens to the player's currently-playing track. When it changes,
/// computes the next-N tracks ahead in the queue and pins them via
/// AudioCacheManager (source: autoPrefetch). After pinning, runs an
/// eviction pass against the operator-set cap.
///
/// The window N comes from cacheSettingsProvider.prefetchWindow
/// (default 5, configurable in Settings).
class Prefetcher {
Prefetcher(this._ref) {
// The mediaItem stream changes whenever the active track changes
// (skipNext/skipPrev or natural progression). Settings changes (e.g.
// operator bumps prefetch window) also trigger a reconcile.
_ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (_, __) => _reconcile());
_ref.listen<AsyncValue<CacheSettings>>(cacheSettingsProvider, (_, __) => _reconcile());
}
final Ref _ref;
Future<void> _reconcile() async {
final settings = _ref.read(cacheSettingsProvider).value;
if (settings == null) return;
final queue = _ref.read(queueProvider).value ?? const <MediaItem>[];
final current = _ref.read(mediaItemProvider).value;
if (queue.isEmpty || current == null) return;
final currentIdx = queue.indexWhere((m) => m.id == current.id);
if (currentIdx < 0) return;
final endIdx =
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
final mgr = _ref.read(audioCacheManagerProvider);
for (var i = currentIdx; i <= endIdx; i++) {
final trackId = queue[i].id;
if (await mgr.isCached(trackId)) continue;
// Fire-and-forget; downloads happen in the background. Errors
// inside pin() are caught + cleaned up internally.
// ignore: unawaited_futures
mgr.pin(trackId, source: CacheSource.autoPrefetch);
}
// Eviction pass after pinning new files.
if (settings.capBytes > 0) {
await mgr.evict(targetBytes: settings.capBytes);
}
}
}
/// Read this provider once at app start to activate the prefetcher.
/// Constructor wires the listeners.
final prefetcherProvider = Provider<Prefetcher>((ref) {
return Prefetcher(ref);
});
+290
View File
@@ -0,0 +1,290 @@
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart' show dioProvider;
import 'audio_cache_manager.dart' show appDbProvider;
import 'db.dart';
/// Counts returned from a sync operation. Surfaced to the operator-
/// facing "Sync now" button + Settings card.
class SyncResult {
const SyncResult({
required this.upserts,
required this.deletes,
required this.cursor,
});
final int upserts;
final int deletes;
final int cursor;
}
/// Drives the delta-sync against the server (#357 Plan A endpoint).
/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`,
/// applies upserts + deletes, advances cursor.
class SyncController extends AsyncNotifier<SyncResult?> {
@override
Future<SyncResult?> build() async => null;
Future<SyncResult?> sync() async {
state = const AsyncLoading();
try {
final db = ref.read(appDbProvider);
final dio = await ref.read(dioProvider.future);
final meta = await db.select(db.syncMetadata).getSingleOrNull();
final cursor = meta?.cursor ?? 0;
final resp = await dio.get<dynamic>(
'/api/library/sync',
queryParameters: {'since': cursor},
);
// 204 No Content — no changes since cursor.
if (resp.statusCode == 204) {
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(
lastSyncAt: drift.Value(DateTime.now()),
),
);
final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor);
state = AsyncData(r);
return r;
}
// 410 Gone — cursor too old, server has compacted past it. Reset
// local state and retry once with cursor=0.
if (resp.statusCode == 410) {
await db.transaction(() async {
await db.delete(db.cachedArtists).go();
await db.delete(db.cachedAlbums).go();
await db.delete(db.cachedTracks).go();
await db.delete(db.cachedLikes).go();
await db.delete(db.cachedPlaylists).go();
await db.delete(db.cachedPlaylistTracks).go();
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(cursor: const drift.Value(0)),
);
});
return sync();
}
final body = resp.data as Map<String, dynamic>;
final newCursor = (body['cursor'] as num).toInt();
final upserts = body['upserts'] as Map<String, dynamic>? ?? {};
final deletes = body['deletes'] as Map<String, dynamic>? ?? {};
var upsertCount = 0;
var deleteCount = 0;
await db.transaction(() async {
// ---- Upserts ----
for (final a in (upserts['artist'] as List? ?? const [])) {
await db.into(db.cachedArtists).insertOnConflictUpdate(
_artistFromJson(a as Map<String, dynamic>),
);
upsertCount++;
}
for (final a in (upserts['album'] as List? ?? const [])) {
await db.into(db.cachedAlbums).insertOnConflictUpdate(
_albumFromJson(a as Map<String, dynamic>),
);
upsertCount++;
}
for (final t in (upserts['track'] as List? ?? const [])) {
await db.into(db.cachedTracks).insertOnConflictUpdate(
_trackFromJson(t as Map<String, dynamic>),
);
upsertCount++;
}
for (final l in (upserts['like_track'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'track',
entityId: m['track_id'] as String,
),
);
upsertCount++;
}
for (final l in (upserts['like_album'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'album',
entityId: m['album_id'] as String,
),
);
upsertCount++;
}
for (final l in (upserts['like_artist'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'artist',
entityId: m['artist_id'] as String,
),
);
upsertCount++;
}
for (final p in (upserts['playlist'] as List? ?? const [])) {
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
_playlistFromJson(p as Map<String, dynamic>),
);
upsertCount++;
}
for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
final m = pt as Map<String, dynamic>;
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
CachedPlaylistTracksCompanion.insert(
playlistId: m['playlist_id'] as String,
trackId: m['track_id'] as String,
),
);
upsertCount++;
}
// ---- Deletes ----
for (final id in (deletes['artist'] as List? ?? const [])) {
await (db.delete(db.cachedArtists)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['album'] as List? ?? const [])) {
await (db.delete(db.cachedAlbums)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['track'] as List? ?? const [])) {
await (db.delete(db.cachedTracks)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['like_track'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('track') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['like_album'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('album') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['like_artist'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('artist') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['playlist'] as List? ?? const [])) {
await (db.delete(db.cachedPlaylists)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['playlist_track'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedPlaylistTracks)
..where((t) =>
t.playlistId.equals(parts[0]) &
t.trackId.equals(parts[1])))
.go();
deleteCount++;
}
// ---- Cursor + lastSyncAt ----
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(
cursor: drift.Value(newCursor),
lastSyncAt: drift.Value(DateTime.now()),
),
);
});
final result = SyncResult(
upserts: upsertCount,
deletes: deleteCount,
cursor: newCursor,
);
state = AsyncData(result);
return result;
} catch (e, st) {
state = AsyncError(e, st);
return null;
}
}
CachedArtistsCompanion _artistFromJson(Map<String, dynamic> j) =>
CachedArtistsCompanion.insert(
id: j['id'] as String,
name: (j['name'] as String?) ?? '',
sortName: (j['sort_name'] as String?) ?? '',
mbid: drift.Value(j['mbid'] as String?),
artistThumbPath: drift.Value(j['artist_thumb_path'] as String?),
artistFanartPath: drift.Value(j['artist_fanart_path'] as String?),
);
CachedAlbumsCompanion _albumFromJson(Map<String, dynamic> j) =>
CachedAlbumsCompanion.insert(
id: j['id'] as String,
artistId: j['artist_id'] as String,
title: (j['title'] as String?) ?? '',
sortTitle: (j['sort_title'] as String?) ?? '',
releaseDate: drift.Value(j['release_date'] as String?),
coverPath: drift.Value(j['cover_art_path'] as String?),
mbid: drift.Value(j['mbid'] as String?),
);
CachedTracksCompanion _trackFromJson(Map<String, dynamic> j) =>
CachedTracksCompanion.insert(
id: j['id'] as String,
albumId: j['album_id'] as String,
artistId: j['artist_id'] as String,
title: (j['title'] as String?) ?? '',
durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0),
trackNumber: drift.Value((j['track_number'] as num?)?.toInt()),
discNumber: drift.Value((j['disc_number'] as num?)?.toInt()),
filePath: drift.Value(j['file_path'] as String?),
fileFormat: drift.Value(j['file_format'] as String?),
genre: drift.Value(j['genre'] as String?),
);
CachedPlaylistsCompanion _playlistFromJson(Map<String, dynamic> j) =>
CachedPlaylistsCompanion.insert(
id: j['id'] as String,
userId: j['user_id'] as String,
name: (j['name'] as String?) ?? '',
description: drift.Value((j['description'] as String?) ?? ''),
isPublic: drift.Value((j['is_public'] as bool?) ?? false),
coverPath: drift.Value(j['cover_path'] as String?),
trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0),
durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0),
systemVariant: drift.Value(j['system_variant'] as String?),
);
}
final syncControllerProvider =
AsyncNotifierProvider<SyncController, SyncResult?>(SyncController.new);
@@ -7,6 +7,7 @@ import '../api/endpoints/discover.dart';
import '../api/errors.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/lidarr.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
@@ -87,6 +88,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
onPressed: () => context.pop(),
),
title: Text('Discover', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/discover')],
),
body: Column(children: [
Padding(
@@ -160,8 +162,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
final rows = snap.data ?? const [];
if (rows.isEmpty) {
return Center(
child: Text('No matches.',
style: TextStyle(color: fs.ash)),
child: Text('Nothing to add for that search yet.',
style: TextStyle(color: fs.ash),
textAlign: TextAlign.center),
);
}
return ListView.separated(
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart';
import '../player/player_provider.dart';
import '../theme/theme_extension.dart';
@@ -38,6 +40,21 @@ class AlbumDetailScreen extends ConsumerWidget {
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
],
)),
IconButton(
key: const Key('download_album_button'),
icon: Icon(Icons.download, color: fs.ash),
tooltip: 'Download album',
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in r.tracks) {
// ignore: unawaited_futures
mgr.pin(t.id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
);
},
),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
+292 -85
View File
@@ -6,15 +6,21 @@ import 'package:go_router/go_router.dart';
import '../api/errors.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/delayed_loading.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
import 'widgets/compact_track_card.dart';
import 'widgets/horizontal_scroll_row.dart';
import 'widgets/track_row.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@@ -22,99 +28,300 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final home = ref.watch(homeProvider);
final allPlaylists = ref.watch(playlistsListProvider('all'));
final status = ref.watch(systemPlaylistsStatusProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
actions: [
IconButton(
icon: Icon(Icons.library_music, color: fs.parchment),
tooltip: 'Library',
onPressed: () => context.push('/library'),
),
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
tooltip: 'Playlists',
onPressed: () => context.push('/playlists'),
),
IconButton(
icon: Icon(Icons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: Icon(Icons.explore, color: fs.parchment),
tooltip: 'Discover',
onPressed: () => context.push('/discover'),
),
IconButton(
icon: Icon(Icons.settings, color: fs.parchment),
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
],
actions: const [MainAppBarActions(currentRoute: '/home')],
),
body: SafeArea(
child: ref.watch(homeProvider).when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
);
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const Center(child: CircularProgressIndicator()),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(children: [
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
const SizedBox(height: 96),
]),
child: home.when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
);
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const DelayedLoading(
isLoading: true,
whenReady: SizedBox.shrink(),
whileDelayed:
Center(child: CircularProgressIndicator()),
),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(children: [
_PlaylistsSection(
playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
),
),
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
_RediscoverSection(
albums: h.rediscoverAlbums,
artists: h.rediscoverArtists,
),
_MostPlayedSection(tracks: h.mostPlayedTracks),
_LastPlayedSection(artists: h.lastPlayedArtists),
const SizedBox(height: 96),
]),
),
),
),
);
}
Widget _albumsRow(BuildContext ctx, String title, List<AlbumRef> albums) =>
HorizontalScrollRow(
title: title,
children: [
for (final a in albums)
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
],
);
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
HorizontalScrollRow(
title: title,
children: [
for (final a in artists)
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
],
);
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
HorizontalScrollRow(
title: title,
height: 64,
children: [
for (final t in tracks)
SizedBox(
width: 280,
child: TrackRow(
track: t,
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
),
),
],
);
}
class _PlaylistsSection extends StatelessWidget {
const _PlaylistsSection({required this.playlists, required this.status});
final List<Playlist> playlists;
final SystemPlaylistsStatus status;
@override
Widget build(BuildContext context) {
final items = _buildPlaylistsRow(playlists, status);
return HorizontalScrollRow(
title: 'Playlists',
height: 220,
children: items.map((item) {
if (item is _RealPlaylist) {
return PlaylistCard(playlist: item.playlist);
}
final ph = item as _PlaceholderPlaylist;
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
}).toList(),
);
}
}
abstract class _PlaylistRowItem {}
class _RealPlaylist extends _PlaylistRowItem {
_RealPlaylist(this.playlist);
final Playlist playlist;
}
class _PlaceholderPlaylist extends _PlaylistRowItem {
_PlaceholderPlaylist(this.label, this.variant);
final String label;
final String variant;
}
List<_PlaylistRowItem> _buildPlaylistsRow(
List<Playlist> ownedAll,
SystemPlaylistsStatus status,
) {
final out = <_PlaylistRowItem>[];
Playlist? findFirst(bool Function(Playlist) test) {
for (final p in ownedAll) {
if (test(p)) return p;
}
return null;
}
// Slot 1: For-You.
final forYou = findFirst((p) => p.systemVariant == 'for_you');
out.add(forYou != null
? _RealPlaylist(forYou)
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
// Slots 2-4: Songs-like (real first, padded to 3).
final songsLike = ownedAll
.where((p) => p.systemVariant == 'songs_like_artist')
.take(3)
.toList();
for (var i = 0; i < 3; i++) {
out.add(i < songsLike.length
? _RealPlaylist(songsLike[i])
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
}
// User-created trail (server returns most-recently-updated first).
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
out.add(_RealPlaylist(p));
}
return out;
}
String _variantFor(String slot, SystemPlaylistsStatus s) {
if (s.inFlight) return 'building';
if (s.lastError != null) return 'failed';
if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed';
return 'pending';
}
class _RecentlyAddedSection extends StatelessWidget {
const _RecentlyAddedSection({required this.albums});
final List<AlbumRef> albums;
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
return const _EmptySection(
title: 'Recently added',
message: "Nothing added yet. Scan a folder via the server's config.",
);
}
final controller = ScrollController();
final rows = _chunk(albums, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Recently added' : '',
controller: controller,
children: rows[i]
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
),
]);
}
}
class _RediscoverSection extends StatelessWidget {
const _RediscoverSection({required this.albums, required this.artists});
final List<AlbumRef> albums;
final List<ArtistRef> artists;
@override
Widget build(BuildContext context) {
if (albums.isEmpty && artists.isEmpty) {
return const _EmptySection(
title: 'Rediscover',
message:
'No forgotten favourites yet. Like some albums or artists to fill this in.',
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (albums.isNotEmpty)
HorizontalScrollRow(
title: 'Rediscover',
children: albums
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
),
if (artists.isNotEmpty)
HorizontalScrollRow(
title: albums.isEmpty ? 'Rediscover' : '',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
))
.toList(),
),
]);
}
}
class _MostPlayedSection extends StatelessWidget {
const _MostPlayedSection({required this.tracks});
final List<TrackRef> tracks;
@override
Widget build(BuildContext context) {
if (tracks.isEmpty) {
return const _EmptySection(
title: 'Most played',
message: 'No plays to draw from. Listen to something.',
);
}
final controller = ScrollController();
final rows = _chunk(tracks, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Most played' : '',
height: 64,
controller: controller,
children: [
for (var j = 0; j < rows[i].length; j++)
CompactTrackCard(
track: rows[i][j],
sectionTracks: tracks,
index: i * 25 + j,
),
],
),
]);
}
}
class _LastPlayedSection extends StatelessWidget {
const _LastPlayedSection({required this.artists});
final List<ArtistRef> artists;
@override
Widget build(BuildContext context) {
if (artists.isEmpty) {
return const _EmptySection(
title: 'Last played',
message: 'No recent plays.',
);
}
return HorizontalScrollRow(
title: 'Last played',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
))
.toList(),
);
}
}
class _EmptySection extends StatelessWidget {
const _EmptySection({required this.title, required this.message});
final String title;
final String message;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
const SizedBox(height: 8),
Text(message, style: TextStyle(color: fs.ash)),
]),
);
}
}
void _push(BuildContext context, String path) {
context.push(path);
}
List<List<T>> _chunk<T>(List<T> items, int size) {
final out = <List<T>>[];
for (var i = 0; i < items.length; i += size) {
out.add(items.sublist(i, i + size > items.length ? items.length : i + size));
}
return out;
}
+152 -12
View File
@@ -1,9 +1,15 @@
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/client.dart';
import '../api/endpoints/library.dart';
import '../auth/auth_provider.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/home_data.dart';
@@ -37,24 +43,158 @@ final homeProvider = FutureProvider<HomeData>((ref) async {
return (await ref.watch(libraryApiProvider.future)).getHome();
});
/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
final artistProvider =
FutureProvider.family<ArtistRef, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
);
});
final artistAlbumsProvider =
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
final db = ref.watch(appDbProvider);
// Join cached_albums + cached_artists to fill artist_name on each row.
final query = db.select(db.cachedAlbums).join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
])..where(db.cachedAlbums.artistId.equals(artistId));
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtistAlbums(artistId);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
});
},
toResult: (rows) => rows.map((r) {
final album = r.readTable(db.cachedAlbums);
final artist = r.readTableOrNull(db.cachedArtists);
return album.toRef(artistName: artist?.name ?? '');
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
);
});
final artistTracksProvider =
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedTracks).join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
])..where(db.cachedTracks.artistId.equals(artistId));
return cacheFirst<drift.TypedResult, List<TrackRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtistTracks(artistId);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
});
},
toResult: (rows) => rows.map((r) {
final track = r.readTable(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
final album = r.readTableOrNull(db.cachedAlbums);
return track.toRef(
artistName: artist?.name ?? '',
albumTitle: album?.title ?? '',
);
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
);
});
final albumProvider =
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
(ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
},
);
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
/// for reactive updates over both rows.
final albumProvider = StreamProvider.family<
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
final db = ref.watch(appDbProvider);
final albumQuery = (db.select(db.cachedAlbums)
..where((t) => t.id.equals(albumId)))
.join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
]);
final tracksQuery = (db.select(db.cachedTracks)
..where((t) => t.albumId.equals(albumId))
..orderBy([
(t) => drift.OrderingTerm.asc(t.discNumber),
(t) => drift.OrderingTerm.asc(t.trackNumber),
]))
.join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
]);
await for (final albumRows in albumQuery.watch()) {
if (albumRows.isEmpty) {
// Cold cache fallback
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getAlbum(albumId);
await db.batch((b) {
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
// watch() re-emits with the populated rows; loop continues.
} catch (_) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
} else {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
continue;
}
final albumRow = albumRows.first;
final album = albumRow.readTable(db.cachedAlbums).toRef(
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
);
final trackRows = await tracksQuery.get();
final tracks = trackRows.map((r) {
final track = r.readTable(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
return track.toRef(
artistName: artist?.name ?? '',
albumTitle: album.title,
);
}).toList();
yield (album: album, tracks: tracks);
}
});
@@ -15,6 +15,7 @@ import '../models/page.dart' as wire;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
@@ -90,6 +91,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Library', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/library')],
bottom: TabBar(
controller: _ctrl,
isScrollable: true,
@@ -130,7 +132,7 @@ class _ArtistsTab extends ConsumerWidget {
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (page) => page.items.isEmpty
? Center(child: Text('No artists.', style: TextStyle(color: fs.ash)))
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
child: GridView.builder(
@@ -162,7 +164,7 @@ class _AlbumsTab extends ConsumerWidget {
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (page) => page.items.isEmpty
? Center(child: Text('No albums.', style: TextStyle(color: fs.ash)))
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
child: GridView.builder(
@@ -239,7 +241,7 @@ class _LikedTab extends ConsumerWidget {
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
}
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
return Center(child: Text('Nothing liked yet.', style: TextStyle(color: fs.ash)));
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
}
return RefreshIndicator(
onRefresh: () async {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/album.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
class AlbumCard extends StatelessWidget {
@@ -27,7 +28,7 @@ class AlbumCard extends StatelessWidget {
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: Image.network(album.coverUrl, fit: BoxFit.cover),
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/artist.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
class ArtistCard extends StatelessWidget {
@@ -26,7 +27,7 @@ class ArtistCard extends StatelessWidget {
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: Image.network(artist.coverUrl, fit: BoxFit.cover),
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../cache/audio_cache_manager.dart';
import '../../theme/theme_extension.dart';
/// Small download glyph shown next to a track row when the track is
/// cached locally. FutureBuilder one-shot — track rows are short-lived
/// and cache state rarely changes mid-render.
class CachedIndicator extends ConsumerWidget {
const CachedIndicator({required this.trackId, super.key});
final String trackId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mgr = ref.read(audioCacheManagerProvider);
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return FutureBuilder<bool>(
future: mgr.isCached(trackId),
builder: (ctx, snap) {
if (snap.data != true) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.download_done, size: 14, color: fs.accent),
);
},
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/track.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart';
import '../../shared/widgets/track_actions/track_actions_button.dart';
import '../../theme/theme_extension.dart';
/// Small horizontal track cell used by the home Most-played section.
/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall).
/// Tap plays from this track within [sectionTracks] starting at [index].
class CompactTrackCard extends ConsumerWidget {
const CompactTrackCard({
super.key,
required this.track,
required this.sectionTracks,
required this.index,
});
final TrackRef track;
/// All tracks in the home section so play-from-here can queue them
/// in order.
final List<TrackRef> sectionTracks;
final int index;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// Cover URL derives from the track's album. Mirrors web
// CompactTrackCard which calls coverUrl(track.album_id).
final coverUrl = track.albumId.isNotEmpty
? '/api/albums/${track.albumId}/cover'
: '';
return SizedBox(
width: 176,
child: InkWell(
onTap: () => ref
.read(playerActionsProvider)
.playTracks(sectionTracks, initialIndex: index),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 48,
height: 48,
child: ServerImage(
url: coverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 13),
),
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 11),
),
],
),
),
TrackActionsButton(track: track),
]),
),
),
);
}
}
@@ -23,17 +23,18 @@ class HorizontalScrollRow extends StatelessWidget {
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
if (title.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
),
),
SizedBox(
height: height,
child: ListView(
@@ -1,19 +1,27 @@
import 'package:flutter/material.dart';
import '../../models/track.dart';
import '../../shared/widgets/track_actions/track_actions_button.dart';
import '../../theme/theme_extension.dart';
import 'cached_indicator.dart';
class TrackRow extends StatelessWidget {
const TrackRow({
required this.track,
required this.onTap,
this.trailing,
this.actions = true,
super.key,
});
final TrackRef track;
final VoidCallback onTap;
final Widget? trailing;
/// Render the 3-dot TrackActionsButton at the end of the row. Default
/// true; pass false in surfaces that don't want the menu (e.g. when
/// showing a static read-only list).
final bool actions;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -48,12 +56,14 @@ class TrackRow extends StatelessWidget {
),
]),
),
CachedIndicator(trackId: track.id),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (trailing != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: trailing!,
),
if (actions) TrackActionsButton(track: track),
]),
),
);
+1 -1
View File
@@ -30,7 +30,7 @@ class LikeButton extends ConsumerWidget {
color: liked ? fs.accent : fs.ash,
size: size,
),
onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id),
onPressed: () => ref.read(likesControllerProvider).toggle(kind, id),
);
}
}
+139 -46
View File
@@ -1,6 +1,12 @@
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../library/library_providers.dart';
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
@@ -22,67 +28,154 @@ class LikedIds {
LikeKind.album => albums.contains(id),
LikeKind.track => tracks.contains(id),
};
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
}
class LikedIdsController extends AsyncNotifier<LikedIds> {
@override
Future<LikedIds> build() async {
final api = await ref.watch(likesApiProvider.future);
final r = await api.ids();
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
}
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
/// SyncController). Reactive — sync writes propagate via watch().
/// Empty + online triggers REST cold-cache fallback that re-populates.
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedLike, LikedIds>(
driftStream: db.select(db.cachedLikes).watch(),
fetchAndPopulate: () async {
final api = await ref.read(likesApiProvider.future);
final fresh = await api.ids();
// Need a userId for the composite primary key. The auth controller
// exposes the current user.
final user = ref.read(authControllerProvider).value;
if (user == null) return; // not logged in; skip
await db.batch((b) {
for (final id in fresh.tracks) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'track',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.albums) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'album',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.artists) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'artist',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
});
},
toResult: (rows) => LikedIds(
tracks: rows
.where((r) => r.entityType == 'track')
.map((r) => r.entityId)
.toSet(),
albums: rows
.where((r) => r.entityType == 'album')
.map((r) => r.entityId)
.toSet(),
artists: rows
.where((r) => r.entityType == 'artist')
.map((r) => r.entityId)
.toSet(),
),
isOnline: () async => (await ref.read(connectivityProvider.future)),
);
});
/// Mutation controller. Writes optimistically to drift first (so the
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
/// REST. Rolls back drift on REST failure.
class LikesController {
LikesController(this._ref);
final Ref _ref;
Future<void> toggle(LikeKind kind, String id) async {
final api = await ref.read(likesApiProvider.future);
final current = state.value ??
const LikedIds(artists: {}, albums: {}, tracks: {});
final user = _ref.read(authControllerProvider).value;
if (user == null) return;
final db = _ref.read(appDbProvider);
final entityType = _entityType(kind);
final isLiked = current.has(kind, id);
final optimistic = _flip(current, kind, id);
state = AsyncData(optimistic);
final existing = await (db.select(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.getSingleOrNull();
final wasLiked = existing != null;
// Optimistic mutation — drift watch() re-emits immediately.
if (wasLiked) {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
} else {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
try {
if (isLiked) {
final api = await _ref.read(likesApiProvider.future);
if (wasLiked) {
await api.unlike(kind, id);
} else {
await api.like(kind, id);
}
} catch (e, st) {
state = AsyncData(current);
// Rollback drift
if (wasLiked) {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
} else {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
}
Error.throwWithStackTrace(e, st);
}
}
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
Set<String> swap(Set<String> s) {
final next = {...s};
if (s.contains(id)) {
next.remove(id);
} else {
next.add(id);
}
return next;
}
return switch (kind) {
LikeKind.artist => LikedIds(
artists: swap(before.artists),
albums: before.albums,
tracks: before.tracks,
),
LikeKind.album => LikedIds(
artists: before.artists,
albums: swap(before.albums),
tracks: before.tracks,
),
LikeKind.track => LikedIds(
artists: before.artists,
albums: before.albums,
tracks: swap(before.tracks),
),
};
}
String _entityType(LikeKind kind) => switch (kind) {
LikeKind.artist => 'artist',
LikeKind.album => 'album',
LikeKind.track => 'track',
};
}
final likedIdsProvider =
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
final likesControllerProvider =
Provider<LikesController>((ref) => LikesController(ref));
@@ -0,0 +1,92 @@
/// One user's report under an aggregated quarantine row.
class AdminQuarantineReport {
const AdminQuarantineReport({
required this.userId,
required this.username,
required this.reason,
this.notes,
required this.createdAt,
});
final String userId;
final String username;
final String reason;
final String? notes;
final String createdAt;
factory AdminQuarantineReport.fromJson(Map<String, dynamic> j) =>
AdminQuarantineReport(
userId: j['user_id'] as String? ?? '',
username: j['username'] as String? ?? '',
reason: j['reason'] as String? ?? '',
notes: j['notes'] as String?,
createdAt: j['created_at'] as String? ?? '',
);
}
/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go.
/// One row aggregates all reports against a single track, with
/// per-reason counts and the underlying per-user reports nested.
class AdminQuarantineItem {
const AdminQuarantineItem({
required this.trackId,
required this.trackTitle,
required this.artistName,
required this.albumTitle,
required this.albumId,
this.lidarrAlbumMbid,
required this.reportCount,
required this.latestAt,
required this.reasonCounts,
required this.reports,
});
final String trackId;
final String trackTitle;
final String artistName;
final String albumTitle;
final String albumId;
final String? lidarrAlbumMbid;
final int reportCount;
final String latestAt;
/// Map of reason → number of reports citing that reason.
final Map<String, int> reasonCounts;
final List<AdminQuarantineReport> reports;
/// One-line summary of the most-cited reason. Returns just the top
/// reason when only one reason exists, or `top (+N more)` when there
/// are additional distinct reasons.
String get topReasonSummary {
if (reasonCounts.isEmpty) return '';
final entries = reasonCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final top = entries.first.key;
return entries.length > 1
? '$top (+${entries.length - 1} more)'
: top;
}
factory AdminQuarantineItem.fromJson(Map<String, dynamic> j) {
final reasonCountsRaw =
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? const {};
final reportsRaw = (j['reports'] as List?) ?? const [];
return AdminQuarantineItem(
trackId: j['track_id'] as String? ?? '',
trackTitle: j['track_title'] as String? ?? '',
artistName: j['artist_name'] as String? ?? '',
albumTitle: j['album_title'] as String? ?? '',
albumId: j['album_id'] as String? ?? '',
lidarrAlbumMbid: j['lidarr_album_mbid'] as String?,
reportCount: (j['report_count'] as int?) ?? 0,
latestAt: j['latest_at'] as String? ?? '',
reasonCounts:
reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)),
reports: reportsRaw
.map((e) =>
AdminQuarantineReport.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
}
}
@@ -0,0 +1,77 @@
/// Mirrors `requestView` from internal/api/requests.go. Server returns
/// these as a flat list (no envelope) from GET /api/admin/requests.
///
/// Note the requester is exposed only as a UUID (`user_id`) — the
/// response does not include the username. The Flutter Requests screen
/// joins client-side against the AdminUser list for display.
class AdminRequest {
const AdminRequest({
required this.id,
required this.userId,
required this.status,
required this.kind,
required this.artistName,
this.albumTitle,
this.trackTitle,
required this.requestedAt,
this.decidedAt,
this.notes,
required this.importedAlbumCount,
required this.importedTrackCount,
this.matchedTrackId,
this.matchedAlbumId,
this.matchedArtistId,
});
final String id;
final String userId;
/// One of: pending, approved, rejected, completed, failed.
final String status;
/// One of: artist, album, track.
final String kind;
final String artistName;
final String? albumTitle;
final String? trackTitle;
final String requestedAt;
final String? decidedAt;
final String? notes;
final int importedAlbumCount;
final int importedTrackCount;
/// Set when the ingest matched into the local library. The user-side
/// "Listen" CTA on a completed request links to whichever id is set
/// (most-specific first: track → album → artist).
final String? matchedTrackId;
final String? matchedAlbumId;
final String? matchedArtistId;
/// Display label depending on the kind of request — what the user
/// asked for. For an album request it's the album title; for an
/// artist request it's the artist name; etc.
String get displayName => switch (kind) {
'album' => albumTitle ?? artistName,
'track' => trackTitle ?? artistName,
_ => artistName,
};
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
id: j['id'] as String? ?? '',
userId: j['user_id'] as String? ?? '',
status: j['status'] as String? ?? 'pending',
kind: j['kind'] as String? ?? 'artist',
artistName: j['artist_name'] as String? ?? '',
albumTitle: j['album_title'] as String?,
trackTitle: j['track_title'] as String?,
requestedAt: j['requested_at'] as String? ?? '',
decidedAt: j['decided_at'] as String?,
notes: j['notes'] as String?,
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
matchedTrackId: j['matched_track_id'] as String?,
matchedAlbumId: j['matched_album_id'] as String?,
matchedArtistId: j['matched_artist_id'] as String?,
);
}
+33
View File
@@ -0,0 +1,33 @@
/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct
/// from MyProfile because admin endpoints expose `auto_approve_requests`
/// and the canonical `created_at` that the user-scoped /me response
/// intentionally omits.
///
/// The list endpoint wraps these in `{"users": [...]}`; the parsing of
/// the envelope is done in `AdminUsersApi`, not here.
class AdminUser {
const AdminUser({
required this.id,
required this.username,
this.displayName,
required this.isAdmin,
required this.autoApproveRequests,
required this.createdAt,
});
final String id;
final String username;
final String? displayName;
final bool isAdmin;
final bool autoApproveRequests;
final String createdAt;
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: j['id'] as String? ?? '',
username: j['username'] as String? ?? '',
displayName: j['display_name'] as String?,
isAdmin: j['is_admin'] as bool? ?? false,
autoApproveRequests: j['auto_approve_requests'] as bool? ?? false,
createdAt: j['created_at'] as String? ?? '',
);
}
+40
View File
@@ -0,0 +1,40 @@
/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list
/// endpoint wraps these in `{"invites": [...]}`; the create endpoint
/// returns a single bare invite. Envelope parsing is done in
/// `AdminInvitesApi`, not here.
///
/// `invitedBy` is the UUID of the inviting admin (not their username);
/// the server doesn't currently denormalize it. Likewise `redeemedBy`.
/// TTL is hardcoded server-side at 24h, so admins can't customise the
/// expiry — only the optional `note`.
class Invite {
const Invite({
required this.token,
required this.invitedBy,
this.note,
required this.createdAt,
required this.expiresAt,
this.redeemedAt,
this.redeemedBy,
});
final String token;
final String invitedBy;
final String? note;
final String createdAt;
final String expiresAt;
final String? redeemedAt;
final String? redeemedBy;
bool get isRedeemed => redeemedAt != null;
factory Invite.fromJson(Map<String, dynamic> j) => Invite(
token: j['token'] as String? ?? '',
invitedBy: j['invited_by'] as String? ?? '',
note: j['note'] as String?,
createdAt: j['created_at'] as String? ?? '',
expiresAt: j['expires_at'] as String? ?? '',
redeemedAt: j['redeemed_at'] as String?,
redeemedBy: j['redeemed_by'] as String?,
);
}
@@ -0,0 +1,24 @@
/// Mirrors `systemPlaylistsStatusResp` from internal/api/me_system_playlists.go.
/// Reflects the caller's most recent system_playlist_runs row, or zero
/// values when no row exists yet (the user has never had a build attempted).
class SystemPlaylistsStatus {
const SystemPlaylistsStatus({
required this.inFlight,
this.lastRunAt,
this.lastError,
});
final bool inFlight;
final String? lastRunAt;
final String? lastError;
factory SystemPlaylistsStatus.empty() =>
const SystemPlaylistsStatus(inFlight: false);
factory SystemPlaylistsStatus.fromJson(Map<String, dynamic> j) =>
SystemPlaylistsStatus(
inFlight: j['in_flight'] as bool? ?? false,
lastRunAt: j['last_run_at'] as String?,
lastError: j['last_error'] as String?,
);
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Caches album cover bytes to disk so MediaItem.artUri can point at a
/// file:// URI. Android's MediaSession framework fetches artUri itself
/// and doesn't carry our Bearer header, so we pre-fetch via the
/// authenticated dio and hand the system a local file path instead.
///
/// Cache layout: `{applicationCacheDirectory}/album_covers/{albumId}.jpg`.
/// No explicit eviction — covers are tiny and OS clears app cache when
/// space is tight.
class AlbumCoverCache {
AlbumCoverCache({
required Future<Dio> Function() dioFactory,
Future<Directory> Function()? cacheDirFactory,
}) : _dioFactory = dioFactory,
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
final Future<Dio> Function() _dioFactory;
final Future<Directory> Function() _cacheDirFactory;
/// In-flight requests keyed by albumId so concurrent callers for the
/// same album dedupe to one fetch.
final Map<String, Future<String?>> _inflight = {};
/// Returns local file path to the album cover, or null on any
/// failure (network, 4xx/5xx, disk full, empty albumId).
Future<String?> getOrFetch(String albumId) {
if (albumId.isEmpty) return Future.value(null);
final existing = _inflight[albumId];
if (existing != null) return existing;
final fut = _doFetch(albumId);
_inflight[albumId] = fut;
fut.whenComplete(() => _inflight.remove(albumId));
return fut;
}
Future<String?> _doFetch(String albumId) async {
try {
final dir = await _cacheDirFactory();
final coversDir = Directory('${dir.path}/album_covers');
await coversDir.create(recursive: true);
final filePath = '${coversDir.path}/$albumId.jpg';
final file = File(filePath);
if (await file.exists()) return filePath;
final dio = await _dioFactory();
final r = await dio.get<List<int>>(
'/api/albums/$albumId/cover',
options: Options(responseType: ResponseType.bytes),
);
final bytes = r.data;
if (bytes == null || bytes.isEmpty) return null;
await file.writeAsBytes(bytes, flush: true);
return filePath;
} catch (e) {
debugPrint('AlbumCoverCache: fetch failed for $albumId: $e');
return null;
}
}
}
+147 -12
View File
@@ -1,20 +1,41 @@
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../models/track.dart';
import 'album_cover_cache.dart';
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
MinstrelAudioHandler() {
_player.playbackEventStream.listen(_broadcastState);
_player.currentIndexStream.listen(_onCurrentIndexChanged);
}
final AudioPlayer _player = AudioPlayer();
String _baseUrl = '';
String? _token;
AlbumCoverCache? _coverCache;
AudioCacheManager? _audioCacheManager;
void configure({required String baseUrl, required String? token}) {
void configure({
required String baseUrl,
required String? token,
AlbumCoverCache? coverCache,
AudioCacheManager? audioCacheManager,
}) {
_baseUrl = baseUrl;
_token = token;
if (coverCache != null) _coverCache = coverCache;
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
'tokenPresent=${token != null && token.isNotEmpty} '
'coverCachePresent=${_coverCache != null} '
'audioCachePresent=${_audioCacheManager != null}');
}
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
@@ -24,20 +45,107 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
}
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
final sources = tracks.map((t) {
final url = t.streamUrl.isNotEmpty
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
? t.streamUrl
: '$_baseUrl${t.streamUrl}')
: '$_baseUrl/api/tracks/${t.id}/stream';
return AudioSource.uri(Uri.parse(url), headers: headers);
}).toList();
debugPrint('audio_handler.setQueueFromTracks: '
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
final sources = await Future.wait(tracks.map(_buildAudioSource));
await _player.setAudioSources(
sources,
initialIndex: initialIndex,
);
// Kick the cover fetch for the initial item — async, doesn't block
// playback. Subsequent track changes are handled by the
// currentIndexStream listener.
unawaited(_loadArtForCurrentItem());
}
String _resolveStreamUrl(TrackRef t) {
if (t.streamUrl.isEmpty) {
return '$_baseUrl/api/tracks/${t.id}/stream';
}
final parsed = Uri.tryParse(t.streamUrl);
if (parsed != null && parsed.hasScheme) {
return t.streamUrl;
}
return '$_baseUrl${t.streamUrl}';
}
/// Builds an AudioSource for a track. Cache-aware:
/// 1. If the track is fully cached on disk, returns a file:// source.
/// 2. Else returns a LockCachingAudioSource that streams + caches as
/// it plays (subsequent plays will hit the cache).
///
/// Without an audio cache manager configured (e.g. in older code paths
/// that pre-date #357), falls back to a plain network AudioSource.uri.
Future<AudioSource> _buildAudioSource(TrackRef t) async {
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
final mgr = _audioCacheManager;
// 1. Cache hit: play from disk, no headers needed.
if (mgr != null) {
final path = await mgr.pathFor(t.id);
if (path != null) {
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
return AudioSource.uri(Uri.file(path));
}
}
final url = _resolveStreamUrl(t);
final parsed = Uri.parse(url);
if (!parsed.hasScheme || parsed.host.isEmpty) {
throw StateError(
'audio_handler: refused to play scheme-less URL "$url" '
'(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", '
'track.id="${t.id}"). configure() must be called with a '
'non-empty baseUrl before setQueueFromTracks().',
);
}
// 2. Cache miss WITH manager: stream + cache-as-you-play. Future
// plays of this track will hit the cache. If the cache write
// completes, we don't currently register an index row — that would
// require a download-complete hook from just_audio that's not
// exposed cleanly. Acceptable for v1: prefetcher / explicit
// pin / Download buttons cover the index path; LockCaching handles
// the network optimization.
if (mgr != null) {
final cacheDir = await getApplicationCacheDirectory();
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
'using LockCachingAudioSource → ${cacheFile.path}');
// ignore: experimental_member_use
return LockCachingAudioSource(parsed,
headers: headers, cacheFile: cacheFile);
}
// 3. No manager configured: plain network source (legacy path).
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
return AudioSource.uri(parsed, headers: headers);
}
/// Inserts [track] right after the currently-playing item so it plays
/// next. If nothing is playing, appends to the end.
Future<void> playNext(TrackRef track) async {
final source = await _buildAudioSource(track);
final item = _toMediaItem(track);
final currentIdx = _player.currentIndex;
final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1;
await _player.insertAudioSource(insertAt, source);
final current = queue.value;
queue.add([
...current.sublist(0, insertAt),
item,
...current.sublist(insertAt),
]);
}
/// Appends [track] to the end of the queue.
Future<void> enqueue(TrackRef track) async {
final source = await _buildAudioSource(track);
final item = _toMediaItem(track);
await _player.addAudioSource(source);
queue.add([...queue.value, item]);
}
MediaItem _toMediaItem(TrackRef t) => MediaItem(
@@ -46,8 +154,35 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
// Stash album_id in extras so _loadArtForCurrentItem can pull it
// back without re-walking the track list.
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
);
void _onCurrentIndexChanged(int? idx) {
if (idx == null) return;
unawaited(_loadArtForCurrentItem());
}
/// Async-fetches the cover for whichever item is currently active and
/// pushes a MediaItem update with artUri set. No-op if no cache is
/// configured, no current item, the item has no album_id in extras,
/// or the fetch returns null.
Future<void> _loadArtForCurrentItem() async {
final cache = _coverCache;
if (cache == null) return;
final current = mediaItem.value;
if (current == null) return;
final albumId = current.extras?['album_id'] as String?;
if (albumId == null || albumId.isEmpty) return;
if (current.artUri != null) return; // already set
final path = await cache.getOrFetch(albumId);
if (path == null) return;
// Discard if the user advanced to another track while we waited.
if (mediaItem.value?.id != current.id) return;
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
}
@override
Future<void> play() => _player.play();
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/track.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'player_provider.dart';
@@ -46,7 +48,28 @@ class NowPlayingScreen extends ConsumerWidget {
width: 280, height: 280, color: fs.slate,
),
const SizedBox(height: 24),
Text(media.title, style: TextStyle(color: fs.parchment, fontSize: 22)),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Flexible(
child: Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
overflow: TextOverflow.ellipsis,
),
),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '', // not stashed in MediaItem.extras today
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
),
]),
Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)),
const SizedBox(height: 16),
Slider(
+28 -1
View File
@@ -2,13 +2,22 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/track.dart';
import 'album_cover_cache.dart';
import 'audio_handler.dart';
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
throw UnimplementedError('overridden in main()');
});
final albumCoverCacheProvider = Provider<AlbumCoverCache>((ref) {
return AlbumCoverCache(
dioFactory: () => ref.read(dioProvider.future),
);
});
final playbackStateProvider = StreamProvider<PlaybackState>(
(ref) => ref.watch(audioHandlerProvider).playbackState,
);
@@ -28,10 +37,28 @@ class PlayerActions {
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
final url = await _ref.read(serverUrlProvider.future);
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token);
final cache = _ref.read(albumCoverCacheProvider);
final audioCache = _ref.read(audioCacheManagerProvider);
final h = _ref.read(audioHandlerProvider)
..configure(
baseUrl: url ?? '',
token: token,
coverCache: cache,
audioCacheManager: audioCache,
);
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
await h.play();
}
Future<void> playNext(TrackRef track) async {
final h = _ref.read(audioHandlerProvider);
await h.playNext(track);
}
Future<void> enqueue(TrackRef track) async {
final h = _ref.read(audioHandlerProvider);
await h.enqueue(track);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../models/playlist.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'playlists_provider.dart';
@@ -50,6 +53,7 @@ class _Body extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final tracks = detail.tracks;
final playable = tracks.where((t) => t.isAvailable).toList();
@@ -57,9 +61,22 @@ class _Body extends ConsumerWidget {
onRefresh: () async =>
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
child: ListView.builder(
itemCount: tracks.length + 1, // header + rows
// Header + (track rows | empty hint).
itemCount: tracks.isEmpty ? 2 : tracks.length + 1,
itemBuilder: (ctx, i) {
if (i == 0) return _Header(detail: detail, playable: playable);
if (tracks.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'No tracks yet. Add some via the "Add to playlist…" entry on any track row.',
style: TextStyle(color: fs.ash),
textAlign: TextAlign.center,
),
),
);
}
final t = tracks[i - 1];
return _PlaylistTrackRow(
row: t,
@@ -123,7 +140,26 @@ class _Header extends ConsumerWidget {
style: TextStyle(color: fs.ash, fontSize: 12),
),
const Spacer(),
if (playable.isNotEmpty)
if (playable.isNotEmpty) ...[
OutlinedButton.icon(
key: const Key('download_playlist_button'),
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in playable) {
final id = t.trackId ?? '';
if (id.isEmpty) continue;
// Fire-and-forget; downloads happen in background.
// ignore: unawaited_futures
mgr.pin(id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
);
},
icon: const Icon(Icons.download, size: 16),
label: const Text('Download'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () {
final refs = playable.map(_toTrackRef).toList(growable: false);
@@ -136,6 +172,7 @@ class _Header extends ConsumerWidget {
foregroundColor: fs.parchment,
),
),
],
]),
]),
);
@@ -184,6 +221,8 @@ class _PlaylistTrackRow extends StatelessWidget {
),
),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (row.trackId != null)
TrackActionsButton(track: _toTrackRef(row)),
]),
),
);
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/playlist.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'playlists_provider.dart';
@@ -22,13 +24,15 @@ class PlaylistsListScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/playlists')],
),
body: list.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (items) {
data: (lists) {
final items = lists.all;
if (items.isEmpty) {
return Center(
child: Text(
@@ -79,7 +83,7 @@ class _PlaylistTile extends StatelessWidget {
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash)
: Image.network(playlist.coverUrl, fit: BoxFit.cover),
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(width: 12),
@@ -1,19 +1,167 @@
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/me.dart';
import '../api/endpoints/playlists.dart';
import '../auth/auth_provider.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
return PlaylistsApi(await ref.watch(dioProvider.future));
});
/// Drift-first per #357 plan C. Returns cached playlists filtered to
/// match the `kind` family arg:
/// - 'user' → only user-created (systemVariant null), owned by current user
/// - 'system' → only system-generated (systemVariant non-null), owned
/// - 'all' → everything: own user playlists + own system + others' public
///
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
/// behavior leaked system playlists into add-to-playlist sheet because we
/// couldn't filter locally.
final playlistsListProvider =
FutureProvider.family<List<Playlist>, String>((ref, kind) async {
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
StreamProvider.family<PlaylistsList, String>((ref, kind) {
final db = ref.watch(appDbProvider);
final user = ref.watch(authControllerProvider).value;
return cacheFirst<CachedPlaylist, PlaylistsList>(
driftStream: db.select(db.cachedPlaylists).watch(),
fetchAndPopulate: () async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.list(kind: kind);
await db.batch((b) {
for (final p in fresh.all) {
b.insert(db.cachedPlaylists, p.toDrift(),
mode: drift.InsertMode.insertOrReplace);
}
});
},
toResult: (rows) {
if (user == null) return PlaylistsList.empty();
final filtered = rows.where((r) {
if (kind == 'user') return r.systemVariant == null;
if (kind == 'system') return r.systemVariant != null;
return true; // 'all'
});
final owned = filtered
.where((r) => r.userId == user.id)
.map((r) => r.toRef())
.toList();
final pub = filtered
.where((r) => r.userId != user.id && r.isPublic)
.map((r) => r.toRef())
.toList();
return PlaylistsList(owned: owned, public: pub);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
);
});
/// Composite shape (playlist + tracks). async* over the playlist watch
/// stream + a one-shot tracks fetch per emission.
final playlistDetailProvider =
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
return (await ref.watch(playlistsApiProvider.future)).get(id);
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
final db = ref.watch(appDbProvider);
final user = ref.watch(authControllerProvider).value;
final playlistQuery = db.select(db.cachedPlaylists)
..where((t) => t.id.equals(id));
final tracksQuery = (db.select(db.cachedPlaylistTracks)
..where((t) => t.playlistId.equals(id))
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
.join([
drift.leftOuterJoin(db.cachedTracks,
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
]);
PlaylistDetail emptyDetail() => PlaylistDetail(
playlist: Playlist(
id: id,
userId: user?.id ?? '',
name: '',
description: '',
isPublic: false,
systemVariant: null,
trackCount: 0,
coverUrl: '',
ownerUsername: '',
createdAt: '',
updatedAt: '',
),
tracks: const [],
);
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id);
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
});
// watch() re-emits next iteration with populated row.
} catch (_) {
yield emptyDetail();
}
} else {
yield emptyDetail();
}
continue;
}
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
final tracks = trackRows.asMap().entries.map((e) {
final r = e.value;
final track = r.readTableOrNull(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
final album = r.readTableOrNull(db.cachedAlbums);
return PlaylistTrack(
position: e.key,
trackId: track?.id,
title: track?.title ?? '',
albumId: album?.id,
albumTitle: album?.title ?? '',
artistId: artist?.id,
artistName: artist?.name ?? '',
durationSec: track == null ? 0 : track.durationMs ~/ 1000,
streamUrl: null,
);
}).toList();
yield PlaylistDetail(playlist: playlist, tracks: tracks);
}
});
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).systemPlaylistsStatus();
});
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../models/playlist.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
/// name + optional system-variant badge below. Tap pushes
/// `/playlists/{id}`.
class PlaylistCard extends StatelessWidget {
const PlaylistCard({super.key, required this.playlist});
final Playlist playlist;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: GestureDetector(
onTap: () => context.push('/playlists/${playlist.id}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 144,
height: 144,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
),
),
),
]),
),
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Same dimensions as PlaylistCard so the home Playlists row keeps
/// visual rhythm whether real or placeholder. Mirrors the web
/// PlaylistPlaceholderCard. Variant decides the state copy below
/// the label.
class PlaylistPlaceholderCard extends StatelessWidget {
const PlaylistPlaceholderCard({
super.key,
required this.label,
required this.variant,
});
/// "For You" or "Songs like…" — what the slot is reserved for.
final String label;
/// One of: 'building' | 'failed' | 'pending' | 'seed-needed'.
final String variant;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
width: 144,
height: 144,
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: fs.slate, width: 1),
),
child: Center(child: _stateIcon(fs)),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_stateText(),
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
]),
),
);
}
Widget _stateIcon(FabledSwordTheme fs) {
switch (variant) {
case 'building':
return SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent),
);
case 'failed':
return Icon(Icons.warning_amber, color: fs.error, size: 32);
default:
return Icon(Icons.queue_music, color: fs.ash, size: 40);
}
}
String _stateText() {
switch (variant) {
case 'building':
return 'Building…';
case 'failed':
return "Couldn't generate";
case 'seed-needed':
return 'Like more music';
default:
return 'Coming soon';
}
}
}
@@ -0,0 +1,70 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/me.dart';
import '../api/endpoints/quarantine.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
return QuarantineApi(await ref.watch(dioProvider.future));
});
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
@override
Future<List<QuarantineMineRow>> build() async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).quarantineMine();
}
/// True when this track is in the caller's quarantine list.
bool isHidden(String trackId) =>
(state.value ?? const []).any((r) => r.trackId == trackId);
/// Optimistic flag: prepends a synthetic row, calls server,
/// rolls back on error.
Future<void> flag(TrackRef track, String reason, String notes) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
if (current.any((r) => r.trackId == track.id)) return; // already hidden
final synthetic = QuarantineMineRow(
trackId: track.id,
reason: reason,
notes: notes.isEmpty ? null : notes,
createdAt: DateTime.now().toUtc().toIso8601String(),
trackTitle: track.title,
trackDurationMs: track.durationSec * 1000,
albumId: track.albumId,
albumTitle: track.albumTitle,
artistId: track.artistId,
artistName: track.artistName,
);
state = AsyncData([synthetic, ...current]);
try {
await api.flag(track.id, reason, notes: notes);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
/// Optimistic unflag: removes the row, calls server, rolls back on error.
Future<void> unflag(String trackId) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
final removed = current.where((r) => r.trackId == trackId).toList();
if (removed.isEmpty) return;
state = AsyncData(current.where((r) => r.trackId != trackId).toList());
try {
await api.unflag(trackId);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final myQuarantineProvider =
AsyncNotifierProvider<MyQuarantineController, List<QuarantineMineRow>>(
MyQuarantineController.new,
);
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/requests.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/admin_request.dart';
final requestsApiProvider = FutureProvider<RequestsApi>((ref) async {
return RequestsApi(await ref.watch(dioProvider.future));
});
/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh
/// every 12s while any row is mid-ingest (status='approved'), stop when
/// all rows settle. Polls regardless of app foreground/background — a
/// future enhancement could pause via WidgetsBindingObserver, but for
/// v1 the small extra refresh is acceptable.
class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
static const Duration _pollInterval = Duration(seconds: 12);
Timer? _pollTimer;
@override
Future<List<AdminRequest>> build() async {
ref.onDispose(() {
_pollTimer?.cancel();
_pollTimer = null;
});
final api = await ref.watch(requestsApiProvider.future);
final rows = await api.listMine();
_maybeStartPolling(rows);
return rows;
}
void _maybeStartPolling(List<AdminRequest> rows) {
_pollTimer?.cancel();
if (rows.any((r) => r.status == 'approved')) {
_pollTimer = Timer.periodic(_pollInterval, (_) {
ref.invalidateSelf();
});
}
}
/// Optimistic remove + REST cancel. Restores the row on failure so
/// the user can retry — silent failure would lie about success.
Future<void> cancel(String id) async {
final api = await ref.read(requestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
state = AsyncData(current.where((r) => r.id != id).toList());
try {
await api.cancel(id);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final myRequestsProvider =
AsyncNotifierProvider<MyRequestsController, List<AdminRequest>>(
MyRequestsController.new);
@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/admin_request.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'requests_provider.dart';
class RequestsScreen extends ConsumerWidget {
const RequestsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final requests = ref.watch(myRequestsProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/requests')],
),
body: SafeArea(
child: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('$e', style: TextStyle(color: fs.error)),
),
),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('Nothing requested yet.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(myRequestsProvider.notifier);
return RefreshIndicator(
onRefresh: () async => ref.refresh(myRequestsProvider.future),
child: ListView.separated(
itemCount: rows.length,
padding: const EdgeInsets.symmetric(vertical: 8),
separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1),
itemBuilder: (_, i) => _RequestRow(
key: Key('request_row_${rows[i].id}'),
request: rows[i],
onCancel: () => notifier.cancel(rows[i].id),
),
),
);
},
),
),
);
}
}
class _RequestRow extends StatelessWidget {
const _RequestRow({
super.key,
required this.request,
required this.onCancel,
});
final AdminRequest request;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final href = _listenHref(request);
return ListTile(
leading: _kindAvatar(fs),
title: Text(
request.displayName,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
_KindPill(label: request.kind),
const SizedBox(width: 6),
_StatusPill(status: request.status),
],
),
if (request.importedAlbumCount > 0 ||
request.importedTrackCount > 0) ...[
const SizedBox(height: 4),
Text(
_ingestProgressText(request),
style: TextStyle(color: fs.accent, fontSize: 13),
),
],
if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[
const SizedBox(height: 4),
Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)),
],
],
),
trailing: _trailing(context, fs, href),
);
}
Widget _kindAvatar(FabledSwordTheme fs) {
final icon = switch (request.kind) {
'artist' => Icons.album,
'album' => Icons.library_music,
_ => Icons.music_note,
};
return CircleAvatar(
backgroundColor: fs.iron,
child: Icon(icon, size: 20, color: fs.ash),
);
}
Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) {
if (request.status == 'pending') {
return TextButton.icon(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cancel request?'),
content: Text('Cancel "${request.displayName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Keep'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Cancel request'),
),
],
),
);
if (ok == true) onCancel();
},
icon: const Icon(Icons.close, size: 16),
label: const Text('Cancel'),
style: TextButton.styleFrom(foregroundColor: fs.ash),
);
}
if (request.status == 'completed' && href != null) {
return TextButton.icon(
onPressed: () => context.push(href),
icon: const Icon(Icons.play_arrow, size: 16),
label: const Text('Listen'),
style: TextButton.styleFrom(foregroundColor: fs.accent),
);
}
return null;
}
String _ingestProgressText(AdminRequest r) {
if (r.kind == 'artist') {
final albums = '${r.importedAlbumCount} '
'${r.importedAlbumCount == 1 ? 'album' : 'albums'}';
final tracks = '${r.importedTrackCount} '
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
return '$albums · $tracks';
}
if (r.kind == 'album') {
return '${r.importedTrackCount} '
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
}
return 'Track ingested';
}
String? _listenHref(AdminRequest r) {
if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}';
if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}';
if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}';
return null;
}
}
class _KindPill extends StatelessWidget {
const _KindPill({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: fs.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(color: fs.accent, fontSize: 11),
),
);
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final color = switch (status) {
'pending' => fs.ash,
'approved' => fs.bronze,
'completed' => fs.moss,
'rejected' => fs.oxblood,
'failed' => fs.oxblood,
_ => fs.ash,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Text(
status,
style: TextStyle(color: color, fontSize: 11),
),
);
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import '../library/widgets/artist_card.dart';
import '../library/widgets/track_row.dart';
import '../models/search_response.dart';
import '../player/player_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'search_provider.dart';
@@ -73,6 +74,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_focus.requestFocus();
},
),
const MainAppBarActions(currentRoute: '/search'),
],
),
body: results.when(
@@ -86,7 +88,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
data: (r) => r == null
? const _Hint(message: 'Type to search your library.')
: r.isEmpty
? const _Hint(message: 'No matches.')
? const _Hint(message: 'No matches for that query.')
: _Results(results: r),
),
);
@@ -7,7 +7,10 @@ import '../api/endpoints/settings.dart';
import '../api/errors.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/my_profile.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'storage_section.dart';
import '../theme/theme_mode_provider.dart';
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
return SettingsApi(await ref.watch(dioProvider.future));
@@ -37,15 +40,23 @@ class SettingsScreen extends ConsumerWidget {
onPressed: () => context.pop(),
),
title: Text('Settings', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/settings')],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: const [
_ProfileSection(),
_Divider(),
_RequestsSection(),
_Divider(),
_AppearanceSection(),
_Divider(),
StorageSection(),
_Divider(),
_PasswordSection(),
_Divider(),
_ListenBrainzSection(),
_AdminSection(),
SizedBox(height: 96),
],
),
@@ -85,6 +96,71 @@ class _SectionHeader extends StatelessWidget {
}
}
class _RequestsSection extends StatelessWidget {
const _RequestsSection();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
key: const Key('settings_requests_card'),
leading: Icon(Icons.queue_music, color: fs.parchment),
title: Text(
'My requests',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(
"Track what you've asked Minstrel to add",
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
onTap: () => context.push('/requests'),
);
}
}
/// Renders an "Admin" entry only when the current profile has
/// `is_admin = true`. Returns an empty SizedBox otherwise so the
/// const-children layout above stays valid.
class _AdminSection extends ConsumerWidget {
const _AdminSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final profile = ref.watch(_profileProvider).value;
if (profile == null || !profile.isAdmin) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Divider(),
ListTile(
key: const Key('settings_admin_card'),
leading: Icon(Icons.shield, color: fs.parchment),
title: Text(
'Admin',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(
'Manage requests, quarantine, and users',
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
onTap: () => context.push('/admin'),
),
],
);
}
}
class _ProfileSection extends ConsumerStatefulWidget {
const _ProfileSection();
@override
@@ -451,3 +527,45 @@ InputDecoration _inputDecoration(FabledSwordTheme fs, String label) {
),
);
}
class _AppearanceSection extends ConsumerWidget {
const _AppearanceSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
// RadioGroup is the post-Flutter-3.32 API: the ancestor owns
// groupValue/onChanged so individual RadioListTiles don't have
// to repeat them. Pre-3.32 RadioListTile.groupValue/onChanged
// are deprecated.
return RadioGroup<AppThemeMode>(
groupValue: current,
onChanged: (m) {
if (m != null) {
ref.read(themeModeProvider.notifier).set(m);
}
},
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const _SectionHeader('Appearance'),
for (final mode in AppThemeMode.values)
RadioListTile<AppThemeMode>(
key: Key('appearance_${mode.name}'),
title: Text(_label(mode), style: TextStyle(color: fs.parchment)),
subtitle: mode == AppThemeMode.system
? Text('Match the device setting',
style: TextStyle(color: fs.ash))
: null,
value: mode,
activeColor: fs.accent,
),
]),
);
}
String _label(AppThemeMode m) => switch (m) {
AppThemeMode.system => 'System',
AppThemeMode.light => 'Light',
AppThemeMode.dark => 'Dark',
};
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/cache_settings_provider.dart';
import '../cache/sync_controller.dart';
import '../theme/theme_extension.dart';
/// Settings card: usage display, cap selector, prefetch window selector,
/// cache-liked toggle, Clear cache + Sync now buttons.
class StorageSection extends ConsumerStatefulWidget {
const StorageSection({super.key});
@override
ConsumerState<StorageSection> createState() => _StorageSectionState();
}
class _StorageSectionState extends ConsumerState<StorageSection> {
int? _usageBytes;
bool _syncing = false;
@override
void initState() {
super.initState();
_refreshUsage();
}
Future<void> _refreshUsage() async {
final mgr = ref.read(audioCacheManagerProvider);
final used = await mgr.usageBytes();
if (mounted) setState(() => _usageBytes = used);
}
String _fmtBytes(int? n) {
if (n == null) return '';
if (n == 0) return '0 B';
if (n < 1024) return '$n B';
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final settings = ref.watch(cacheSettingsProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Card(
color: fs.iron,
child: Padding(
padding: const EdgeInsets.all(16),
child: settings.when(
loading: () => const Padding(
padding: EdgeInsets.all(8),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Text('$e', style: TextStyle(color: fs.error)),
data: (s) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Storage',
style: TextStyle(
color: fs.parchment,
fontSize: 18,
fontWeight: FontWeight.w500)),
const SizedBox(height: 12),
Row(children: [
Text('Cache usage', style: TextStyle(color: fs.ash)),
const Spacer(),
Text(
'${_fmtBytes(_usageBytes)} / '
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
style: TextStyle(color: fs.parchment),
),
]),
const SizedBox(height: 16),
_capSelector(s, fs),
const SizedBox(height: 8),
_prefetchSelector(s, fs),
const SizedBox(height: 8),
SwitchListTile(
key: const Key('cache_liked_toggle'),
contentPadding: EdgeInsets.zero,
title: Text('Cache liked tracks',
style: TextStyle(color: fs.parchment)),
value: s.cacheLikedTracks,
onChanged: (v) => ref
.read(cacheSettingsProvider.notifier)
.setCacheLikedTracks(v),
),
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
OutlinedButton(
key: const Key('clear_cache_button'),
onPressed: _confirmClear,
child: const Text('Clear cache'),
),
OutlinedButton.icon(
key: const Key('sync_now_button'),
onPressed: _syncing
? null
: () async {
setState(() => _syncing = true);
try {
await ref
.read(syncControllerProvider.notifier)
.sync();
} finally {
if (mounted) setState(() => _syncing = false);
}
},
icon: _syncing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, size: 16),
label: const Text('Sync now'),
),
]),
],
),
),
),
),
);
}
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [
(1024 * 1024 * 1024, '1 GB'),
(5 * 1024 * 1024 * 1024, '5 GB'),
(10 * 1024 * 1024 * 1024, '10 GB'),
(25 * 1024 * 1024 * 1024, '25 GB'),
(0, 'Unlimited'),
];
return Row(children: [
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('cap_selector'),
value: options.any((o) => o.$1 == s.capBytes)
? s.capBytes
: 5 * 1024 * 1024 * 1024,
items: options
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
.toList(),
onChanged: (v) async {
if (v == null) return;
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
if (v > 0) {
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
}
await _refreshUsage();
},
),
]);
}
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [1, 3, 5, 7, 10];
return Row(children: [
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('prefetch_selector'),
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
items: options
.map((n) => DropdownMenuItem(value: n, child: Text('$n tracks')))
.toList(),
onChanged: (v) async {
if (v == null) return;
await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v);
},
),
]);
}
Future<void> _confirmClear() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear cache?'),
content: const Text(
'This deletes all cached audio files (including manually downloaded ones). '
'The next play of any track will re-download from the server.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: fs.error),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
await ref.read(audioCacheManagerProvider).clearAll();
await _refreshUsage();
}
}
@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
/// Renders [whileDelayed] only after [isLoading] has been true
/// continuously for [delay], then renders [whenReady] once loading
/// completes. Mirrors the web `useDelayed` hook: a brief loading flash
/// doesn't trigger a skeleton; sustained loading does.
///
/// Once [isLoading] flips back to false, the timer resets.
class DelayedLoading extends StatefulWidget {
const DelayedLoading({
super.key,
required this.isLoading,
required this.whileDelayed,
required this.whenReady,
this.delay = const Duration(milliseconds: 200),
});
final bool isLoading;
final Widget whileDelayed;
final Widget whenReady;
final Duration delay;
@override
State<DelayedLoading> createState() => _DelayedLoadingState();
}
class _DelayedLoadingState extends State<DelayedLoading> {
Timer? _timer;
bool _delayPassed = false;
@override
void initState() {
super.initState();
_maybeStart();
}
@override
void didUpdateWidget(DelayedLoading old) {
super.didUpdateWidget(old);
if (widget.isLoading != old.isLoading) {
_timer?.cancel();
_delayPassed = false;
_maybeStart();
}
}
void _maybeStart() {
if (!widget.isLoading) return;
_timer = Timer(widget.delay, () {
if (mounted) setState(() => _delayPassed = true);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.isLoading) return widget.whenReady;
return _delayPassed ? widget.whileDelayed : const SizedBox.shrink();
}
}
+15
View File
@@ -15,8 +15,14 @@ import '../player/player_bar.dart';
import '../player/queue_screen.dart';
import '../playlists/playlist_detail_screen.dart';
import '../playlists/playlists_list_screen.dart';
import '../requests/requests_screen.dart';
import '../search/search_screen.dart';
import '../settings/settings_screen.dart';
import '../update/update_banner.dart';
import '../admin/admin_landing_screen.dart';
import '../admin/admin_requests_screen.dart';
import '../admin/admin_quarantine_screen.dart';
import '../admin/admin_users_screen.dart';
import 'widgets/version_gate.dart';
/// Exposed as a Provider so its single argument is a real `Ref` (the
@@ -39,6 +45,9 @@ GoRouter buildRouter(Ref ref) {
if (user != null && (loc == '/login' || loc == '/server-url')) {
return '/home';
}
if (loc.startsWith('/admin') && !user!.isAdmin) {
return '/home';
}
return null;
},
routes: [
@@ -68,6 +77,11 @@ GoRouter buildRouter(Ref ref) {
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
],
),
],
@@ -81,6 +95,7 @@ class _ShellWithPlayerBar extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
const UpdateBanner(),
Expanded(child: child),
const PlayerBar(),
],
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../auth/auth_provider.dart';
import '../../theme/theme_extension.dart';
/// Shared AppBar `actions` for top-level screens. Renders Home / Library /
/// Search as primary icons (suppressing the icon for [currentRoute]) plus
/// a kebab overflow with Playlists / Discover / Settings and (for admins)
/// Admin.
class MainAppBarActions extends ConsumerWidget {
const MainAppBarActions({super.key, required this.currentRoute});
final String currentRoute;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final user = ref.watch(authControllerProvider).value;
final isAdmin = user?.isAdmin ?? false;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (currentRoute != '/home')
IconButton(
key: const Key('app_bar_home'),
icon: Icon(Icons.home, color: fs.parchment),
tooltip: 'Home',
onPressed: () => context.go('/home'),
),
if (currentRoute != '/library')
IconButton(
key: const Key('app_bar_library'),
icon: Icon(Icons.library_music, color: fs.parchment),
tooltip: 'Library',
onPressed: () => context.push('/library'),
),
if (currentRoute != '/search')
IconButton(
key: const Key('app_bar_search'),
icon: Icon(Icons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
PopupMenuButton<String>(
key: const Key('app_bar_overflow'),
icon: Icon(Icons.more_vert, color: fs.parchment),
tooltip: 'More',
onSelected: (route) => context.push(route),
itemBuilder: (_) => [
const PopupMenuItem(value: '/playlists', child: Text('Playlists')),
const PopupMenuItem(value: '/discover', child: Text('Discover')),
const PopupMenuItem(value: '/settings', child: Text('Settings')),
if (isAdmin)
const PopupMenuItem(value: '/admin', child: Text('Admin')),
],
),
],
);
}
}
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/auth_provider.dart';
/// Image.network wrapper that resolves server-relative URLs (e.g.
/// `/api/albums/<id>/cover`) against the configured server base URL
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
/// unchanged. Empty/null base or empty URL render the [fallback]
/// (defaults to a transparent SizedBox so callers can supply their own
/// surrounding placeholder).
///
/// Mirrors the absolute-or-relative logic the audio handler uses for
/// stream URLs, so cover art and audio resolve URLs the same way.
class ServerImage extends ConsumerWidget {
const ServerImage({
super.key,
required this.url,
this.fit,
this.fallback,
});
final String url;
final BoxFit? fit;
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final empty = fallback ?? const SizedBox.shrink();
if (url.isEmpty) return empty;
final base = ref.watch(serverUrlProvider).value;
final resolved = _resolve(base, url);
if (resolved == null) return empty;
// Cover endpoints are gated by RequireUser server-side. Image.network
// doesn't carry cookies/Bearer tokens automatically the way the
// browser's <img> tag does, so we forward the session token as a
// header. Without this, the server returns 401 and the image fails.
final token = ref.watch(sessionTokenProvider).value;
final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'}
: null;
return Image.network(resolved, fit: fit, headers: headers);
}
static String? _resolve(String? baseUrl, String url) {
final parsed = Uri.tryParse(url);
if (parsed != null && parsed.hasScheme) return url;
if (baseUrl == null || baseUrl.isEmpty) return null;
final base = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
final path = url.startsWith('/') ? url : '/$url';
return '$base$path';
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../playlists/playlists_provider.dart';
import '../../../theme/theme_extension.dart';
/// Modal bottom sheet listing the caller's user-created playlists.
/// Returns the picked playlistId via Navigator.pop, or null on cancel.
class AddToPlaylistSheet extends ConsumerWidget {
const AddToPlaylistSheet({super.key});
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (_) => const AddToPlaylistSheet(),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final lists = ref.watch(playlistsListProvider('user'));
return SafeArea(
child: Container(
color: fs.iron,
padding: const EdgeInsets.symmetric(vertical: 8),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Text(
'Add to playlist',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 20,
),
),
),
Flexible(
child: lists.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (data) {
final owned = data.owned
.where((p) => p.systemVariant == null)
.toList();
if (owned.isEmpty) {
return Padding(
padding: const EdgeInsets.all(20),
child: Text(
"You haven't created any playlists yet.",
style: TextStyle(color: fs.ash),
),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: owned.length,
itemBuilder: (_, i) {
final p = owned[i];
return ListTile(
key: Key('add_to_playlist_${p.id}'),
leading: Icon(Icons.queue_music, color: fs.parchment),
title: Text(
p.name,
style: TextStyle(color: fs.parchment),
),
subtitle: Text(
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
style: TextStyle(color: fs.ash, fontSize: 12),
),
onTap: () => Navigator.pop(context, p.id),
);
},
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../../../theme/theme_extension.dart';
/// Modal bottom sheet for picking a hide reason + optional notes.
/// Returns ({reason, notes}) on submit, null on cancel.
class HideTrackSheet extends StatefulWidget {
const HideTrackSheet({super.key});
static Future<({String reason, String notes})?> show(BuildContext context) {
return showModalBottomSheet<({String reason, String notes})>(
context: context,
isScrollControlled: true,
builder: (_) => const HideTrackSheet(),
);
}
@override
State<HideTrackSheet> createState() => _HideTrackSheetState();
}
class _HideTrackSheetState extends State<HideTrackSheet> {
String _reason = 'bad_rip';
final _notesCtrl = TextEditingController();
// Wire values mirror the server vocabulary; display labels mirror the
// existing _QuarantineTile in library_screen.dart.
static const _options = [
('bad_rip', 'Bad rip'),
('wrong_file', 'Wrong file'),
('wrong_tags', 'Wrong tags'),
('duplicate', 'Duplicate'),
('other', 'Other'),
];
@override
void dispose() {
_notesCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
color: fs.iron,
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hide this track',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 20,
),
),
const SizedBox(height: 12),
Text(
'Pick a reason. Optional notes are visible to admins.',
style: TextStyle(color: fs.ash),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (value, label) in _options)
ChoiceChip(
key: Key('hide_reason_$value'),
label: Text(label),
selected: _reason == value,
onSelected: (_) => setState(() => _reason = value),
),
],
),
const SizedBox(height: 12),
TextField(
key: const Key('hide_notes_input'),
controller: _notesCtrl,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
),
maxLines: 2,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
key: const Key('hide_confirm'),
style: ElevatedButton.styleFrom(
backgroundColor: fs.oxblood,
foregroundColor: fs.parchment,
),
onPressed: () => Navigator.pop(
context,
(reason: _reason, notes: _notesCtrl.text.trim()),
),
child: const Text('Hide'),
),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../../models/track.dart';
import '../../../theme/theme_extension.dart';
import 'track_actions_sheet.dart';
/// Small 3-dot trigger that opens TrackActionsSheet. Drop into any
/// track row / card to expose the canonical 7-action menu.
class TrackActionsButton extends StatelessWidget {
const TrackActionsButton({
super.key,
required this.track,
this.hideQueueActions = false,
});
final TrackRef track;
/// Suppresses Play next / Add to queue. Set true on the Now Playing
/// screen where the menu's track IS the currently-playing one.
final bool hideQueueActions;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return IconButton(
icon: Icon(Icons.more_vert, color: fs.ash, size: 18),
tooltip: 'Track actions',
onPressed: () => TrackActionsSheet.show(
context,
track,
hideQueueActions: hideQueueActions,
),
);
}
}
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../api/endpoints/likes.dart';
import '../../../likes/likes_provider.dart';
import '../../../models/track.dart';
import '../../../player/player_provider.dart';
import '../../../playlists/playlists_provider.dart';
import '../../../quarantine/quarantine_provider.dart';
import '../../../theme/theme_extension.dart';
import 'add_to_playlist_sheet.dart';
import 'hide_track_sheet.dart';
/// Modal bottom sheet that lists the 7 canonical track actions.
/// Pops first when an item is tapped (immediate visual feedback) and
/// then runs the action — for actions that open a sub-sheet, the
/// sub-sheet opens after the parent pops.
class TrackActionsSheet extends ConsumerWidget {
const TrackActionsSheet({
super.key,
required this.track,
required this.hideQueueActions,
});
final TrackRef track;
final bool hideQueueActions;
static Future<void> show(
BuildContext context,
TrackRef track, {
bool hideQueueActions = false,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => TrackActionsSheet(
track: track,
hideQueueActions: hideQueueActions,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final liked = ref.watch(likedIdsProvider).value?.has(LikeKind.track, track.id) ?? false;
final hidden = ref.watch(myQuarantineProvider).value?.any((r) => r.trackId == track.id) ?? false;
return SafeArea(
child: Container(
color: fs.iron,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!hideQueueActions) ...[
_MenuItem(
key: const Key('track_actions_play_next'),
icon: Icons.playlist_play,
label: 'Play next',
onTap: () async {
Navigator.pop(context);
await ref.read(playerActionsProvider).playNext(track);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Added to queue (next)')),
);
}
},
),
_MenuItem(
key: const Key('track_actions_enqueue'),
icon: Icons.queue_music,
label: 'Add to queue',
onTap: () async {
Navigator.pop(context);
await ref.read(playerActionsProvider).enqueue(track);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Added to queue')),
);
}
},
),
const _Divider(),
],
_MenuItem(
key: const Key('track_actions_like'),
icon: liked ? Icons.favorite : Icons.favorite_border,
label: liked ? 'Unlike' : 'Like',
onTap: () async {
Navigator.pop(context);
await ref.read(likesControllerProvider).toggle(LikeKind.track, track.id);
},
),
_MenuItem(
key: const Key('track_actions_add_to_playlist'),
icon: Icons.playlist_add,
label: 'Add to playlist…',
onTap: () async {
Navigator.pop(context);
final playlistId = await AddToPlaylistSheet.show(context);
if (playlistId == null || !context.mounted) return;
try {
await ref.read(addToPlaylistActionProvider).call(playlistId, track.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Added to playlist')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't add to playlist: $e")),
);
}
}
},
),
const _Divider(),
_MenuItem(
key: const Key('track_actions_go_to_album'),
icon: Icons.album,
label: 'Go to album',
onTap: () {
Navigator.pop(context);
context.push('/albums/${track.albumId}');
},
),
_MenuItem(
key: const Key('track_actions_go_to_artist'),
icon: Icons.person,
label: 'Go to artist',
onTap: () {
Navigator.pop(context);
context.push('/artists/${track.artistId}');
},
),
const _Divider(),
_MenuItem(
key: const Key('track_actions_hide'),
icon: hidden ? Icons.visibility : Icons.visibility_off,
label: hidden ? 'Unhide' : 'Hide',
onTap: () async {
Navigator.pop(context);
if (hidden) {
try {
await ref.read(myQuarantineProvider.notifier).unflag(track.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't unhide: $e")),
);
}
}
return;
}
final result = await HideTrackSheet.show(context);
if (result == null || !context.mounted) return;
try {
await ref
.read(myQuarantineProvider.notifier)
.flag(track, result.reason, result.notes);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't hide: $e")),
);
}
}
},
),
],
),
),
);
}
}
class _MenuItem extends StatelessWidget {
const _MenuItem({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
leading: Icon(icon, color: fs.parchment),
title: Text(label, style: TextStyle(color: fs.parchment)),
onTap: onTap,
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Divider(height: 1, color: fs.slate);
}
}
/// Convenience callable for "append a single track to a playlist."
/// Lives here (not in playlists_provider) because it's purely a
/// menu-flow concern — no other caller.
typedef AddToPlaylistAction = Future<void> Function(String playlistId, String trackId);
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
return (playlistId, trackId) async {
final api = await ref.read(playlistsApiProvider.future);
await api.appendTracks(playlistId, [trackId]);
};
});
+33 -4
View File
@@ -4,8 +4,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'theme_extension.dart';
import 'tokens.dart';
ThemeData buildThemeData() {
final fs = FabledSwordTheme.fromTokens();
ThemeData buildDarkTheme() {
final fs = FabledSwordTheme.dark();
final colorScheme = ColorScheme.dark(
surface: fs.iron,
onSurface: fs.parchment,
@@ -14,16 +14,45 @@ ThemeData buildThemeData() {
secondary: fs.moss,
error: fs.error,
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
scaffoldBackgroundColor: fs.obsidian,
textTheme: GoogleFonts.interTextTheme().apply(
bodyColor: fs.parchment,
displayColor: fs.parchment,
),
fontFamily: FabledSwordTokens.fontBody,
fontFamily: FabledSwordFlatTokens.fontBody,
extensions: <ThemeExtension<dynamic>>[fs],
);
}
ThemeData buildLightTheme() {
final fs = FabledSwordTheme.light();
final colorScheme = ColorScheme.light(
surface: fs.iron,
onSurface: fs.parchment, // semantic — light's "parchment" is dark text
primary: fs.accent,
onPrimary: FabledSwordFlatTokens.onAction,
secondary: fs.moss,
error: fs.error,
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
scaffoldBackgroundColor: fs.obsidian, // semantic — light's obsidian is the bg
textTheme: GoogleFonts.interTextTheme().apply(
bodyColor: fs.parchment,
displayColor: fs.parchment,
),
fontFamily: FabledSwordFlatTokens.fontBody,
extensions: <ThemeExtension<dynamic>>[fs],
);
}
/// Back-compat alias. Existing tests + code call buildThemeData(); it
/// returns the dark theme to preserve current behaviour. New code
/// should prefer buildDarkTheme()/buildLightTheme() explicitly.
ThemeData buildThemeData() => buildDarkTheme();
+43 -18
View File
@@ -30,26 +30,51 @@ class FabledSwordTheme extends ThemeExtension<FabledSwordTheme> {
final Color warning, error, info;
final TextStyle display, body, mono;
static FabledSwordTheme fromTokens() => const FabledSwordTheme(
accent: FabledSwordTokens.accent,
obsidian: FabledSwordTokens.obsidian,
iron: FabledSwordTokens.iron,
slate: FabledSwordTokens.slate,
pewter: FabledSwordTokens.pewter,
parchment: FabledSwordTokens.parchment,
vellum: FabledSwordTokens.vellum,
ash: FabledSwordTokens.ash,
moss: FabledSwordTokens.moss,
bronze: FabledSwordTokens.bronze,
oxblood: FabledSwordTokens.oxblood,
warning: FabledSwordTokens.warning,
error: FabledSwordTokens.error,
info: FabledSwordTokens.info,
display: TextStyle(fontFamily: FabledSwordTokens.fontDisplay),
body: TextStyle(fontFamily: FabledSwordTokens.fontBody),
mono: TextStyle(fontFamily: FabledSwordTokens.fontMono),
factory FabledSwordTheme.dark() => const FabledSwordTheme(
accent: FabledSwordFlatTokens.accent,
obsidian: FabledSwordDarkTokens.obsidian,
iron: FabledSwordDarkTokens.iron,
slate: FabledSwordDarkTokens.slate,
pewter: FabledSwordDarkTokens.pewter,
parchment: FabledSwordDarkTokens.parchment,
vellum: FabledSwordDarkTokens.vellum,
ash: FabledSwordDarkTokens.ash,
moss: FabledSwordFlatTokens.moss,
bronze: FabledSwordFlatTokens.bronze,
oxblood: FabledSwordFlatTokens.oxblood,
warning: FabledSwordFlatTokens.warning,
error: FabledSwordFlatTokens.error,
info: FabledSwordFlatTokens.info,
display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay),
body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody),
mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono),
);
factory FabledSwordTheme.light() => const FabledSwordTheme(
accent: FabledSwordFlatTokens.accent,
obsidian: FabledSwordLightTokens.obsidian,
iron: FabledSwordLightTokens.iron,
slate: FabledSwordLightTokens.slate,
pewter: FabledSwordLightTokens.pewter,
parchment: FabledSwordLightTokens.parchment,
vellum: FabledSwordLightTokens.vellum,
ash: FabledSwordLightTokens.ash,
moss: FabledSwordFlatTokens.moss,
bronze: FabledSwordFlatTokens.bronze,
oxblood: FabledSwordFlatTokens.oxblood,
warning: FabledSwordFlatTokens.warning,
error: FabledSwordFlatTokens.error,
info: FabledSwordFlatTokens.info,
display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay),
body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody),
mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono),
);
/// Back-compat alias for the original API. Returns the dark theme.
/// Existing call sites can keep using fromTokens() until they're
/// migrated to .dark() / .light() explicitly.
static FabledSwordTheme fromTokens() => FabledSwordTheme.dark();
@override
FabledSwordTheme copyWith({
Color? accent,
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
const _kThemeModeKey = 'theme_mode';
enum AppThemeMode { system, dark, light }
extension AppThemeModeMaterial on AppThemeMode {
ThemeMode get materialMode => switch (this) {
AppThemeMode.system => ThemeMode.system,
AppThemeMode.dark => ThemeMode.dark,
AppThemeMode.light => ThemeMode.light,
};
}
class ThemeModeController extends AsyncNotifier<AppThemeMode> {
@override
Future<AppThemeMode> build() async {
final storage = ref.watch(secureStorageProvider);
final raw = await storage.read(key: _kThemeModeKey);
return switch (raw) {
'dark' => AppThemeMode.dark,
'light' => AppThemeMode.light,
_ => AppThemeMode.system,
};
}
Future<void> set(AppThemeMode mode) async {
final storage = ref.read(secureStorageProvider);
await storage.write(key: _kThemeModeKey, value: mode.name);
state = AsyncData(mode);
}
}
final themeModeProvider =
AsyncNotifierProvider<ThemeModeController, AppThemeMode>(
ThemeModeController.new,
);
+40
View File
@@ -2,6 +2,46 @@
// Run `dart run tool/gen_tokens.dart` to regenerate.
import 'package:flutter/material.dart';
class FabledSwordDarkTokens {
static const Color obsidian = Color(0xFF14171A);
static const Color iron = Color(0xFF1E2228);
static const Color slate = Color(0xFF2C313A);
static const Color pewter = Color(0xFF3F4651);
static const Color parchment = Color(0xFFE8E4D8);
static const Color vellum = Color(0xFFC2BFB4);
static const Color ash = Color(0xFF9C9A92);
}
class FabledSwordLightTokens {
static const Color obsidian = Color(0xFFF8F5EE);
static const Color iron = Color(0xFFECE6D5);
static const Color slate = Color(0xFFDCD3BD);
static const Color pewter = Color(0xFFB8AE94);
static const Color parchment = Color(0xFF14171A);
static const Color vellum = Color(0xFF2C313A);
static const Color ash = Color(0xFF5C6068);
}
class FabledSwordFlatTokens {
static const Color moss = Color(0xFF4A5D3F);
static const Color bronze = Color(0xFF8B7355);
static const Color oxblood = Color(0xFF6B2118);
static const Color warning = Color(0xFF8B6F1E);
static const Color error = Color(0xFFC04A1F);
static const Color info = Color(0xFF3D5A6E);
static const Color accent = Color(0xFF4A6B5C);
static const Color onAction = Color(0xFFE8E4D8);
static const double radiusSm = 4;
static const double radiusMd = 8;
static const double radiusLg = 12;
static const double radiusXl = 16;
static const String fontDisplay = "Fraunces";
static const String fontBody = "Inter";
static const String fontMono = "JetBrains Mono";
}
/// Back-compat alias — dark surface tokens + flat. Prefer the explicit
/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens.
class FabledSwordTokens {
static const Color obsidian = Color(0xFF14171A);
static const Color iron = Color(0xFF1E2228);
@@ -0,0 +1,112 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pub_semver/pub_semver.dart';
import '../library/library_providers.dart' show dioProvider;
import 'installer.dart';
import 'update_info.dart';
/// Tracks the bundled-server APK version vs. the installed app version.
/// Polls /api/client/version on startup + every 24h. Returns null when
/// no update is available (or the channel is unreachable / disabled).
///
/// Server returns 404 when no APK is bundled (dev environments, pre-CI
/// images) — we treat that as "no update channel" and stay silent.
class ClientUpdateController extends AsyncNotifier<UpdateInfo?> {
static const Duration _pollInterval = Duration(hours: 24);
Timer? _pollTimer;
@override
Future<UpdateInfo?> build() async {
ref.onDispose(() {
_pollTimer?.cancel();
_pollTimer = null;
});
_pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf());
return _check();
}
Future<UpdateInfo?> _check() async {
final dio = await ref.read(dioProvider.future);
final Response<Map<String, dynamic>> r;
try {
r = await dio.get<Map<String, dynamic>>('/api/client/version');
} on DioException catch (e) {
// 404 = no APK bundled; any other error = treat as silent.
if (e.response?.statusCode == 404) return null;
return null;
}
if (r.data == null) return null;
final info = UpdateInfo.fromJson(r.data!);
final installed = (await PackageInfo.fromPlatform()).version;
if (!isVersionNewer(info.version, installed)) return null;
return info;
}
}
/// True when `serverVersion` is strictly newer than `installedVersion`.
/// Both strings are normalized (leading 'v' stripped) and parsed as
/// semver. On parse failure, falls back to string inequality (treats
/// any difference as "newer" — operator can dismiss if wrong).
///
/// Exposed for testing; the polling logic in ClientUpdateController
/// is the only production caller.
bool isVersionNewer(String serverVersion, String installedVersion) {
final svr = serverVersion.replaceFirst(RegExp(r'^v'), '');
final ins = installedVersion.replaceFirst(RegExp(r'^v'), '');
try {
return Version.parse(svr) > Version.parse(ins);
} catch (_) {
return svr != ins;
}
}
final clientUpdateProvider =
AsyncNotifierProvider<ClientUpdateController, UpdateInfo?>(
ClientUpdateController.new);
/// In-memory dismissed-versions set. Keyed by version string so a
/// later release's banner re-appears even if the operator dismissed
/// the previous one. Not persisted — restart re-shows the banner,
/// which is acceptable nudging for v1.
class _DismissedVersionsNotifier extends Notifier<Set<String>> {
@override
Set<String> build() => <String>{};
void add(String version) {
state = {...state, version};
}
}
final _dismissedVersionsProvider =
NotifierProvider<_DismissedVersionsNotifier, Set<String>>(
_DismissedVersionsNotifier.new);
/// True when the update banner should render: an UpdateInfo is
/// available AND the operator hasn't dismissed this specific version.
final shouldShowUpdateBannerProvider = Provider<UpdateInfo?>((ref) {
final info = ref.watch(clientUpdateProvider).value;
if (info == null) return null;
final dismissed = ref.watch(_dismissedVersionsProvider);
if (dismissed.contains(info.version)) return null;
return info;
});
/// Dismiss controller: marks the given version as dismissed so the
/// banner stops showing for this session.
final dismissUpdateProvider = Provider<void Function(String)>((ref) {
return (version) =>
ref.read(_dismissedVersionsProvider.notifier).add(version);
});
/// Installer provider — depends on dio so the install download uses
/// the same authenticated client (though /api/client/apk is unauthed,
/// reusing dio keeps configuration consistent).
final updateInstallerProvider = FutureProvider<UpdateInstaller>((ref) async {
return UpdateInstaller(await ref.watch(dioProvider.future));
});
+46
View File
@@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
/// Bridges to MainActivity.kt's MethodChannel for the Android
/// PackageInstaller intent (#397). Web / iOS don't support self-
/// install; this class is Android-only at v1.
class UpdateInstaller {
UpdateInstaller(this._dio);
final Dio _dio;
static const _channel = MethodChannel('com.fabledsword.minstrel/installer');
static const _filename = 'minstrel-update.apk';
/// Streams the APK from `apkUrl` into the cache directory. `onProgress`
/// receives 0..1 fractions; emits 1.0 once when the download completes.
/// Returns the local path the install intent will read from.
Future<String> download(
String apkUrl, {
void Function(double progress)? onProgress,
}) async {
final cacheDir = await getApplicationCacheDirectory();
final path = '${cacheDir.path}/$_filename';
await _dio.download(
apkUrl,
path,
onReceiveProgress: (received, total) {
if (total > 0 && onProgress != null) {
onProgress(received / total);
}
},
);
return path;
}
/// Hands the downloaded APK to Android's PackageInstaller via a
/// FileProvider content:// URI. The system shows the install confirm
/// dialog; user must tap Install. App restarts on the new version.
///
/// First-ever install attempt prompts the user to flip "Install
/// unknown apps" for Minstrel in Settings → Apps → Special access.
/// One-time grant; persists across updates.
Future<void> install(String apkPath) async {
await _channel.invokeMethod('install', {'path': apkPath});
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme/theme_extension.dart';
import 'client_update_provider.dart';
import 'update_info.dart';
/// Soft banner mounted at the top of the shell. Renders nothing when
/// no update is available or the user has dismissed this version's
/// banner. Tapping Install downloads the APK and fires the system
/// install intent (Android only).
class UpdateBanner extends ConsumerStatefulWidget {
const UpdateBanner({super.key});
@override
ConsumerState<UpdateBanner> createState() => _UpdateBannerState();
}
enum _Stage { idle, downloading, error }
class _UpdateBannerState extends ConsumerState<UpdateBanner> {
_Stage _stage = _Stage.idle;
double _progress = 0;
String? _error;
Future<void> _onInstall(UpdateInfo info) async {
setState(() {
_stage = _Stage.downloading;
_progress = 0;
_error = null;
});
try {
final installer = await ref.read(updateInstallerProvider.future);
final path = await installer.download(
info.apkUrl,
onProgress: (p) => setState(() => _progress = p),
);
await installer.install(path);
// Stage stays 'downloading' — Android system install dialog has
// taken over. If user cancels, the banner is still here.
} catch (e) {
if (!mounted) return;
setState(() {
_stage = _Stage.error;
_error = '$e';
});
}
}
void _onDismiss(UpdateInfo info) {
ref.read(dismissUpdateProvider)(info.version);
}
@override
Widget build(BuildContext context) {
final info = ref.watch(shouldShowUpdateBannerProvider);
if (info == null) return const SizedBox.shrink();
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Material(
color: fs.iron,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
child: Row(
children: [
Icon(Icons.system_update, color: fs.parchment, size: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_stage == _Stage.error
? 'Update failed'
: 'Update Minstrel · ${info.version} available',
style: TextStyle(color: fs.parchment, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
if (_stage == _Stage.downloading) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: _progress > 0 ? _progress : null,
minHeight: 2,
backgroundColor: fs.obsidian,
valueColor: AlwaysStoppedAnimation(fs.accent),
),
],
if (_stage == _Stage.error && _error != null) ...[
const SizedBox(height: 2),
Text(
_error!,
style: TextStyle(color: fs.ash, fontSize: 11),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (_stage == _Stage.idle)
TextButton(
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(
foregroundColor: fs.accent,
minimumSize: const Size(64, 36),
),
child: const Text('Install'),
),
if (_stage == _Stage.error)
TextButton(
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(foregroundColor: fs.accent),
child: const Text('Retry'),
),
IconButton(
tooltip: 'Dismiss',
icon: Icon(Icons.close, size: 18, color: fs.ash),
onPressed: () => _onDismiss(info),
),
],
),
),
),
);
}
}
@@ -0,0 +1,21 @@
// Wire shape returned by GET /api/client/version. `version` is the
// server-bundled APK version (raw — may have a "v" prefix from the
// git tag); `apkUrl` is server-relative (e.g. "/api/client/apk").
class UpdateInfo {
const UpdateInfo({
required this.version,
required this.apkUrl,
required this.sizeBytes,
});
final String version;
final String apkUrl;
final int sizeBytes;
factory UpdateInfo.fromJson(Map<String, dynamic> j) => UpdateInfo(
version: (j['version'] as String?) ?? '',
apkUrl: (j['apk_url'] as String?) ?? '/api/client/apk',
sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0,
);
}
+7
View File
@@ -23,12 +23,19 @@ dependencies:
package_info_plus: ^8.3.1
pub_semver: ^2.1.4
cupertino_icons: ^1.0.8
path_provider: ^2.1.5
drift: ^2.18.0
drift_flutter: ^0.2.0
sqlite3_flutter_libs: ^0.5.24
connectivity_plus: ^6.0.5
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
mocktail: ^1.0.4
drift_dev: ^2.18.0
build_runner: ^2.4.13
flutter:
uses-material-design: true
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_landing_screen.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
void main() {
testWidgets('renders three section cards with counts from provider',
(t) async {
await t.pumpWidget(
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminCountsProvider.overrideWith(
(_) async => const AdminCounts(
requests: 3,
quarantine: 1,
users: 5,
),
),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminLandingScreen(),
),
),
);
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_card_requests')), findsOneWidget);
expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget);
expect(find.byKey(const Key('admin_card_users')), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('1'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
});
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_quarantine_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_quarantine_item.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubQuarantine extends AdminQuarantineController {
_StubQuarantine(this._initial);
final List<AdminQuarantineItem> _initial;
@override
Future<List<AdminQuarantineItem>> build() async => _initial;
}
const _item = AdminQuarantineItem(
trackId: 't1',
trackTitle: 'Bad Track',
artistName: 'Some Artist',
albumTitle: 'Some Album',
albumId: 'al1',
lidarrAlbumMbid: null,
reportCount: 2,
latestAt: '2026-05-08T00:00:00Z',
reasonCounts: {'wrong_tags': 1, 'bad_rip': 1},
reports: [
AdminQuarantineReport(
userId: 'u2',
username: 'alice',
reason: 'wrong_tags',
notes: null,
createdAt: '2026-05-08T00:00:00Z',
),
AdminQuarantineReport(
userId: 'u3',
username: 'bob',
reason: 'bad_rip',
notes: 'cracks at 2:13',
createdAt: '2026-05-08T00:01:00Z',
),
],
);
Widget _harness(List<AdminQuarantineItem> rows) => ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminQuarantineScreen(),
),
);
void main() {
testWidgets('empty state', (t) async {
await t.pumpWidget(_harness(const []));
await t.pumpAndSettle();
expect(find.text('No quarantined tracks.'), findsOneWidget);
});
testWidgets('row renders aggregate summary + 3-dot menu has three actions',
(t) async {
await t.pumpWidget(_harness(const [_item]));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget);
expect(find.text('Bad Track'), findsOneWidget);
expect(find.textContaining('2 reports'), findsOneWidget);
await t.tap(find.byKey(const Key('admin_quarantine_menu_t1')));
await t.pumpAndSettle();
expect(find.text('Resolve'), findsOneWidget);
expect(find.text('Delete file'), findsOneWidget);
expect(find.text('Delete via Lidarr'), findsOneWidget);
});
testWidgets('expanding tile reveals per-user reports', (t) async {
await t.pumpWidget(_harness(const [_item]));
await t.pumpAndSettle();
await t.tap(find.byKey(const Key('admin_quarantine_tile_t1')));
await t.pumpAndSettle();
expect(find.textContaining('alice — wrong_tags'), findsOneWidget);
expect(find.textContaining('bob — bad_rip'), findsOneWidget);
expect(find.text('cracks at 2:13'), findsOneWidget);
});
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_requests_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_request.dart';
import 'package:minstrel/models/admin_user.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubRequests extends AdminRequestsController {
_StubRequests(this._initial);
final List<AdminRequest> _initial;
@override
Future<List<AdminRequest>> build() async => _initial;
}
class _StubUsers extends AdminUsersController {
_StubUsers(this._initial);
final List<AdminUser> _initial;
@override
Future<List<AdminUser>> build() async => _initial;
}
const _row = AdminRequest(
id: 'r1',
userId: 'user-uuid-1',
status: 'pending',
kind: 'album',
artistName: 'Some Artist',
albumTitle: 'Test Album',
trackTitle: null,
requestedAt: '2026-05-08T00:00:00Z',
decidedAt: null,
notes: null,
importedAlbumCount: 0,
importedTrackCount: 0,
);
const _alice = AdminUser(
id: 'user-uuid-1',
username: 'alice',
displayName: null,
isAdmin: false,
autoApproveRequests: false,
createdAt: '2026-05-01T00:00:00Z',
);
Widget _harness({
required List<AdminRequest> requests,
required List<AdminUser> users,
}) =>
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminRequestsProvider.overrideWith(() => _StubRequests(requests)),
adminUsersProvider.overrideWith(() => _StubUsers(users)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminRequestsScreen(),
),
);
void main() {
testWidgets('renders empty state when no requests', (t) async {
await t.pumpWidget(_harness(requests: const [], users: const []));
await t.pumpAndSettle();
expect(find.text('No pending requests.'), findsOneWidget);
});
testWidgets('renders row with display name + joined requester username',
(t) async {
await t.pumpWidget(_harness(requests: const [_row], users: const [_alice]));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget);
expect(find.text('Test Album'), findsOneWidget);
expect(find.textContaining('requested by alice'), findsOneWidget);
});
testWidgets('falls back to uuid prefix when username unknown', (t) async {
await t.pumpWidget(_harness(requests: const [_row], users: const []));
await t.pumpAndSettle();
expect(find.textContaining('requested by user-uui'), findsOneWidget);
});
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_users_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_user.dart';
import 'package:minstrel/models/invite.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubUsers extends AdminUsersController {
_StubUsers(this._initial);
final List<AdminUser> _initial;
@override
Future<List<AdminUser>> build() async => _initial;
}
class _StubInvites extends AdminInvitesController {
_StubInvites(this._initial);
final List<Invite> _initial;
@override
Future<List<Invite>> build() async => _initial;
}
const _userRow = AdminUser(
id: 'u2',
username: 'alice',
displayName: null,
isAdmin: false,
autoApproveRequests: true,
createdAt: '2026-05-01T00:00:00Z',
);
const _inviteRow = Invite(
token: 'INV-ABC123',
invitedBy: 'u1',
note: 'for alice',
createdAt: '2026-05-08T00:00:00Z',
expiresAt: '2026-05-09T00:00:00Z',
);
Widget _harness({
required List<AdminUser> users,
required List<Invite> invites,
}) =>
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminUsersProvider.overrideWith(() => _StubUsers(users)),
adminInvitesProvider.overrideWith(() => _StubInvites(invites)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminUsersScreen(),
),
);
void main() {
testWidgets('renders user rows with badges', (t) async {
await t.pumpWidget(_harness(users: const [_userRow], invites: const []));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget);
expect(find.text('alice'), findsOneWidget);
expect(find.text('auto-approve'), findsOneWidget);
});
testWidgets('renders invite rows + Generate button', (t) async {
await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow]));
await t.pumpAndSettle();
expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget);
expect(find.byKey(const Key('invite_generate_button')), findsOneWidget);
expect(find.textContaining('for alice'), findsOneWidget);
});
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/cache/adapters.dart';
import 'package:minstrel/models/album.dart';
import 'package:minstrel/models/artist.dart';
import 'package:minstrel/models/playlist.dart';
import 'package:minstrel/models/track.dart';
void main() {
test('ArtistRef.toDrift preserves id + name + sortName', () {
const ref = ArtistRef(id: 'a1', name: 'Boards of Canada', sortName: 'Boards of Canada');
final companion = ref.toDrift();
expect(companion.id.value, 'a1');
expect(companion.name.value, 'Boards of Canada');
expect(companion.sortName.value, 'Boards of Canada');
});
test('ArtistRef.toDrift falls back to name when sortName is empty', () {
const ref = ArtistRef(id: 'a1', name: 'The Album Leaf');
final companion = ref.toDrift();
expect(companion.sortName.value, 'The Album Leaf');
});
test('AlbumRef.toDrift preserves id + title + artistId', () {
const ref = AlbumRef(id: 'al1', title: 'Geogaddi', artistId: 'ar1');
final companion = ref.toDrift();
expect(companion.id.value, 'al1');
expect(companion.title.value, 'Geogaddi');
expect(companion.artistId.value, 'ar1');
});
test('TrackRef.toDrift converts seconds to ms + preserves track/disc', () {
const ref = TrackRef(
id: 't1',
title: 'Roygbiv',
albumId: 'al1',
artistId: 'ar1',
durationSec: 137,
trackNumber: 4,
discNumber: 1,
);
final companion = ref.toDrift();
expect(companion.id.value, 't1');
expect(companion.durationMs.value, 137 * 1000);
expect(companion.trackNumber.value, 4);
expect(companion.discNumber.value, 1);
});
test('Playlist.toDrift preserves id + userId + name', () {
const p = Playlist(
id: 'p1',
userId: 'u1',
name: 'My Mix',
description: 'a great mix',
isPublic: true,
systemVariant: null,
trackCount: 12,
coverUrl: '',
ownerUsername: 'alice',
createdAt: '',
updatedAt: '',
);
final companion = p.toDrift();
expect(companion.id.value, 'p1');
expect(companion.userId.value, 'u1');
expect(companion.name.value, 'My Mix');
expect(companion.isPublic.value, true);
expect(companion.trackCount.value, 12);
});
}
+141
View File
@@ -0,0 +1,141 @@
@Tags(['drift'])
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/cache/audio_cache_manager.dart';
import 'package:minstrel/cache/db.dart';
// See sync_controller_test.dart for the same skip rationale.
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
AppDb _testDb() => AppDb(NativeDatabase.memory());
void main() {
test('isCached returns false when no row exists', skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => Directory.systemTemp.createTempSync(),
);
expect(await mgr.isCached('nonexistent'), false);
expect(await mgr.pathFor('nonexistent'), null);
});
test('usageBytes sums sizeBytes across rows', skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
AudioCacheIndexCompanion.insert(
trackId: 'a',
path: 'p',
sizeBytes: 100,
source: CacheSource.manual),
AudioCacheIndexCompanion.insert(
trackId: 'b',
path: 'p',
sizeBytes: 250,
source: CacheSource.incidental),
]);
});
expect(await mgr.usageBytes(), 350);
});
test('eviction order: incidental first, manual never', skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f1 = File('${tmp.path}/audio_cache/inc.mp3');
await f1.create(recursive: true);
await f1.writeAsBytes(List.filled(100, 0));
final f2 = File('${tmp.path}/audio_cache/man.mp3');
await f2.create(recursive: true);
await f2.writeAsBytes(List.filled(100, 0));
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
AudioCacheIndexCompanion.insert(
trackId: 'inc',
path: f1.path,
sizeBytes: 100,
source: CacheSource.incidental),
AudioCacheIndexCompanion.insert(
trackId: 'man',
path: f2.path,
sizeBytes: 100,
source: CacheSource.manual),
]);
});
expect(await mgr.usageBytes(), 200);
await mgr.evict(targetBytes: 100);
expect(await mgr.isCached('inc'), false);
expect(await mgr.isCached('man'), true);
});
test('clearAll removes everything including manual', skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f = File('${tmp.path}/audio_cache/man.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(50, 0));
await db.into(db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: 'man',
path: f.path,
sizeBytes: 50,
source: CacheSource.manual),
);
expect(await mgr.usageBytes(), 50);
await mgr.clearAll();
expect(await mgr.usageBytes(), 0);
expect(await mgr.isCached('man'), false);
});
test('unpin removes index row + deletes file', skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f = File('${tmp.path}/audio_cache/x.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(10, 0));
await db.into(db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: 'x',
path: f.path,
sizeBytes: 10,
source: CacheSource.autoPrefetch),
);
expect(await mgr.isCached('x'), true);
await mgr.unpin('x');
expect(await mgr.isCached('x'), false);
expect(f.existsSync(), false);
});
}

Some files were not shown because too many files have changed in this diff Show More