Commit Graph

15 Commits

Author SHA1 Message Date
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00
bvandeusen bf0ef5e0c3 fix(flutter): keep artist avatars round in the Library grid
ArtistCard hardcoded its avatar at Container(width: 124, height:
124) inside a ClipOval. In the Library Artists 3-column grid the
cell is narrower than the card's nominal 140dp width — on a typical
phone the cell is ~109dp, the padded inner content area ~93dp. The
parent constrained the Container's width to ~93dp but the explicit
height stayed 124dp, so ClipOval clipped a 93×124 rectangle and
the avatar rendered as a vertical ellipse.

Fix mirrors AlbumCard's pattern: ArtistCard takes an optional
`width` parameter (default 140 for horizontal carousels) and
derives coverSize = width - 16, so the Container is always square.
ArtistsTab now uses LayoutBuilder to compute cell width and passes
it through, same as AlbumsTab. Avatar stays a true circle at any
cell width.

mainAxisExtent on the grid replaces the previous fixed
childAspectRatio so cell height tracks cellW + name line, with
slack matching AlbumsTab's overflow guard.
2026-05-14 08:39:37 -04:00
bvandeusen bb9e979876 feat(web,flutter): #401 — now-playing highlight on TrackRow + PlaylistTrackRow
Cross-platform consistency: when a track is currently playing, its row
on the album / liked / history / search / playlist detail surfaces
gets the same accent-border + bg-lift treatment that QueueTrackRow
applies on the queue panel. Closes the inconsistency caught during
the #375 DRY audit (the queue row had a "you are here" indicator;
other track-row surfaces did not).

Web — TrackRow.svelte + PlaylistTrackRow.svelte:
  isCurrent = player.current?.id === track.id
  → border-l-2 border-l-accent bg-surface-hover when true.
  PlaylistTrackRow additionally gates on !isUnavailable so deleted
  rows never match a phantom playing id.

Flutter — TrackRow + _PlaylistTrackRow:
  TrackRow becomes a ConsumerWidget, watches mediaItemProvider, and
  wraps its InkWell in a Container whose BoxDecoration carries the
  fs.iron background + 2px fs.accent left border when current. Title
  text also shifts to fs.accent and FontWeight.w500. Same pattern for
  _PlaylistTrackRow.

No "Now playing" pill on these surfaces — the player bar already
names the track, so the accent band alone reads as enough cue.

For #401 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:05:33 -04:00
bvandeusen 872b0de304 feat(flutter): #393 — always-visible play button on home cards
AlbumCard / ArtistCard / PlaylistCard gain a 44dp circular play
button overlaid bottom-right of the cover art. Mirrors the
hover-revealed .play-overlay on the web cards; always visible
because hover is not a real interaction on touch.

Per-card semantics match the web:

- AlbumCard: fetches /api/albums/{id}, starts playback from track 0.
- ArtistCard: fetches /api/artists/{id}/tracks, Fisher-Yates shuffles,
  plays from index 0 (matches web's playQueue(shuffle(tracks), 0)).
- PlaylistCard: fetches /api/playlists/{id}, materializes available
  rows into TrackRef, plays from index 0. Disabled state when
  trackCount == 0 — semi-transparent button, taps ignored.

Shared PlayCircleButton widget manages loading state (spinner during
fetch) so each card's onPressed can stay async without re-entrancy
guards. Cards become ConsumerWidget so they can reach the
playerActionsProvider + relevant API providers; constructor surface
unchanged so existing call sites (artist_detail, library_screen,
home_screen) keep working.

CompactTrackCard unchanged — its tap already plays-from-here.

For #393 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:35:57 -04:00
bvandeusen 983a9d92be fix(flutter): tighter track row + missing artist in mini player + hide redundant artist on artist page
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
  so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
  numbers cramp.

Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
  track in album detail; layout change wasn't the cause. Real cause:
  albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
  never cachedArtists. The drift JOINs that build the AlbumRef + each
  TrackRef returned null for the artist row, so artistName fell back
  to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
  off the album header + each track, inserts them into cachedArtists
  alongside the album/tracks. Subsequent JOINs populate artistName,
  the audio handler stamps it on MediaItem, and the mini player picks
  it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
  re-fetched (cache invalidation TBD). Future cold-cache hits will.

Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
  detail grid since the page header already names the artist. cellH
  trimmed by the 16dp artist line.
2026-05-11 08:47:50 -04:00
bvandeusen 21ab0d78bb fix(flutter): artist albums grid — 3 per row, title wraps, no overflow
AlbumCard gains optional `width` (default 140 keeps horizontal lists
unchanged) and `titleMaxLines` (default 1) so callers can let the
title wrap to a second line. Cover sizes to width - 16 instead of a
hardcoded 124, so the card scales down cleanly when a narrower cell
width is passed in.

Artist detail grid: switch to 3 columns. LayoutBuilder computes the
cell width from the available width so AlbumCard sizes to the cell
exactly (no overflow). cellH = cover + gap + 2-line title + artist
line + small fudge — tight enough that there's no visible gap below
the card, tall enough to fit a wrapped title.
2026-05-11 08:43:58 -04:00
bvandeusen 3162fedd3b fix(flutter): card taps via Material+InkWell, not GestureDetector
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.

Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:

- InkWell registers via the Material ripple system instead of the
  raw gesture arena, sidestepping any subtle deferToChild edge cases
  inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
  visually — if the ripple shows but nothing navigates, the issue is
  on the route side; if no ripple, the tap never landed.

Material color is transparent so the existing dark page background
shows through unchanged.
2026-05-11 07:57:10 -04:00
bvandeusen d703fc27f8 fix(flutter): album/artist card tap (HitTestBehavior.opaque)
Same root cause as the playlist card fix in f65650f — GestureDetector
defers to children by default, so taps over the cover image area
(ServerImage / SVG fallback) didn't reliably propagate. Mark the
detector opaque so the entire card surface is tappable, not just the
text below the cover.
2026-05-11 07:30:05 -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 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 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 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 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 c47deb3c87 feat(flutter/library): card + row widgets (artist/album/track)
HorizontalScrollRow takes an optional shared ScrollController so
multi-row sections (Most played: 3 rows) couple their scroll like the
web HorizontalScrollRow.svelte. Cards fall back to the synced
album-fallback.svg when coverUrl is empty.
2026-05-02 17:29:03 -04:00