diff --git a/lib/library/browse_query.dart b/lib/library/browse_query.dart index 4815910..89868c9 100644 --- a/lib/library/browse_query.dart +++ b/lib/library/browse_query.dart @@ -5,7 +5,7 @@ import '../subsonic/models.dart'; /// Held in `state/providers.dart` (not persisted) so the app never silently /// reopens filtered. Sort order *is* persisted (see [AppSettings]). class BrowseFilter { - const BrowseFilter({this.genre, this.year}); + const BrowseFilter({this.genre, this.year, this.minRating}); /// Case-insensitive genre match, or null for "all genres". final String? genre; @@ -13,14 +13,27 @@ class BrowseFilter { /// Exact release-year match, or null for "all years". final int? year; - bool get isActive => genre != null || year != null; + /// Minimum star rating (1–5) a track must meet, or null for "any rating". + /// Tracks are kept when their effective rating is `>= minRating`. + final int? minRating; - int get activeCount => (genre != null ? 1 : 0) + (year != null ? 1 : 0); + bool get isActive => genre != null || year != null || minRating != null; - BrowseFilter copyWith({Object? genre = _unset, Object? year = _unset}) => + int get activeCount => + (genre != null ? 1 : 0) + + (year != null ? 1 : 0) + + (minRating != null ? 1 : 0); + + BrowseFilter copyWith({ + Object? genre = _unset, + Object? year = _unset, + Object? minRating = _unset, + }) => BrowseFilter( genre: identical(genre, _unset) ? this.genre : genre as String?, year: identical(year, _unset) ? this.year : year as int?, + minRating: + identical(minRating, _unset) ? this.minRating : minRating as int?, ); static const Object _unset = Object(); @@ -60,6 +73,15 @@ bool _genreMatches(String? itemGenre, String? filterGenre) { int _byString(String? a, String? b) => (a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase()); +/// A track's effective 0–5 rating: the live value from the favorites map (see +/// `state/favorites.dart`) when present, else the index-time [Song.userRating], +/// else 0 (unrated). Mirrors the star UI on the Now Playing screen. +int _effectiveRating(Song s, Map ratings) { + final live = ratings[s.id]; + if (live != null && live > 0) return live; + return s.userRating ?? 0; +} + /// Compare where a null [a]/[b] always sorts *last*, regardless of [descending]. /// Takes bare [Comparable] so both `int` (`Comparable`) and `DateTime` work. int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) { @@ -112,11 +134,15 @@ List applyAlbumQuery( List applyTrackQuery( List songs, BrowseFilter filter, - TrackSort sort, -) { + TrackSort sort, { + Map ratings = const {}, +}) { final out = songs .where((s) => _genreMatches(s.genre, filter.genre)) .where((s) => filter.year == null || s.year == filter.year) + .where((s) => + filter.minRating == null || + _effectiveRating(s, ratings) >= filter.minRating!) .toList(); switch (sort) { @@ -144,6 +170,12 @@ List applyTrackQuery( final c = _nullsLast(a.createdAt, b.createdAt, descending: true); return c != 0 ? c : _byString(a.title, b.title); }); + case TrackSort.ratingDesc: + out.sort((a, b) { + // Descending: highest rating first; unrated (0) naturally sinks last. + final c = _effectiveRating(b, ratings) - _effectiveRating(a, ratings); + return c != 0 ? c : _byString(a.title, b.title); + }); } return out; } diff --git a/lib/main.dart b/lib/main.dart index 3798281..83f5eef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'shell/app_shell.dart'; import 'state/providers.dart'; import 'theme/accent.dart'; import 'theme/app_theme.dart'; +import 'theme/tokens.dart'; import 'widgets/splash_screen.dart'; Future main() async { @@ -38,12 +39,26 @@ class TimbreApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // The accent either tracks the album art (default) or is pinned to a - // user-chosen static color. The theme rebuilds whenever it changes. final settings = ref.watch(settingsProvider); - final accent = settings.useStaticAccent - ? settings.staticAccentColor - : ref.watch(accentProvider); + + // Point the token getters at the selected theme's palette before the theme + // (and the widget tree) read them. + final palette = settings.appTheme == AppTheme.lavender + ? TimbrePalette.lavender + : TimbrePalette.dark; + TimbreColors.applyPalette(palette); + + // The accent either tracks the album art (default theme, dynamic mode) or + // is pinned — to the user's static color, or to the theme's own accent when + // the theme locks it (Lavender). The theme rebuilds whenever it changes. + final Color accent; + if (settings.appTheme == AppTheme.lavender) { + accent = palette.accentDefault; // locked lavender primary + } else if (settings.useStaticAccent) { + accent = settings.staticAccentColor; + } else { + accent = ref.watch(accentProvider); + } // Keep the accent synced with the remote track's art while acting as a // remote (bug #6). Alive as long as the app is. ref.watch(remoteAccentSyncProvider); @@ -52,8 +67,11 @@ class TimbreApp extends ConsumerWidget { debugShowCheckedModeBanner: false, theme: buildTimbreTheme(accent), // AppShell mounts under the splash and boots behind it; the splash fades - // out after a couple of seconds. - home: const SplashGate(child: AppShell()), + // out after a couple of seconds. Keying AppShell by the theme forces a + // remount on theme switch so every widget re-reads the (now runtime) + // TimbreColors getters; providers live above TimbreApp, so playback and + // library state survive the remount. + home: SplashGate(child: AppShell(key: ValueKey(settings.appTheme))), ); } } diff --git a/lib/screens/add_tag_sheet.dart b/lib/screens/add_tag_sheet.dart index 547ae36..5e45262 100644 --- a/lib/screens/add_tag_sheet.dart +++ b/lib/screens/add_tag_sheet.dart @@ -50,7 +50,7 @@ class _AddTagSheet extends ConsumerWidget { ), const SizedBox(height: TimbreSpacing.md), if (!connected) - const Padding( + Padding( padding: EdgeInsets.all(TimbreSpacing.xl), child: Text('Connect to a server to manage tags.', style: TextStyle(color: TimbreColors.dimmed)), @@ -142,7 +142,7 @@ class _Tile extends StatelessWidget { ), if (trailing != null) Text(trailing!, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ], ), ), diff --git a/lib/screens/add_to_playlist_sheet.dart b/lib/screens/add_to_playlist_sheet.dart index fbae98d..de0f583 100644 --- a/lib/screens/add_to_playlist_sheet.dart +++ b/lib/screens/add_to_playlist_sheet.dart @@ -49,7 +49,7 @@ class _AddToPlaylistSheet extends ConsumerWidget { ), const SizedBox(height: TimbreSpacing.md), if (!connected) - const Padding( + Padding( padding: EdgeInsets.all(TimbreSpacing.xl), child: Text('Connect to a server to manage playlists.', style: TextStyle(color: TimbreColors.dimmed)), @@ -140,7 +140,7 @@ class _Tile extends StatelessWidget { ), if (trailing != null) Text(trailing!, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ], ), ), @@ -166,8 +166,8 @@ Future promptPlaylistName( content: TextField( controller: controller, autofocus: true, - style: const TextStyle(color: TimbreColors.foreground), - decoration: const InputDecoration( + style: TextStyle(color: TimbreColors.foreground), + decoration: InputDecoration( hintText: 'Playlist name', hintStyle: TextStyle(color: TimbreColors.dimmed), ), diff --git a/lib/screens/browse_controls.dart b/lib/screens/browse_controls.dart index 6ce45ca..288e4f8 100644 --- a/lib/screens/browse_controls.dart +++ b/lib/screens/browse_controls.dart @@ -158,6 +158,29 @@ class TrackControlBar extends ConsumerWidget { } }, ), + _ControlChip( + label: filter.minRating != null ? '${filter.minRating}+★' : 'Rating', + active: filter.minRating != null, + onTap: () async { + final picked = await _showPicker( + context, + title: 'Minimum rating', + current: filter.minRating, + options: const [ + _Opt('Any rating', null), + _Opt('1+', 1), + _Opt('2+', 2), + _Opt('3+', 3), + _Opt('4+', 4), + _Opt('5 stars', 5), + ], + ); + if (picked != null) { + ref.read(trackFilterProvider.notifier).state = + filter.copyWith(minRating: picked.value); + } + }, + ), if (filter.isActive) _ClearChip(onTap: () => ref.read(trackFilterProvider.notifier).state = const BrowseFilter()), @@ -250,11 +273,11 @@ class _ClearChip extends StatelessWidget { const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), alignment: Alignment.center, - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.close, size: 14, color: TimbreColors.dimmed), - SizedBox(width: TimbreSpacing.xs), + const SizedBox(width: TimbreSpacing.xs), Text('Clear', style: TextStyle(color: TimbreColors.dimmed)), ], ), diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index fc1332d..27e14d4 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -138,9 +138,9 @@ class _ModeSelector extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ label('Artists', BrowseMode.artists), - const Text('|', style: TextStyle(color: TimbreColors.border)), + Text('|', style: TextStyle(color: TimbreColors.border)), label('Albums', BrowseMode.albums), - const Text('|', style: TextStyle(color: TimbreColors.border)), + Text('|', style: TextStyle(color: TimbreColors.border)), label('Tracks', BrowseMode.tracks), ], ); @@ -292,14 +292,14 @@ class _AlbumTile extends StatelessWidget { album.name ?? 'Unknown album', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground), + style: TextStyle(color: TimbreColors.foreground), ), if (album.artist != null) Text( album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12), ), ], @@ -311,7 +311,7 @@ class _AlbumTile extends StatelessWidget { class _AlbumArtFallback extends StatelessWidget { const _AlbumArtFallback(); @override - Widget build(BuildContext context) => const Center( + Widget build(BuildContext context) => Center( child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32), ); @@ -354,7 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { children: [ const _Loading(), const SizedBox(height: TimbreSpacing.md), - Text(label, style: const TextStyle(color: TimbreColors.dimmed)), + Text(label, style: TextStyle(color: TimbreColors.dimmed)), ], ), ); @@ -423,8 +423,8 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { onTap: index.building ? null : () => ref.read(libraryIndexProvider.notifier).refresh(), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), child: Text('↻ refresh', style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)), ), @@ -439,7 +439,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { _confirmDownloadAll(context, visible); } }, - itemBuilder: (_) => const [ + itemBuilder: (_) => [ PopupMenuItem( value: 'download-all', child: Row( @@ -454,8 +454,8 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { ), ), ], - child: const Padding( - padding: EdgeInsets.all(TimbreSpacing.xs), + child: Padding( + padding: const EdgeInsets.all(TimbreSpacing.xs), child: Icon(Icons.more_vert, size: 18, color: TimbreColors.dimmed), ), @@ -513,7 +513,7 @@ class _Action extends StatelessWidget { children: [ Icon(icon, size: 16, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.sm), - Text(label, style: const TextStyle(color: TimbreColors.foreground)), + Text(label, style: TextStyle(color: TimbreColors.foreground)), ], ), ), @@ -710,7 +710,7 @@ class BrowseRow extends StatelessWidget { SizedBox( width: 28, child: Text(leading!, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Column( @@ -721,14 +721,14 @@ class BrowseRow extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground), + style: TextStyle(color: TimbreColors.foreground), ), if (subtitle != null) Text( subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12), ), ], @@ -752,7 +752,7 @@ class BrowseRow extends StatelessWidget { if (trailing != null) ...[ const SizedBox(width: TimbreSpacing.md), Text(trailing!, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ], if (onPlayNext != null) _RowIcon( @@ -809,7 +809,7 @@ class _RowMenu extends StatelessWidget { @override Widget build(BuildContext context) { return PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), + icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, padding: EdgeInsets.zero, constraints: @@ -897,7 +897,7 @@ class _NotConnected extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Centered( + return _Centered( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -940,7 +940,7 @@ class _ErrorText extends StatelessWidget { Widget build(BuildContext context) => Text( message, textAlign: TextAlign.center, - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ); } diff --git a/lib/screens/connect_sheet.dart b/lib/screens/connect_sheet.dart index f11a422..d6c1bc7 100644 --- a/lib/screens/connect_sheet.dart +++ b/lib/screens/connect_sheet.dart @@ -49,7 +49,7 @@ class _ServerSheet extends ConsumerWidget { ), const SizedBox(height: TimbreSpacing.md), if (conn.servers.isEmpty) - const Padding( + Padding( padding: EdgeInsets.symmetric( horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), child: Text('No servers saved yet.', @@ -77,7 +77,7 @@ class _ServerSheet extends ConsumerWidget { nav.pop(); showConnectSheet(nav.context); }, - child: const Padding( + child: Padding( padding: EdgeInsets.symmetric( horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), child: Row( @@ -268,7 +268,7 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> { onPressed: _busy ? null : _save, style: OutlinedButton.styleFrom( foregroundColor: TimbreColors.foreground, - side: const BorderSide(color: TimbreColors.border), + side: BorderSide(color: TimbreColors.border), shape: const RoundedRectangleBorder(), ), child: const Text('Save'), @@ -314,13 +314,13 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> { keyboardType: keyboard, autocorrect: false, enableSuggestions: false, - style: const TextStyle(color: TimbreColors.foreground), + style: TextStyle(color: TimbreColors.foreground), decoration: InputDecoration( labelText: label, hintText: hint, - labelStyle: const TextStyle(color: TimbreColors.dimmed), - hintStyle: const TextStyle(color: TimbreColors.dimmed), - enabledBorder: const OutlineInputBorder( + labelStyle: TextStyle(color: TimbreColors.dimmed), + hintStyle: TextStyle(color: TimbreColors.dimmed), + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: TimbreColors.border), ), diff --git a/lib/screens/devices_sheet.dart b/lib/screens/devices_sheet.dart index 9caf792..060e2e7 100644 --- a/lib/screens/devices_sheet.dart +++ b/lib/screens/devices_sheet.dart @@ -189,8 +189,8 @@ class _Divider extends StatelessWidget { const _Divider(); @override - Widget build(BuildContext context) => const Padding( - padding: EdgeInsets.symmetric( + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.sm), child: Divider(color: TimbreColors.border, height: 1), ); @@ -219,7 +219,7 @@ class _Note extends StatelessWidget { ], Flexible( child: Text(text, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ), ], ), diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index 246a667..7b2a81d 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -28,14 +28,14 @@ class DownloadsScreen extends ConsumerWidget { if (completed.isNotEmpty) TextButton( onPressed: () => _confirmClear(context, controller), - child: const Text('Clear all', + child: Text('Clear all', style: TextStyle(color: TimbreColors.dimmed)), ), ], ), body: SafeArea( child: (active.isEmpty && completed.isEmpty) - ? const Center( + ? Center( child: Text('No downloads yet.', style: TextStyle(color: TimbreColors.dimmed)), ) @@ -65,7 +65,7 @@ class DownloadsScreen extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: completed.isEmpty - ? const Padding( + ? Padding( padding: EdgeInsets.all(TimbreSpacing.lg), child: Text('Nothing saved for offline yet.', style: TextStyle(color: TimbreColors.dimmed)), @@ -131,7 +131,7 @@ class _ActiveRow extends StatelessWidget { Text(info.song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), const SizedBox(height: TimbreSpacing.xs), if (failed) const Text('Failed', @@ -151,7 +151,7 @@ class _ActiveRow extends StatelessWidget { failed ? '—' : (info.status == DownloadStatus.queued ? 'Queued' : ''), - style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12), + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), ), ], ), @@ -190,7 +190,7 @@ class _SavedRow extends StatelessWidget { Text(info.song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), Text( [ info.song.artist, @@ -199,7 +199,7 @@ class _SavedRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: - const TextStyle(color: TimbreColors.dimmed, fontSize: 12), + TextStyle(color: TimbreColors.dimmed, fontSize: 12), ), ], ), @@ -207,7 +207,7 @@ class _SavedRow extends StatelessWidget { InkWell( onTap: onRemove, customBorder: const CircleBorder(), - child: const SizedBox( + child: SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, child: Icon(Icons.delete_outline, diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index e534822..e318c03 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -28,11 +28,11 @@ class FavoritesScreen extends ConsumerWidget { ), error: (e, _) => Center( child: Text('$e', - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ), data: (s) { if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) { - return const Center( + return Center( child: Text('No favorites yet.', style: TextStyle(color: TimbreColors.dimmed)), ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6b19140..a0aabbd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -69,8 +69,8 @@ class HomeScreen extends ConsumerWidget { ), if (recentAlbums.isEmpty && client == null) - const Padding( - padding: EdgeInsets.only(top: TimbreSpacing.xl), + Padding( + padding: const EdgeInsets.only(top: TimbreSpacing.xl), child: Text( 'Connect to a server and start listening — your home fills in as you play.', style: TextStyle(color: TimbreColors.dimmed), @@ -116,11 +116,11 @@ class _HeroCard extends ConsumerWidget { return _HeroShell( accent: accent, onTap: () => ref.read(selectedTabProvider.notifier).state = 1, - child: const Row( + child: Row( children: [ Icon(Icons.library_music_outlined, color: TimbreColors.dimmed, size: 40), - SizedBox(width: TimbreSpacing.lg), + const SizedBox(width: TimbreSpacing.lg), Expanded( child: Text('Browse your library to start listening', style: TextStyle(color: TimbreColors.foreground)), @@ -152,9 +152,9 @@ class _HeroCard extends ConsumerWidget { key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, - errorBuilder: (_, _, _) => const Icon( + errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) - : const Icon(Icons.album_outlined, + : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), @@ -181,14 +181,14 @@ class _HeroCard extends ConsumerWidget { Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700)), if (subtitle != null) Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), if (hasCurrent) ...[ const SizedBox(height: TimbreSpacing.md), const _HeroProgress(), @@ -283,7 +283,7 @@ class _ShelfHeader extends StatelessWidget { return Row( children: [ Text(title, - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700, letterSpacing: 0.5)), @@ -292,7 +292,7 @@ class _ShelfHeader extends StatelessWidget { InkWell( onTap: onShuffle, customBorder: const CircleBorder(), - child: const SizedBox( + child: SizedBox( width: TimbreSpacing.minTouchTarget, height: 28, child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed), @@ -412,9 +412,9 @@ class _RandomBody extends StatelessWidget { key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, - errorBuilder: (_, _, _) => const Icon( + errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) - : const Icon(Icons.album_outlined, color: TimbreColors.dimmed), + : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), ), @@ -427,7 +427,7 @@ class _RandomBody extends StatelessWidget { Text(album.name ?? 'Unknown album', maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700)), const SizedBox(height: TimbreSpacing.xs), @@ -435,16 +435,16 @@ class _RandomBody extends StatelessWidget { Text(album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), if (album.year != null) Text('${album.year}', - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), if (album.genre != null) Text(album.genre!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), @@ -461,7 +461,7 @@ class _RandomSkeleton extends StatelessWidget { Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(4), - child: const SizedBox( + child: SizedBox( width: _RandomAlbum._size, height: _RandomAlbum._size, child: ColoredBox(color: TimbreColors.surface), @@ -507,9 +507,9 @@ class _ArtCard extends StatelessWidget { key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, - errorBuilder: (_, _, _) => const Icon( + errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) - : const Icon(Icons.album_outlined, + : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), @@ -518,13 +518,13 @@ class _ArtCard extends StatelessWidget { Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), if (subtitle != null) Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: - const TextStyle(color: TimbreColors.dimmed, fontSize: 12)), + TextStyle(color: TimbreColors.dimmed, fontSize: 12)), ], ), ), @@ -541,7 +541,7 @@ class _ArtCardSkeleton extends StatelessWidget { width: _ArtCard._size, child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: const SizedBox( + child: SizedBox( width: _ArtCard._size, height: _ArtCard._size, child: ColoredBox(color: TimbreColors.surface), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 1dfd375..68ee04b 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -61,7 +61,7 @@ class _NowPlayingScreenState extends ConsumerState { (_, _) => _seedFavorites()); if (current == null) { - return const Center( + return Center( child: Text('Nothing playing.', style: TextStyle(color: TimbreColors.dimmed)), ); @@ -84,8 +84,8 @@ class _NowPlayingScreenState extends ConsumerState { const SizedBox(height: TimbreSpacing.xs), _Transport(state: state, ref: ref, accent: accent), if (!state.supported) - const Padding( - padding: EdgeInsets.only(top: TimbreSpacing.sm), + Padding( + padding: const EdgeInsets.only(top: TimbreSpacing.sm), child: Text( 'Audio output unavailable on this platform — test on Android/iOS.', style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), @@ -266,10 +266,12 @@ class _QueuePanel extends ConsumerStatefulWidget { class _QueuePanelState extends ConsumerState<_QueuePanel> { /// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus - /// the [TimbreSpacing.xs] vertical padding above and below it. Pinning the - /// extent lets us scroll to a row by index without measuring. + /// the [TimbreSpacing.xs] vertical padding above and below it, with an extra + /// [TimbreSpacing.md] of headroom so the two-line title/artist column has + /// breathing room and never overflows the pinned extent. Pinning the extent + /// lets us scroll to a row by index without measuring. static const double _rowExtent = - TimbreSpacing.minTouchTarget + TimbreSpacing.xs * 2; + TimbreSpacing.minTouchTarget + TimbreSpacing.md + TimbreSpacing.xs * 2; final ScrollController _controller = ScrollController(); bool _didInitialScroll = false; @@ -358,28 +360,45 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { maxLines: 1, softWrap: false, overflow: TextOverflow.clip, - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ), ), Expanded( - child: Text( - song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: titleColor, - fontWeight: - isCurrent ? FontWeight.w700 : FontWeight.w400, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: titleColor, + fontWeight: + isCurrent ? FontWeight.w700 : FontWeight.w400, + ), + ), + if (song.artist != null) + Text( + song.artist!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.dimmed, + fontSize: 12, + ), + ), + ], ), ), Text(_fmt(song.duration), - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), InkWell( onTap: () => ref.read(playbackCommandsProvider).removeAt(i), customBorder: const CircleBorder(), - child: const SizedBox( + child: SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, child: Icon(Icons.close, @@ -520,8 +539,8 @@ class _FavRating extends ConsumerWidget { InkWell( onTap: () => showAddToPlaylistSheet(context, songs: [song]), customBorder: const CircleBorder(), - child: const Padding( - padding: EdgeInsets.all(TimbreSpacing.sm), + child: Padding( + padding: const EdgeInsets.all(TimbreSpacing.sm), child: Icon(Icons.playlist_add, size: 22, color: TimbreColors.dimmed), ), @@ -623,9 +642,9 @@ class _InfoStrip extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle(color: accent, fontWeight: FontWeight.w700)), Text(song.artist ?? 'Unknown artist', - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), if (album.isNotEmpty) - Text(album, style: const TextStyle(color: TimbreColors.dimmed)), + Text(album, style: TextStyle(color: TimbreColors.dimmed)), ], ); } @@ -647,14 +666,14 @@ class _NowPlayingProgress extends ConsumerWidget { return Row( children: [ Text(_fmtDur(position), - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), const SizedBox(width: TimbreSpacing.md), Expanded( child: BlockProgressBar(progress: progress, cells: 28, height: 18), ), const SizedBox(width: TimbreSpacing.md), Text(_fmtDur(duration), - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ], ); } @@ -692,10 +711,10 @@ class _Transport extends StatelessWidget { } Widget _btn(IconData icon, VoidCallback onTap, - {Color color = TimbreColors.foreground, double size = 28}) { + {Color? color, double size = 28}) { return IconButton( onPressed: onTap, - icon: Icon(icon, color: color, size: size), + icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size), ); } } @@ -703,7 +722,7 @@ class _Transport extends StatelessWidget { class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override - Widget build(BuildContext context) => const Center( + Widget build(BuildContext context) => Center( child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 48), ); diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index b955503..55a267f 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -55,7 +55,7 @@ class PlaylistsScreen extends ConsumerWidget { ? 'No playlists yet. Tap + to create one.' : 'Connect to a server to see playlists.', textAlign: TextAlign.center, - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ), ) : Column( @@ -206,7 +206,7 @@ class _PlaylistRow extends StatelessWidget { padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ - const Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed), + Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( @@ -219,7 +219,7 @@ class _PlaylistRow extends StatelessWidget { child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground)), ), if (badge != null) ...[ @@ -230,14 +230,14 @@ class _PlaylistRow extends StatelessWidget { ), if (subtitle != null) Text(subtitle!, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), if (hasMenu) PopupMenuButton( - icon: const Icon(Icons.more_vert, + icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { @@ -375,12 +375,12 @@ class _PlaylistDetailScreenState extends ConsumerState { ), body: SafeArea( child: detail == null - ? const Center( + ? Center( child: Text('Loading…', style: TextStyle(color: TimbreColors.dimmed)), ) : songs.isEmpty - ? const Center( + ? Center( child: Text('This playlist is empty.', style: TextStyle(color: TimbreColors.dimmed)), ) @@ -454,7 +454,7 @@ class _TrackRow extends StatelessWidget { SizedBox( width: 28, child: Text('$index', - style: const TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Column( @@ -464,18 +464,18 @@ class _TrackRow extends StatelessWidget { Text(song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), if (song.artist != null) Text(song.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), PopupMenuButton( - icon: const Icon(Icons.more_vert, + icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 8408695..e5a4297 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -32,9 +32,9 @@ class _SearchScreenState extends ConsumerState { controller: _controller, autofocus: true, textInputAction: TextInputAction.search, - style: const TextStyle(color: TimbreColors.foreground), + style: TextStyle(color: TimbreColors.foreground), cursorColor: accent, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Search artists, albums, songs…', hintStyle: TextStyle(color: TimbreColors.dimmed), border: InputBorder.none, @@ -44,7 +44,7 @@ class _SearchScreenState extends ConsumerState { ), body: SafeArea( child: _query.trim().isEmpty - ? const Center( + ? Center( child: Text('Type and press search.', style: TextStyle(color: TimbreColors.dimmed)), ) @@ -71,11 +71,11 @@ class _Results extends ConsumerWidget { ), ), error: (e, _) => Center( - child: Text('$e', style: const TextStyle(color: TimbreColors.dimmed)), + child: Text('$e', style: TextStyle(color: TimbreColors.dimmed)), ), data: (r) { if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) { - return const Center( + return Center( child: Text('No results.', style: TextStyle(color: TimbreColors.dimmed)), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9b8432a..797444e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -35,7 +35,10 @@ class SettingsScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Caption( - 'Quality used when playing tracks that are not downloaded.'), + 'Quality used when playing tracks that are not downloaded. ' + '"Original (native)" streams the source untouched — full ' + 'resolution up to 192 kHz / 24-bit. The kbps options ' + 'transcode down to that (lossy) data rate.'), const SizedBox(height: TimbreSpacing.md), _ChoiceChips( label: 'Max bitrate', @@ -56,7 +59,8 @@ class SettingsScreen extends ConsumerWidget { children: [ const _Caption( 'Quality used when saving tracks for offline playback. ' - '"Original" keeps the source file (best quality, largest).'), + '"Original (native)" keeps the source file untouched — ' + 'full resolution up to 192 kHz / 24-bit (largest).'), const SizedBox(height: TimbreSpacing.md), _ChoiceChips( label: 'Max bitrate', @@ -95,25 +99,39 @@ class SettingsScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _Caption( - 'By default the accent color is drawn from the playing ' - 'album art. Turn on a static accent to pin one color.'), + const _Caption('Choose the overall color theme of the app.'), const SizedBox(height: TimbreSpacing.md), - _ChoiceChips( - label: 'Static accent', - values: const [false, true], - selected: settings.useStaticAccent, - labelFor: (v) => v ? 'On' : 'Off', - onSelect: controller.setUseStaticAccent, + _ChoiceChips( + label: 'Theme', + values: AppTheme.values, + selected: settings.appTheme, + labelFor: AppSettings.themeLabel, + onSelect: controller.setAppTheme, ), - if (settings.useStaticAccent) ...[ + // The static-vs-dynamic accent choice is unique to the + // Default theme; other themes lock their own accent. + if (settings.appTheme == AppTheme.standard) ...[ const SizedBox(height: TimbreSpacing.lg), - _ColorChips( - label: 'Accent color', - values: AppSettings.accentChoices, - selected: settings.staticAccentColor, - onSelect: controller.setStaticAccentColor, + const _Caption( + 'By default the accent color is drawn from the playing ' + 'album art. Turn on a static accent to pin one color.'), + const SizedBox(height: TimbreSpacing.md), + _ChoiceChips( + label: 'Static accent', + values: const [false, true], + selected: settings.useStaticAccent, + labelFor: (v) => v ? 'On' : 'Off', + onSelect: controller.setUseStaticAccent, ), + if (settings.useStaticAccent) ...[ + const SizedBox(height: TimbreSpacing.lg), + _ColorChips( + label: 'Accent color', + values: AppSettings.accentChoices, + selected: settings.staticAccentColor, + onSelect: controller.setStaticAccentColor, + ), + ], ], const SizedBox(height: TimbreSpacing.lg), const _Caption( @@ -184,7 +202,7 @@ class SettingsScreen extends ConsumerWidget { 'one is connected at a time.'), const SizedBox(height: TimbreSpacing.sm), if (conn.servers.isEmpty) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm), child: Text('No servers saved yet.', style: TextStyle(color: TimbreColors.dimmed)), @@ -206,7 +224,7 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: TimbreSpacing.sm), InkWell( onTap: () => showConnectSheet(context), - child: const Padding( + child: Padding( padding: EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: Row( @@ -285,13 +303,13 @@ class _ServerManageRow extends StatelessWidget { IconButton( tooltip: 'Edit', onPressed: onEdit, - icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed), + icon: Icon(Icons.edit, size: 16, color: TimbreColors.dimmed), ), IconButton( tooltip: 'Delete', onPressed: onDelete, icon: - const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed), + Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed), ), ], ); @@ -304,7 +322,7 @@ class _Caption extends StatelessWidget { @override Widget build(BuildContext context) => Text( text, - style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12), + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), ); } @@ -331,7 +349,7 @@ class _ChoiceChips extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(color: TimbreColors.foreground)), + Text(label, style: TextStyle(color: TimbreColors.foreground)), const SizedBox(height: TimbreSpacing.sm), Wrap( spacing: TimbreSpacing.sm, @@ -415,7 +433,7 @@ class _Stepper extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(color: TimbreColors.foreground)), + Text(label, style: TextStyle(color: TimbreColors.foreground)), const SizedBox(height: TimbreSpacing.sm), Row( children: [ @@ -432,7 +450,7 @@ class _Stepper extends StatelessWidget { alignment: Alignment.center, child: Text( '$value', - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700, ), @@ -507,7 +525,7 @@ class _ColorChips extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(color: TimbreColors.foreground)), + Text(label, style: TextStyle(color: TimbreColors.foreground)), const SizedBox(height: TimbreSpacing.sm), Wrap( spacing: TimbreSpacing.sm, diff --git a/lib/screens/tags_screen.dart b/lib/screens/tags_screen.dart index 69a1d47..368516d 100644 --- a/lib/screens/tags_screen.dart +++ b/lib/screens/tags_screen.dart @@ -50,7 +50,7 @@ class TagsScreen extends ConsumerWidget { 'from their ⋮ menu.' : 'Connect to a server to see tags.', textAlign: TextAlign.center, - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ), ) : ListView.builder( @@ -145,7 +145,7 @@ class _TagRow extends StatelessWidget { padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ - const Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed), + Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( @@ -155,17 +155,17 @@ class _TagRow extends StatelessWidget { Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground)), + style: TextStyle(color: TimbreColors.foreground)), if (subtitle != null) Text(subtitle!, - style: const TextStyle( + style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), if (onRename != null || onDelete != null) PopupMenuButton( - icon: const Icon(Icons.more_vert, + icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, onSelected: (v) { diff --git a/lib/settings/settings_store.dart b/lib/settings/settings_store.dart index 33ab686..78c90e7 100644 --- a/lib/settings/settings_store.dart +++ b/lib/settings/settings_store.dart @@ -19,7 +19,12 @@ enum SearchMode { discovery, standard } enum AlbumSort { nameAsc, artistAsc, yearDesc, yearAsc, recentlyAdded } /// Ordering applied to the Tracks browse list (filtered client-side). -enum TrackSort { titleAsc, artistAsc, albumAsc, yearDesc, recentlyAdded } +enum TrackSort { titleAsc, artistAsc, albumAsc, yearDesc, recentlyAdded, ratingDesc } + +/// Selectable app-wide color theme. [standard] is the original dark palette +/// (with its static-vs-dynamic accent toggle); [lavender] is a light theme +/// whose accent is locked to its lavender primary. See `theme/tokens.dart`. +enum AppTheme { standard, lavender } /// A selectable static accent color offered by the theme override (TODO #1). class AccentChoice { @@ -47,6 +52,7 @@ class AppSettings { this.albumSort = AlbumSort.nameAsc, this.trackSort = TrackSort.titleAsc, this.nowPlayingCassette = false, + this.appTheme = AppTheme.standard, }); /// Cap for live streaming, in kbps. 0 = original / no transcode. @@ -89,6 +95,15 @@ class AppSettings { /// album cover. final bool nowPlayingCassette; + /// The selected app-wide color theme. + final AppTheme appTheme; + + /// Whether the accent is pinned (never overridden by album-art extraction): + /// either the user enabled the static accent, or a theme that locks its + /// accent (e.g. [AppTheme.lavender]) is active. + bool get accentIsFixed => + useStaticAccent || appTheme == AppTheme.lavender; + /// Offered bitrate choices (kbps); 0 renders as "Original". static const List bitrateChoices = [0, 96, 128, 192, 256, 320]; @@ -118,7 +133,8 @@ class AppSettings { static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange - static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps'; + static String bitrateLabel(int rate) => + rate == 0 ? 'Original (native)' : '$rate kbps'; static String formatLabel(String? f) => f ?? 'Original'; static String browseModeLabel(BrowseMode m) => switch (m) { BrowseMode.artists => 'Artists', @@ -142,6 +158,11 @@ class AppSettings { TrackSort.albumAsc => 'Album', TrackSort.yearDesc => 'Year (newest)', TrackSort.recentlyAdded => 'Recently added', + TrackSort.ratingDesc => 'Rating (highest first)', + }; + static String themeLabel(AppTheme t) => switch (t) { + AppTheme.standard => 'Default', + AppTheme.lavender => 'Lavender', }; AppSettings copyWith({ @@ -157,6 +178,7 @@ class AppSettings { AlbumSort? albumSort, TrackSort? trackSort, bool? nowPlayingCassette, + AppTheme? appTheme, }) => AppSettings( streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate, @@ -173,6 +195,7 @@ class AppSettings { albumSort: albumSort ?? this.albumSort, trackSort: trackSort ?? this.trackSort, nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette, + appTheme: appTheme ?? this.appTheme, ); static const Object _unset = Object(); @@ -189,6 +212,7 @@ class AppSettings { 'albumSort': albumSort.name, 'trackSort': trackSort.name, 'nowPlayingCassette': nowPlayingCassette, + 'appTheme': appTheme.name, }; factory AppSettings.fromJson(Map j) => AppSettings( @@ -212,6 +236,8 @@ class AppSettings { trackSort: _enumByName(TrackSort.values, j['trackSort'] as String?) ?? TrackSort.titleAsc, nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false, + appTheme: _enumByName(AppTheme.values, j['appTheme'] as String?) ?? + AppTheme.standard, ); /// Serialize a color to a `#RRGGBB` hex string. @@ -316,6 +342,11 @@ class SettingsController extends StateNotifier { _persist(); } + void setAppTheme(AppTheme theme) { + state = state.copyWith(appTheme: theme); + _persist(); + } + Future _persist() async { try { final file = _file; diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index a33250f..02faf52 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -219,12 +219,12 @@ class _TabBar extends StatelessWidget { ); if (i < tabs.length - 1) { children.add( - const Text('|', style: TextStyle(color: TimbreColors.border)), + Text('|', style: TextStyle(color: TimbreColors.border)), ); } } return Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(top: BorderSide(color: TimbreColors.border)), ), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children), @@ -288,7 +288,7 @@ class _StatusBar extends ConsumerWidget { onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ), - child: const Padding( + child: Padding( padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.lg), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 54fd82d..bd1fb69 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -349,7 +349,9 @@ final visibleTracksProvider = Provider>((ref) { final filter = ref.watch(trackFilterProvider); final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); final songs = ref.watch(libraryIndexProvider).songs; - return applyTrackQuery(songs, filter, sort); + // Live ratings so the Rating sort/filter reacts to star changes immediately. + final ratings = ref.watch(favoritesProvider).ratings; + return applyTrackQuery(songs, filter, sort, ratings: ratings); }); /// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on @@ -564,8 +566,9 @@ final playbackProvider = coverArtUriFor: coverArtUriFor, serverKeyGetter: () => ref.read(serverKeyProvider), onArt: (artUri) async { - // Skip extraction entirely when the user has pinned a static accent. - if (ref.read(settingsProvider).useStaticAccent) return; + // Skip extraction entirely when the accent is pinned (static accent, or a + // theme that locks its accent like Lavender). + if (ref.read(settingsProvider).accentIsFixed) return; final color = await extractAccent(NetworkImage(artUri.toString())); if (color != null) ref.read(accentProvider.notifier).set(color); }, @@ -610,7 +613,7 @@ final remoteAccentSyncProvider = Provider((ref) { (s) => s.isAttached ? s.remoteState?.current?.coverArt : null, ), (prev, coverArt) async { - if (ref.read(settingsProvider).useStaticAccent) return; + if (ref.read(settingsProvider).accentIsFixed) return; if (coverArt == null) return; final client = ref.read(subsonicClientProvider); if (client == null) return; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 69c1c2b..69a3a01 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -9,16 +9,18 @@ import 'tokens.dart'; /// (a close analog to the terminal fonts in Timbre's screenshots). The [accent] /// is passed in so the theme rebuilds when album-art extraction changes it. ThemeData buildTimbreTheme(Color accent) { - final mono = GoogleFonts.jetBrainsMonoTextTheme( - ThemeData.dark().textTheme, - ).apply( + final brightness = TimbreColors.brightness; + final base = brightness == Brightness.light + ? ThemeData.light() + : ThemeData.dark(); + final mono = GoogleFonts.jetBrainsMonoTextTheme(base.textTheme).apply( bodyColor: TimbreColors.foreground, displayColor: TimbreColors.foreground, ); final scheme = ColorScheme.fromSeed( seedColor: accent, - brightness: Brightness.dark, + brightness: brightness, surface: TimbreColors.surface, ).copyWith( primary: accent, @@ -28,7 +30,7 @@ ThemeData buildTimbreTheme(Color accent) { return ThemeData( useMaterial3: true, - brightness: Brightness.dark, + brightness: brightness, scaffoldBackgroundColor: TimbreColors.background, canvasColor: TimbreColors.background, colorScheme: scheme, @@ -38,7 +40,7 @@ ThemeData buildTimbreTheme(Color accent) { splashFactory: NoSplash.splashFactory, highlightColor: Colors.transparent, // Keep chrome flat and quiet — the content (and accent) carry the look. - appBarTheme: const AppBarTheme( + appBarTheme: AppBarTheme( backgroundColor: TimbreColors.background, surfaceTintColor: Colors.transparent, elevation: 0, diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index 393ed59..02f6f64 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -1,36 +1,96 @@ import 'package:flutter/widgets.dart'; +/// A full color palette for one selectable app theme. Immutable; the app swaps +/// which instance is [TimbreColors._active] when the user changes theme. +class TimbrePalette { + const TimbrePalette({ + required this.brightness, + required this.background, + required this.surface, + required this.foreground, + required this.dimmed, + required this.border, + required this.borderActive, + required this.accentDefault, + }); + + final Brightness brightness; + final Color background; + final Color surface; + final Color foreground; + final Color dimmed; + final Color border; + final Color borderActive; + final Color accentDefault; + + /// The original near-black, low-contrast theme (the app's default). + static const TimbrePalette dark = TimbrePalette( + brightness: Brightness.dark, + background: Color(0xFF1A1A1A), + surface: Color(0xFF161616), + foreground: Color(0xFFD4D0C8), + dimmed: Color(0xFF5A5858), + border: Color(0xFF252525), + borderActive: Color(0xFF3A3A3A), + accentDefault: Color(0xFFFF8C00), + ); + + /// A light theme: lavender canvas, slightly-deeper-lavender panels, deep-indigo + /// text and accent. The accent is locked to [accentDefault] while this theme + /// is active (see `main.dart`). + static const TimbrePalette lavender = TimbrePalette( + brightness: Brightness.light, + background: Color(0xFFD3D3FF), // lavender canvas (primary) + surface: Color(0xFFC6C6F4), // slightly darker lavender panels + foreground: Color(0xFF2E2A45), // deep-indigo text + dimmed: Color(0xFF6A6688), // muted indigo-grey + border: Color(0xFFB9B9E8), // deeper-lavender hairline + borderActive: Color(0xFF2E2A45), // accent-toned active border + accentDefault: Color(0xFF2E2A45), // deep indigo (accent) + ); +} + /// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults /// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`). /// -/// The palette is intentionally near-black and low-contrast; the *accent* -/// is the one lively color and, in the `dynamic` theme, is extracted live -/// from the current album art (Phase 2). Everything else stays fixed. +/// The palette is intentionally near-black and low-contrast in the default +/// theme; the *accent* is the one lively color and, in the `dynamic` theme, is +/// extracted live from the current album art. The base colors are no longer +/// compile-time constants — they resolve through [_active] so a user-selected +/// theme (see [TimbrePalette]) recolors the whole app. `applyPalette` is called +/// once per build by `TimbreApp` before the theme is built. class TimbreColors { const TimbreColors._(); - /// App canvas — `background = #1a1a1a`. - static const Color background = Color(0xFF1A1A1A); + static TimbrePalette _active = TimbrePalette.dark; - /// Panel/surface fill — `surface = #161616` (slightly darker than canvas). - static const Color surface = Color(0xFF161616); + /// Swap the active palette. Called by `TimbreApp.build` from the persisted + /// theme setting; a re-keyed subtree remount then re-reads these getters. + static void applyPalette(TimbrePalette p) => _active = p; - /// Primary text — `foreground = #d4d0c8` (warm off-white, not pure white). - static const Color foreground = Color(0xFFD4D0C8); + /// Brightness of the active theme (drives `ThemeData`/`ColorScheme`). + static Brightness get brightness => _active.brightness; - /// Secondary/muted text — `dimmed = #5a5858`. - static const Color dimmed = Color(0xFF5A5858); + /// App canvas. + static Color get background => _active.background; - /// Hairline borders (inactive) — `border = #252525`. - static const Color border = Color(0xFF252525); + /// Panel/surface fill (slightly offset from the canvas). + static Color get surface => _active.surface; - /// Hairline borders when a pane is focused — `border_active`. - /// Timbre tints this toward the accent; we start with a lighter grey and - /// swap in the accent at runtime once art extraction lands. - static const Color borderActive = Color(0xFF3A3A3A); + /// Primary text. + static Color get foreground => _active.foreground; - /// Default accent — `accent = #ff8c00`. Overridden per-track in `dynamic`. - static const Color accentDefault = Color(0xFFFF8C00); + /// Secondary/muted text. + static Color get dimmed => _active.dimmed; + + /// Hairline borders (inactive). + static Color get border => _active.border; + + /// Hairline borders when a pane is focused. + static Color get borderActive => _active.borderActive; + + /// Default accent when no art-derived accent applies. + static Color get accentDefault => _active.accentDefault; } /// Spacing scale — dense, "player as instrument" rather than airy mobile cards. diff --git a/lib/widgets/cassette_view.dart b/lib/widgets/cassette_view.dart index c789aa1..3039892 100644 --- a/lib/widgets/cassette_view.dart +++ b/lib/widgets/cassette_view.dart @@ -134,14 +134,14 @@ class _LabelArt extends StatelessWidget { @override Widget build(BuildContext context) { if (artUri == null) { - return const ColoredBox(color: TimbreColors.surface); + return ColoredBox(color: TimbreColors.surface); } return Image.network( artUri!, key: ValueKey(artUri), fit: BoxFit.cover, // square art → wide label: crop the sides/top gaplessPlayback: true, - errorBuilder: (_, _, _) => const ColoredBox(color: TimbreColors.surface), + errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface), ); } } diff --git a/lib/widgets/hairline_panel.dart b/lib/widgets/hairline_panel.dart index e63e79c..ce81726 100644 --- a/lib/widgets/hairline_panel.dart +++ b/lib/widgets/hairline_panel.dart @@ -16,7 +16,7 @@ class HairlinePanel extends StatelessWidget { this.action, this.active = false, this.padding = const EdgeInsets.all(TimbreSpacing.lg), - this.backgroundColor = TimbreColors.background, + this.backgroundColor, }); /// Panel label, e.g. "Queue". Rendered uppercase-ish in mono. @@ -40,12 +40,13 @@ class HairlinePanel extends StatelessWidget { /// Color painted behind the title to "cut" the border. Must match whatever /// sits behind this panel (the canvas by default). - final Color backgroundColor; + final Color? backgroundColor; static const double _titleStraddle = 8; @override Widget build(BuildContext context) { + final bg = backgroundColor ?? TimbreColors.background; final borderColor = active ? TimbreColors.borderActive : TimbreColors.border; final titleColor = @@ -65,7 +66,7 @@ class HairlinePanel extends StatelessWidget { left: TimbreSpacing.lg, top: 0, child: Container( - color: backgroundColor, + color: bg, padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.md, ), @@ -83,7 +84,7 @@ class HairlinePanel extends StatelessWidget { if (trailing != null) TextSpan( text: ' $trailing', - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ), ], ), @@ -96,7 +97,7 @@ class HairlinePanel extends StatelessWidget { right: TimbreSpacing.md, top: 0, child: Container( - color: backgroundColor, + color: bg, padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), child: action, ), diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 9844118..11289eb 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -68,7 +68,7 @@ class MiniPlayer extends ConsumerWidget { current.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700, ), @@ -77,7 +77,7 @@ class MiniPlayer extends ConsumerWidget { current.artist ?? 'Unknown artist', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.dimmed), + style: TextStyle(color: TimbreColors.dimmed), ), ], ), @@ -95,12 +95,11 @@ class MiniPlayer extends ConsumerWidget { ); } - Widget _btn(IconData icon, VoidCallback onTap, - {Color color = TimbreColors.foreground}) { + Widget _btn(IconData icon, VoidCallback onTap, {Color? color}) { return IconButton( onPressed: onTap, visualDensity: VisualDensity.compact, - icon: Icon(icon, color: color, size: 24), + icon: Icon(icon, color: color ?? TimbreColors.foreground, size: 24), ); } } @@ -129,7 +128,7 @@ class _MiniProgress extends ConsumerWidget { class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override - Widget build(BuildContext context) => const Center( + Widget build(BuildContext context) => Center( child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 22), ); diff --git a/lib/widgets/toast.dart b/lib/widgets/toast.dart index 27d27fa..fd70db4 100644 --- a/lib/widgets/toast.dart +++ b/lib/widgets/toast.dart @@ -28,7 +28,7 @@ void showToast(BuildContext context, String message, {IconData? icon}) { Expanded( child: Text( message, - style: const TextStyle(color: TimbreColors.foreground), + style: TextStyle(color: TimbreColors.foreground), ), ), ], diff --git a/test/features_test.dart b/test/features_test.dart index c2612ea..20344c6 100644 --- a/test/features_test.dart +++ b/test/features_test.dart @@ -176,6 +176,7 @@ void main() { albumSort: AlbumSort.recentlyAdded, trackSort: TrackSort.yearDesc, nowPlayingCassette: true, + appTheme: AppTheme.lavender, ); final back = AppSettings.fromJson(s.toJson()); expect(back.useStaticAccent, isTrue); @@ -186,6 +187,7 @@ void main() { expect(back.albumSort, AlbumSort.recentlyAdded); expect(back.trackSort, TrackSort.yearDesc); expect(back.nowPlayingCassette, isTrue); + expect(back.appTheme, AppTheme.lavender); }); test('fromJson falls back to defaults for missing/unknown values', () { @@ -201,6 +203,7 @@ void main() { expect(back.albumSort, AlbumSort.nameAsc); expect(back.trackSort, TrackSort.titleAsc); expect(back.nowPlayingCassette, isFalse); + expect(back.appTheme, AppTheme.standard); // An unrecognised enum name also falls back rather than throwing. expect( AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode, @@ -533,5 +536,46 @@ void main() { songs, const BrowseFilter(genre: 'rock'), TrackSort.titleAsc); expect(out.map((s) => s.id), ['b', 'a']); }); + + test('track ratingDesc sorts highest first, unrated last, tie-break title', + () { + final songs = [ + song('low', title: 'A'), + song('high', title: 'B'), + song('unrated', title: 'C'), + song('tieB', title: 'Zed'), + song('tieA', title: 'Abe'), + ]; + final out = applyTrackQuery( + songs, + const BrowseFilter(), + TrackSort.ratingDesc, + ratings: {'low': 2, 'high': 5, 'tieA': 3, 'tieB': 3}, + ); + // 5, then the two 3s (title tie-break Abe s.id), ['high', 'tieA', 'tieB', 'low', 'unrated']); + }); + + test('track ratingDesc falls back to Song.userRating when not in map', () { + final songs = [ + Song(id: 'r4', title: 'A', userRating: 4), + Song(id: 'r1', title: 'B', userRating: 1), + ]; + final out = + applyTrackQuery(songs, const BrowseFilter(), TrackSort.ratingDesc); + expect(out.map((s) => s.id), ['r4', 'r1']); + }); + + test('track minRating filter keeps only tracks at or above the threshold', + () { + final songs = [song('a'), song('b'), song('c'), song('d')]; + final out = applyTrackQuery( + songs, + const BrowseFilter(minRating: 3), + TrackSort.titleAsc, + ratings: {'a': 5, 'b': 3, 'c': 2}, // d unrated (0) + ); + expect(out.map((s) => s.id), ['a', 'b']); + }); }); }