Commit Graph

826 Commits

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