From bb9e979876a4b387935656a49eb5790a4f23f350 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 14:05:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(web,flutter):=20#401=20=E2=80=94=20now-pla?= =?UTF-8?q?ying=20highlight=20on=20TrackRow=20+=20PlaylistTrackRow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/library/widgets/track_row.dart | 105 +++++++++++------- .../lib/playlists/playlist_detail_screen.dart | 88 +++++++++------ .../lib/components/PlaylistTrackRow.svelte | 11 +- web/src/lib/components/TrackRow.svelte | 11 +- 4 files changed, 135 insertions(+), 80 deletions(-) diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart index 1c930a99..48c97e4e 100644 --- a/flutter_client/lib/library/widgets/track_row.dart +++ b/flutter_client/lib/library/widgets/track_row.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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 'cached_indicator.dart'; -class TrackRow extends StatelessWidget { +class TrackRow extends ConsumerWidget { const TrackRow({ required this.track, required this.onTap, @@ -23,56 +25,75 @@ class TrackRow extends StatelessWidget { final bool actions; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0'); final secs = (track.durationSec % 60).toString().padLeft(2, '0'); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row(children: [ - if (track.trackNumber != null) - SizedBox( - width: 22, - child: Text( - track.trackNumber.toString(), - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), + // Watch the currently-playing media item so the row's accent + // highlight tracks playback state. Matches _QueueRow's visual + // treatment in queue_screen.dart so the "you are here" cue is + // consistent across album / playlist / queue surfaces. + final currentId = ref.watch(mediaItemProvider).value?.id; + final isCurrent = currentId != null && currentId == track.id; + return Container( + decoration: BoxDecoration( + color: isCurrent ? fs.iron : null, + border: isCurrent + ? Border(left: BorderSide(color: fs.accent, width: 2)) + : null, + ), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row(children: [ + if (track.trackNumber != null) + SizedBox( + width: 22, + child: Text( + track.trackNumber.toString(), + style: TextStyle(color: fs.ash, fontSize: 13), ), - // Skip the artist line entirely when empty so the row - // height collapses to a single line of title — keeps - // dense album views from looking padded. - if (track.artistName.isNotEmpty) + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ Text( - track.artistName, + track.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), + style: TextStyle( + color: isCurrent ? fs.accent : fs.parchment, + fontSize: 14, + fontWeight: + isCurrent ? FontWeight.w500 : FontWeight.w400, + ), ), - ], + // Skip the artist line entirely when empty so the row + // height collapses to a single line of title — keeps + // dense album views from looking padded. + if (track.artistName.isNotEmpty) + Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), - ), - 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), - ]), + 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), + ]), + ), ), ); } diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 37d7ce9a..5e8c07e7 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -233,51 +233,69 @@ class _Header extends ConsumerWidget { } } -class _PlaylistTrackRow extends StatelessWidget { +class _PlaylistTrackRow extends ConsumerWidget { const _PlaylistTrackRow({required this.row, required this.onTap}); final PlaylistTrack row; final VoidCallback? onTap; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); final secs = (row.durationSec % 60).toString().padLeft(2, '0'); - final color = row.isAvailable ? fs.parchment : fs.ash; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 14, - decoration: row.isAvailable - ? null - : TextDecoration.lineThrough, + // "Now playing" highlight — matches _QueueRow and TrackRow so the + // user sees which playlist row is current without reading the + // player bar. Unavailable rows never match. + final currentId = ref.watch(mediaItemProvider).value?.id; + final isCurrent = row.isAvailable && + currentId != null && + currentId == row.trackId; + final baseColor = row.isAvailable ? fs.parchment : fs.ash; + final titleColor = isCurrent ? fs.accent : baseColor; + return Container( + decoration: BoxDecoration( + color: isCurrent ? fs.iron : null, + border: isCurrent + ? Border(left: BorderSide(color: fs.accent, width: 2)) + : null, + ), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: titleColor, + fontSize: 14, + fontWeight: + isCurrent ? FontWeight.w500 : FontWeight.w400, + decoration: row.isAvailable + ? null + : TextDecoration.lineThrough, + ), ), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], + Text( + '${row.artistName} · ${row.albumTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), - ), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (row.trackId != null) - TrackActionsButton(track: _toTrackRef(row)), - ]), + Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), + if (row.trackId != null) + TrackActionsButton(track: _toTrackRef(row)), + ]), + ), ), ); } diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte index 595a85fa..c6916061 100644 --- a/web/src/lib/components/PlaylistTrackRow.svelte +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -5,6 +5,7 @@ import TrackMenu from './TrackMenu.svelte'; import type { PlaylistTrack } from '$lib/api/types'; import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef'; + import { player } from '$lib/player/store.svelte'; import { offsetToDelta } from './queue-row-math'; let { @@ -50,12 +51,20 @@ // the dragEnd handler skips by returning before onMove fires; visually // the user never sees the row move. const canDrag = $derived(isOwner && !isUnavailable); + + // "Now playing" highlight: matches QueueTrackRow's treatment so the + // user can see which row is current without reading the player bar. + // Unavailable rows (trackId === null) never match. + const isCurrent = $derived( + !isUnavailable && player.current?.id === row.track_id, + );
import type { TrackRef } from '$lib/api/types'; import { formatDuration } from '$lib/media/duration'; - import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte'; + import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte'; import { Plus } from 'lucide-svelte'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; @@ -16,6 +16,13 @@ const track = $derived(tracks[index]); + // Mirrors QueueTrackRow's "now playing" treatment so the user sees + // which row in the album / liked / history / search view is currently + // playing without having to read the player bar. Only the accent + // border + bg lift — no "Now playing" pill, since the player bar + // already names the track. + const isCurrent = $derived(player.current?.id === track.id); + function activate() { if (onPlay) onPlay(tracks, index); else playQueue(tracks, index); @@ -49,7 +56,7 @@ aria-label={track.title} onclick={onRowClick} onkeydown={onRowKey} - class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" + class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}" > {track.track_number ?? '—'}