This commit is contained in:
Forrest 2026-08-05 21:57:06 -04:00
parent 2ce32cac4e
commit 71e131cb88
26 changed files with 454 additions and 204 deletions

View file

@ -5,7 +5,7 @@ import '../subsonic/models.dart';
/// Held in `state/providers.dart` (not persisted) so the app never silently /// Held in `state/providers.dart` (not persisted) so the app never silently
/// reopens filtered. Sort order *is* persisted (see [AppSettings]). /// reopens filtered. Sort order *is* persisted (see [AppSettings]).
class BrowseFilter { 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". /// Case-insensitive genre match, or null for "all genres".
final String? genre; final String? genre;
@ -13,14 +13,27 @@ class BrowseFilter {
/// Exact release-year match, or null for "all years". /// Exact release-year match, or null for "all years".
final int? year; 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( BrowseFilter(
genre: identical(genre, _unset) ? this.genre : genre as String?, genre: identical(genre, _unset) ? this.genre : genre as String?,
year: identical(year, _unset) ? this.year : year as int?, year: identical(year, _unset) ? this.year : year as int?,
minRating:
identical(minRating, _unset) ? this.minRating : minRating as int?,
); );
static const Object _unset = Object(); static const Object _unset = Object();
@ -60,6 +73,15 @@ bool _genreMatches(String? itemGenre, String? filterGenre) {
int _byString(String? a, String? b) => int _byString(String? a, String? b) =>
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase()); (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<String, int> 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]. /// Compare where a null [a]/[b] always sorts *last*, regardless of [descending].
/// Takes bare [Comparable] so both `int` (`Comparable<num>`) and `DateTime` work. /// Takes bare [Comparable] so both `int` (`Comparable<num>`) and `DateTime` work.
int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) { int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) {
@ -112,11 +134,15 @@ List<Album> applyAlbumQuery(
List<Song> applyTrackQuery( List<Song> applyTrackQuery(
List<Song> songs, List<Song> songs,
BrowseFilter filter, BrowseFilter filter,
TrackSort sort, TrackSort sort, {
) { Map<String, int> ratings = const {},
}) {
final out = songs final out = songs
.where((s) => _genreMatches(s.genre, filter.genre)) .where((s) => _genreMatches(s.genre, filter.genre))
.where((s) => filter.year == null || s.year == filter.year) .where((s) => filter.year == null || s.year == filter.year)
.where((s) =>
filter.minRating == null ||
_effectiveRating(s, ratings) >= filter.minRating!)
.toList(); .toList();
switch (sort) { switch (sort) {
@ -144,6 +170,12 @@ List<Song> applyTrackQuery(
final c = _nullsLast(a.createdAt, b.createdAt, descending: true); final c = _nullsLast(a.createdAt, b.createdAt, descending: true);
return c != 0 ? c : _byString(a.title, b.title); 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; return out;
} }

View file

@ -10,6 +10,7 @@ import 'shell/app_shell.dart';
import 'state/providers.dart'; import 'state/providers.dart';
import 'theme/accent.dart'; import 'theme/accent.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'theme/tokens.dart';
import 'widgets/splash_screen.dart'; import 'widgets/splash_screen.dart';
Future<void> main() async { Future<void> main() async {
@ -38,12 +39,26 @@ class TimbreApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 settings = ref.watch(settingsProvider);
final accent = settings.useStaticAccent
? settings.staticAccentColor // Point the token getters at the selected theme's palette before the theme
: ref.watch(accentProvider); // (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 // Keep the accent synced with the remote track's art while acting as a
// remote (bug #6). Alive as long as the app is. // remote (bug #6). Alive as long as the app is.
ref.watch(remoteAccentSyncProvider); ref.watch(remoteAccentSyncProvider);
@ -52,8 +67,11 @@ class TimbreApp extends ConsumerWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTimbreTheme(accent), theme: buildTimbreTheme(accent),
// AppShell mounts under the splash and boots behind it; the splash fades // AppShell mounts under the splash and boots behind it; the splash fades
// out after a couple of seconds. // out after a couple of seconds. Keying AppShell by the theme forces a
home: const SplashGate(child: AppShell()), // 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))),
); );
} }
} }

View file

@ -50,7 +50,7 @@ class _AddTagSheet extends ConsumerWidget {
), ),
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
if (!connected) if (!connected)
const Padding( Padding(
padding: EdgeInsets.all(TimbreSpacing.xl), padding: EdgeInsets.all(TimbreSpacing.xl),
child: Text('Connect to a server to manage tags.', child: Text('Connect to a server to manage tags.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
@ -142,7 +142,7 @@ class _Tile extends StatelessWidget {
), ),
if (trailing != null) if (trailing != null)
Text(trailing!, Text(trailing!,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
], ],
), ),
), ),

View file

@ -49,7 +49,7 @@ class _AddToPlaylistSheet extends ConsumerWidget {
), ),
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
if (!connected) if (!connected)
const Padding( Padding(
padding: EdgeInsets.all(TimbreSpacing.xl), padding: EdgeInsets.all(TimbreSpacing.xl),
child: Text('Connect to a server to manage playlists.', child: Text('Connect to a server to manage playlists.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
@ -140,7 +140,7 @@ class _Tile extends StatelessWidget {
), ),
if (trailing != null) if (trailing != null)
Text(trailing!, Text(trailing!,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
], ],
), ),
), ),
@ -166,8 +166,8 @@ Future<String?> promptPlaylistName(
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Playlist name', hintText: 'Playlist name',
hintStyle: TextStyle(color: TimbreColors.dimmed), hintStyle: TextStyle(color: TimbreColors.dimmed),
), ),

View file

@ -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<int?>(
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) if (filter.isActive)
_ClearChip(onTap: () => ref.read(trackFilterProvider.notifier).state = _ClearChip(onTap: () => ref.read(trackFilterProvider.notifier).state =
const BrowseFilter()), const BrowseFilter()),
@ -250,11 +273,11 @@ class _ClearChip extends StatelessWidget {
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center, alignment: Alignment.center,
child: const Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.close, size: 14, color: TimbreColors.dimmed), Icon(Icons.close, size: 14, color: TimbreColors.dimmed),
SizedBox(width: TimbreSpacing.xs), const SizedBox(width: TimbreSpacing.xs),
Text('Clear', style: TextStyle(color: TimbreColors.dimmed)), Text('Clear', style: TextStyle(color: TimbreColors.dimmed)),
], ],
), ),

View file

@ -138,9 +138,9 @@ class _ModeSelector extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
label('Artists', BrowseMode.artists), label('Artists', BrowseMode.artists),
const Text('|', style: TextStyle(color: TimbreColors.border)), Text('|', style: TextStyle(color: TimbreColors.border)),
label('Albums', BrowseMode.albums), label('Albums', BrowseMode.albums),
const Text('|', style: TextStyle(color: TimbreColors.border)), Text('|', style: TextStyle(color: TimbreColors.border)),
label('Tracks', BrowseMode.tracks), label('Tracks', BrowseMode.tracks),
], ],
); );
@ -292,14 +292,14 @@ class _AlbumTile extends StatelessWidget {
album.name ?? 'Unknown album', album.name ?? 'Unknown album',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
), ),
if (album.artist != null) if (album.artist != null)
Text( Text(
album.artist!, album.artist!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12), color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
@ -311,7 +311,7 @@ class _AlbumTile extends StatelessWidget {
class _AlbumArtFallback extends StatelessWidget { class _AlbumArtFallback extends StatelessWidget {
const _AlbumArtFallback(); const _AlbumArtFallback();
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined, child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 32), color: TimbreColors.dimmed, size: 32),
); );
@ -354,7 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
children: [ children: [
const _Loading(), const _Loading(),
const SizedBox(height: TimbreSpacing.md), 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 onTap: index.building
? null ? null
: () => ref.read(libraryIndexProvider.notifier).refresh(), : () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: Text('↻ refresh', child: Text('↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)), style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
), ),
@ -439,7 +439,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
_confirmDownloadAll(context, visible); _confirmDownloadAll(context, visible);
} }
}, },
itemBuilder: (_) => const [ itemBuilder: (_) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'download-all', value: 'download-all',
child: Row( child: Row(
@ -454,8 +454,8 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
), ),
), ),
], ],
child: const Padding( child: Padding(
padding: EdgeInsets.all(TimbreSpacing.xs), padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon(Icons.more_vert, child: Icon(Icons.more_vert,
size: 18, color: TimbreColors.dimmed), size: 18, color: TimbreColors.dimmed),
), ),
@ -513,7 +513,7 @@ class _Action extends StatelessWidget {
children: [ children: [
Icon(icon, size: 16, color: TimbreColors.dimmed), Icon(icon, size: 16, color: TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.sm), 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( SizedBox(
width: 28, width: 28,
child: Text(leading!, child: Text(leading!,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Column( child: Column(
@ -721,14 +721,14 @@ class BrowseRow extends StatelessWidget {
title, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
), ),
if (subtitle != null) if (subtitle != null)
Text( Text(
subtitle!, subtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12), color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
@ -752,7 +752,7 @@ class BrowseRow extends StatelessWidget {
if (trailing != null) ...[ if (trailing != null) ...[
const SizedBox(width: TimbreSpacing.md), const SizedBox(width: TimbreSpacing.md),
Text(trailing!, Text(trailing!,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
], ],
if (onPlayNext != null) if (onPlayNext != null)
_RowIcon( _RowIcon(
@ -809,7 +809,7 @@ class _RowMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface, color: TimbreColors.surface,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: constraints:
@ -897,7 +897,7 @@ class _NotConnected extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const _Centered( return _Centered(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -940,7 +940,7 @@ class _ErrorText extends StatelessWidget {
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
); );
} }

View file

@ -49,7 +49,7 @@ class _ServerSheet extends ConsumerWidget {
), ),
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
if (conn.servers.isEmpty) if (conn.servers.isEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Text('No servers saved yet.', child: Text('No servers saved yet.',
@ -77,7 +77,7 @@ class _ServerSheet extends ConsumerWidget {
nav.pop(); nav.pop();
showConnectSheet(nav.context); showConnectSheet(nav.context);
}, },
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Row( child: Row(
@ -268,7 +268,7 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
onPressed: _busy ? null : _save, onPressed: _busy ? null : _save,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: TimbreColors.foreground, foregroundColor: TimbreColors.foreground,
side: const BorderSide(color: TimbreColors.border), side: BorderSide(color: TimbreColors.border),
shape: const RoundedRectangleBorder(), shape: const RoundedRectangleBorder(),
), ),
child: const Text('Save'), child: const Text('Save'),
@ -314,13 +314,13 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
keyboardType: keyboard, keyboardType: keyboard,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
hintText: hint, hintText: hint,
labelStyle: const TextStyle(color: TimbreColors.dimmed), labelStyle: TextStyle(color: TimbreColors.dimmed),
hintStyle: const TextStyle(color: TimbreColors.dimmed), hintStyle: TextStyle(color: TimbreColors.dimmed),
enabledBorder: const OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: TimbreColors.border), borderSide: BorderSide(color: TimbreColors.border),
), ),

View file

@ -189,8 +189,8 @@ class _Divider extends StatelessWidget {
const _Divider(); const _Divider();
@override @override
Widget build(BuildContext context) => const Padding( Widget build(BuildContext context) => Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.sm), horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.sm),
child: Divider(color: TimbreColors.border, height: 1), child: Divider(color: TimbreColors.border, height: 1),
); );
@ -219,7 +219,7 @@ class _Note extends StatelessWidget {
], ],
Flexible( Flexible(
child: Text(text, child: Text(text,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
], ],
), ),

View file

@ -28,14 +28,14 @@ class DownloadsScreen extends ConsumerWidget {
if (completed.isNotEmpty) if (completed.isNotEmpty)
TextButton( TextButton(
onPressed: () => _confirmClear(context, controller), onPressed: () => _confirmClear(context, controller),
child: const Text('Clear all', child: Text('Clear all',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
], ],
), ),
body: SafeArea( body: SafeArea(
child: (active.isEmpty && completed.isEmpty) child: (active.isEmpty && completed.isEmpty)
? const Center( ? Center(
child: Text('No downloads yet.', child: Text('No downloads yet.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
@ -65,7 +65,7 @@ class DownloadsScreen extends ConsumerWidget {
padding: padding:
const EdgeInsets.symmetric(vertical: TimbreSpacing.md), const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: completed.isEmpty child: completed.isEmpty
? const Padding( ? Padding(
padding: EdgeInsets.all(TimbreSpacing.lg), padding: EdgeInsets.all(TimbreSpacing.lg),
child: Text('Nothing saved for offline yet.', child: Text('Nothing saved for offline yet.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
@ -131,7 +131,7 @@ class _ActiveRow extends StatelessWidget {
Text(info.song.title ?? 'Untitled', Text(info.song.title ?? 'Untitled',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
if (failed) if (failed)
const Text('Failed', const Text('Failed',
@ -151,7 +151,7 @@ class _ActiveRow extends StatelessWidget {
failed failed
? '—' ? '—'
: (info.status == DownloadStatus.queued ? 'Queued' : ''), : (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', Text(info.song.title ?? 'Untitled',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
Text( Text(
[ [
info.song.artist, info.song.artist,
@ -199,7 +199,7 @@ class _SavedRow extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: style:
const TextStyle(color: TimbreColors.dimmed, fontSize: 12), TextStyle(color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
), ),
@ -207,7 +207,7 @@ class _SavedRow extends StatelessWidget {
InkWell( InkWell(
onTap: onRemove, onTap: onRemove,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const SizedBox( child: SizedBox(
width: TimbreSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.delete_outline, child: Icon(Icons.delete_outline,

View file

@ -28,11 +28,11 @@ class FavoritesScreen extends ConsumerWidget {
), ),
error: (e, _) => Center( error: (e, _) => Center(
child: Text('$e', child: Text('$e',
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
data: (s) { data: (s) {
if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) { if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) {
return const Center( return Center(
child: Text('No favorites yet.', child: Text('No favorites yet.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
); );

View file

@ -69,8 +69,8 @@ class HomeScreen extends ConsumerWidget {
), ),
if (recentAlbums.isEmpty && client == null) if (recentAlbums.isEmpty && client == null)
const Padding( Padding(
padding: EdgeInsets.only(top: TimbreSpacing.xl), padding: const EdgeInsets.only(top: TimbreSpacing.xl),
child: Text( child: Text(
'Connect to a server and start listening — your home fills in as you play.', 'Connect to a server and start listening — your home fills in as you play.',
style: TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
@ -116,11 +116,11 @@ class _HeroCard extends ConsumerWidget {
return _HeroShell( return _HeroShell(
accent: accent, accent: accent,
onTap: () => ref.read(selectedTabProvider.notifier).state = 1, onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
child: const Row( child: Row(
children: [ children: [
Icon(Icons.library_music_outlined, Icon(Icons.library_music_outlined,
color: TimbreColors.dimmed, size: 40), color: TimbreColors.dimmed, size: 40),
SizedBox(width: TimbreSpacing.lg), const SizedBox(width: TimbreSpacing.lg),
Expanded( Expanded(
child: Text('Browse your library to start listening', child: Text('Browse your library to start listening',
style: TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
@ -152,9 +152,9 @@ class _HeroCard extends ConsumerWidget {
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon( errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed)) Icons.album_outlined, color: TimbreColors.dimmed))
: const Icon(Icons.album_outlined, : Icon(Icons.album_outlined,
color: TimbreColors.dimmed), color: TimbreColors.dimmed),
), ),
), ),
@ -181,14 +181,14 @@ class _HeroCard extends ConsumerWidget {
Text(title, Text(title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
if (subtitle != null) if (subtitle != null)
Text(subtitle, Text(subtitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
if (hasCurrent) ...[ if (hasCurrent) ...[
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
const _HeroProgress(), const _HeroProgress(),
@ -283,7 +283,7 @@ class _ShelfHeader extends StatelessWidget {
return Row( return Row(
children: [ children: [
Text(title, Text(title,
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5)), letterSpacing: 0.5)),
@ -292,7 +292,7 @@ class _ShelfHeader extends StatelessWidget {
InkWell( InkWell(
onTap: onShuffle, onTap: onShuffle,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const SizedBox( child: SizedBox(
width: TimbreSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: 28, height: 28,
child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed), child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed),
@ -412,9 +412,9 @@ class _RandomBody extends StatelessWidget {
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon( errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed)) 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', Text(album.name ?? 'Unknown album',
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
const SizedBox(height: TimbreSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
@ -435,16 +435,16 @@ class _RandomBody extends StatelessWidget {
Text(album.artist!, Text(album.artist!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
if (album.year != null) if (album.year != null)
Text('${album.year}', Text('${album.year}',
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
if (album.genre != null) if (album.genre != null)
Text(album.genre!, Text(album.genre!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
@ -461,7 +461,7 @@ class _RandomSkeleton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: const SizedBox( child: SizedBox(
width: _RandomAlbum._size, width: _RandomAlbum._size,
height: _RandomAlbum._size, height: _RandomAlbum._size,
child: ColoredBox(color: TimbreColors.surface), child: ColoredBox(color: TimbreColors.surface),
@ -507,9 +507,9 @@ class _ArtCard extends StatelessWidget {
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon( errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed)) Icons.album_outlined, color: TimbreColors.dimmed))
: const Icon(Icons.album_outlined, : Icon(Icons.album_outlined,
color: TimbreColors.dimmed), color: TimbreColors.dimmed),
), ),
), ),
@ -518,13 +518,13 @@ class _ArtCard extends StatelessWidget {
Text(title, Text(title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: 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, width: _ArtCard._size,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: const SizedBox( child: SizedBox(
width: _ArtCard._size, width: _ArtCard._size,
height: _ArtCard._size, height: _ArtCard._size,
child: ColoredBox(color: TimbreColors.surface), child: ColoredBox(color: TimbreColors.surface),

View file

@ -61,7 +61,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
(_, _) => _seedFavorites()); (_, _) => _seedFavorites());
if (current == null) { if (current == null) {
return const Center( return Center(
child: Text('Nothing playing.', child: Text('Nothing playing.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
); );
@ -84,8 +84,8 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
const SizedBox(height: TimbreSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
_Transport(state: state, ref: ref, accent: accent), _Transport(state: state, ref: ref, accent: accent),
if (!state.supported) if (!state.supported)
const Padding( Padding(
padding: EdgeInsets.only(top: TimbreSpacing.sm), padding: const EdgeInsets.only(top: TimbreSpacing.sm),
child: Text( child: Text(
'Audio output unavailable on this platform — test on Android/iOS.', 'Audio output unavailable on this platform — test on Android/iOS.',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
@ -266,10 +266,12 @@ class _QueuePanel extends ConsumerStatefulWidget {
class _QueuePanelState extends ConsumerState<_QueuePanel> { class _QueuePanelState extends ConsumerState<_QueuePanel> {
/// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus /// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus
/// the [TimbreSpacing.xs] vertical padding above and below it. Pinning the /// the [TimbreSpacing.xs] vertical padding above and below it, with an extra
/// extent lets us scroll to a row by index without measuring. /// [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 = static const double _rowExtent =
TimbreSpacing.minTouchTarget + TimbreSpacing.xs * 2; TimbreSpacing.minTouchTarget + TimbreSpacing.md + TimbreSpacing.xs * 2;
final ScrollController _controller = ScrollController(); final ScrollController _controller = ScrollController();
bool _didInitialScroll = false; bool _didInitialScroll = false;
@ -358,28 +360,45 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
style: const TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
), ),
), ),
Expanded( Expanded(
child: Text( child: Column(
song.title ?? 'Untitled', mainAxisSize: MainAxisSize.min,
maxLines: 1, mainAxisAlignment: MainAxisAlignment.center,
overflow: TextOverflow.ellipsis, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
color: titleColor, Text(
fontWeight: song.title ?? 'Untitled',
isCurrent ? FontWeight.w700 : FontWeight.w400, 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), Text(_fmt(song.duration),
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
InkWell( InkWell(
onTap: () => onTap: () =>
ref.read(playbackCommandsProvider).removeAt(i), ref.read(playbackCommandsProvider).removeAt(i),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const SizedBox( child: SizedBox(
width: TimbreSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close, child: Icon(Icons.close,
@ -520,8 +539,8 @@ class _FavRating extends ConsumerWidget {
InkWell( InkWell(
onTap: () => showAddToPlaylistSheet(context, songs: [song]), onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const Padding( child: Padding(
padding: EdgeInsets.all(TimbreSpacing.sm), padding: const EdgeInsets.all(TimbreSpacing.sm),
child: Icon(Icons.playlist_add, child: Icon(Icons.playlist_add,
size: 22, color: TimbreColors.dimmed), size: 22, color: TimbreColors.dimmed),
), ),
@ -623,9 +642,9 @@ class _InfoStrip extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)), style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist', Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty) 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( return Row(
children: [ children: [
Text(_fmtDur(position), Text(_fmtDur(position),
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: TimbreSpacing.md), const SizedBox(width: TimbreSpacing.md),
Expanded( Expanded(
child: BlockProgressBar(progress: progress, cells: 28, height: 18), child: BlockProgressBar(progress: progress, cells: 28, height: 18),
), ),
const SizedBox(width: TimbreSpacing.md), const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(duration), 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, Widget _btn(IconData icon, VoidCallback onTap,
{Color color = TimbreColors.foreground, double size = 28}) { {Color? color, double size = 28}) {
return IconButton( return IconButton(
onPressed: onTap, 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 { class _ArtFallback extends StatelessWidget {
const _ArtFallback(); const _ArtFallback();
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined, child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 48), color: TimbreColors.dimmed, size: 48),
); );

View file

@ -55,7 +55,7 @@ class PlaylistsScreen extends ConsumerWidget {
? 'No playlists yet. Tap + to create one.' ? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.', : 'Connect to a server to see playlists.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
), ),
) )
: Column( : Column(
@ -206,7 +206,7 @@ class _PlaylistRow extends StatelessWidget {
padding: const EdgeInsets.only(left: TimbreSpacing.lg), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ 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), const SizedBox(width: TimbreSpacing.md),
Expanded( Expanded(
child: Column( child: Column(
@ -219,7 +219,7 @@ class _PlaylistRow extends StatelessWidget {
child: Text(name, child: Text(name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground)), color: TimbreColors.foreground)),
), ),
if (badge != null) ...[ if (badge != null) ...[
@ -230,14 +230,14 @@ class _PlaylistRow extends StatelessWidget {
), ),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),
if (hasMenu) if (hasMenu)
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed), size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface, color: TimbreColors.surface,
onSelected: (v) { onSelected: (v) {
@ -375,12 +375,12 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
), ),
body: SafeArea( body: SafeArea(
child: detail == null child: detail == null
? const Center( ? Center(
child: Text('Loading…', child: Text('Loading…',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
: songs.isEmpty : songs.isEmpty
? const Center( ? Center(
child: Text('This playlist is empty.', child: Text('This playlist is empty.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
@ -454,7 +454,7 @@ class _TrackRow extends StatelessWidget {
SizedBox( SizedBox(
width: 28, width: 28,
child: Text('$index', child: Text('$index',
style: const TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Column( child: Column(
@ -464,18 +464,18 @@ class _TrackRow extends StatelessWidget {
Text(song.title ?? 'Untitled', Text(song.title ?? 'Untitled',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
if (song.artist != null) if (song.artist != null)
Text(song.artist!, Text(song.artist!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed), size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface, color: TimbreColors.surface,
onSelected: (v) { onSelected: (v) {

View file

@ -32,9 +32,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
controller: _controller, controller: _controller,
autofocus: true, autofocus: true,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
cursorColor: accent, cursorColor: accent,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Search artists, albums, songs…', hintText: 'Search artists, albums, songs…',
hintStyle: TextStyle(color: TimbreColors.dimmed), hintStyle: TextStyle(color: TimbreColors.dimmed),
border: InputBorder.none, border: InputBorder.none,
@ -44,7 +44,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
), ),
body: SafeArea( body: SafeArea(
child: _query.trim().isEmpty child: _query.trim().isEmpty
? const Center( ? Center(
child: Text('Type and press search.', child: Text('Type and press search.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
@ -71,11 +71,11 @@ class _Results extends ConsumerWidget {
), ),
), ),
error: (e, _) => Center( error: (e, _) => Center(
child: Text('$e', style: const TextStyle(color: TimbreColors.dimmed)), child: Text('$e', style: TextStyle(color: TimbreColors.dimmed)),
), ),
data: (r) { data: (r) {
if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) { if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) {
return const Center( return Center(
child: Text('No results.', child: Text('No results.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
); );

View file

@ -35,7 +35,10 @@ class SettingsScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const _Caption( 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), const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>( _ChoiceChips<int>(
label: 'Max bitrate', label: 'Max bitrate',
@ -56,7 +59,8 @@ class SettingsScreen extends ConsumerWidget {
children: [ children: [
const _Caption( const _Caption(
'Quality used when saving tracks for offline playback. ' '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), const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>( _ChoiceChips<int>(
label: 'Max bitrate', label: 'Max bitrate',
@ -95,25 +99,39 @@ class SettingsScreen extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const _Caption( const _Caption('Choose the overall color theme of the app.'),
'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), const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>( _ChoiceChips<AppTheme>(
label: 'Static accent', label: 'Theme',
values: const [false, true], values: AppTheme.values,
selected: settings.useStaticAccent, selected: settings.appTheme,
labelFor: (v) => v ? 'On' : 'Off', labelFor: AppSettings.themeLabel,
onSelect: controller.setUseStaticAccent, 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), const SizedBox(height: TimbreSpacing.lg),
_ColorChips( const _Caption(
label: 'Accent color', 'By default the accent color is drawn from the playing '
values: AppSettings.accentChoices, 'album art. Turn on a static accent to pin one color.'),
selected: settings.staticAccentColor, const SizedBox(height: TimbreSpacing.md),
onSelect: controller.setStaticAccentColor, _ChoiceChips<bool>(
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 SizedBox(height: TimbreSpacing.lg),
const _Caption( const _Caption(
@ -184,7 +202,7 @@ class SettingsScreen extends ConsumerWidget {
'one is connected at a time.'), 'one is connected at a time.'),
const SizedBox(height: TimbreSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
if (conn.servers.isEmpty) if (conn.servers.isEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm), padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Text('No servers saved yet.', child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
@ -206,7 +224,7 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: TimbreSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
InkWell( InkWell(
onTap: () => showConnectSheet(context), onTap: () => showConnectSheet(context),
child: const Padding( child: Padding(
padding: padding:
EdgeInsets.symmetric(vertical: TimbreSpacing.md), EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Row( child: Row(
@ -285,13 +303,13 @@ class _ServerManageRow extends StatelessWidget {
IconButton( IconButton(
tooltip: 'Edit', tooltip: 'Edit',
onPressed: onEdit, onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed), icon: Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
), ),
IconButton( IconButton(
tooltip: 'Delete', tooltip: 'Delete',
onPressed: onDelete, onPressed: onDelete,
icon: 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 @override
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
text, text,
style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12), style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
); );
} }
@ -331,7 +349,7 @@ class _ChoiceChips<T> extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)), Text(label, style: TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
Wrap( Wrap(
spacing: TimbreSpacing.sm, spacing: TimbreSpacing.sm,
@ -415,7 +433,7 @@ class _Stepper extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)), Text(label, style: TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
Row( Row(
children: [ children: [
@ -432,7 +450,7 @@ class _Stepper extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
'$value', '$value',
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -507,7 +525,7 @@ class _ColorChips extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)), Text(label, style: TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
Wrap( Wrap(
spacing: TimbreSpacing.sm, spacing: TimbreSpacing.sm,

View file

@ -50,7 +50,7 @@ class TagsScreen extends ConsumerWidget {
'from their ⋮ menu.' 'from their ⋮ menu.'
: 'Connect to a server to see tags.', : 'Connect to a server to see tags.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
), ),
) )
: ListView.builder( : ListView.builder(
@ -145,7 +145,7 @@ class _TagRow extends StatelessWidget {
padding: const EdgeInsets.only(left: TimbreSpacing.lg), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ 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), const SizedBox(width: TimbreSpacing.md),
Expanded( Expanded(
child: Column( child: Column(
@ -155,17 +155,17 @@ class _TagRow extends StatelessWidget {
Text(name, Text(name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
style: const TextStyle( style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),
if (onRename != null || onDelete != null) if (onRename != null || onDelete != null)
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed), size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface, color: TimbreColors.surface,
onSelected: (v) { onSelected: (v) {

View file

@ -19,7 +19,12 @@ enum SearchMode { discovery, standard }
enum AlbumSort { nameAsc, artistAsc, yearDesc, yearAsc, recentlyAdded } enum AlbumSort { nameAsc, artistAsc, yearDesc, yearAsc, recentlyAdded }
/// Ordering applied to the Tracks browse list (filtered client-side). /// 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). /// A selectable static accent color offered by the theme override (TODO #1).
class AccentChoice { class AccentChoice {
@ -47,6 +52,7 @@ class AppSettings {
this.albumSort = AlbumSort.nameAsc, this.albumSort = AlbumSort.nameAsc,
this.trackSort = TrackSort.titleAsc, this.trackSort = TrackSort.titleAsc,
this.nowPlayingCassette = false, this.nowPlayingCassette = false,
this.appTheme = AppTheme.standard,
}); });
/// Cap for live streaming, in kbps. 0 = original / no transcode. /// Cap for live streaming, in kbps. 0 = original / no transcode.
@ -89,6 +95,15 @@ class AppSettings {
/// album cover. /// album cover.
final bool nowPlayingCassette; 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". /// Offered bitrate choices (kbps); 0 renders as "Original".
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320]; static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
@ -118,7 +133,8 @@ class AppSettings {
static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange 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 formatLabel(String? f) => f ?? 'Original';
static String browseModeLabel(BrowseMode m) => switch (m) { static String browseModeLabel(BrowseMode m) => switch (m) {
BrowseMode.artists => 'Artists', BrowseMode.artists => 'Artists',
@ -142,6 +158,11 @@ class AppSettings {
TrackSort.albumAsc => 'Album', TrackSort.albumAsc => 'Album',
TrackSort.yearDesc => 'Year (newest)', TrackSort.yearDesc => 'Year (newest)',
TrackSort.recentlyAdded => 'Recently added', TrackSort.recentlyAdded => 'Recently added',
TrackSort.ratingDesc => 'Rating (highest first)',
};
static String themeLabel(AppTheme t) => switch (t) {
AppTheme.standard => 'Default',
AppTheme.lavender => 'Lavender',
}; };
AppSettings copyWith({ AppSettings copyWith({
@ -157,6 +178,7 @@ class AppSettings {
AlbumSort? albumSort, AlbumSort? albumSort,
TrackSort? trackSort, TrackSort? trackSort,
bool? nowPlayingCassette, bool? nowPlayingCassette,
AppTheme? appTheme,
}) => }) =>
AppSettings( AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate, streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
@ -173,6 +195,7 @@ class AppSettings {
albumSort: albumSort ?? this.albumSort, albumSort: albumSort ?? this.albumSort,
trackSort: trackSort ?? this.trackSort, trackSort: trackSort ?? this.trackSort,
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette, nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
appTheme: appTheme ?? this.appTheme,
); );
static const Object _unset = Object(); static const Object _unset = Object();
@ -189,6 +212,7 @@ class AppSettings {
'albumSort': albumSort.name, 'albumSort': albumSort.name,
'trackSort': trackSort.name, 'trackSort': trackSort.name,
'nowPlayingCassette': nowPlayingCassette, 'nowPlayingCassette': nowPlayingCassette,
'appTheme': appTheme.name,
}; };
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings( factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
@ -212,6 +236,8 @@ class AppSettings {
trackSort: _enumByName(TrackSort.values, j['trackSort'] as String?) ?? trackSort: _enumByName(TrackSort.values, j['trackSort'] as String?) ??
TrackSort.titleAsc, TrackSort.titleAsc,
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false, nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
appTheme: _enumByName(AppTheme.values, j['appTheme'] as String?) ??
AppTheme.standard,
); );
/// Serialize a color to a `#RRGGBB` hex string. /// Serialize a color to a `#RRGGBB` hex string.
@ -316,6 +342,11 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist(); _persist();
} }
void setAppTheme(AppTheme theme) {
state = state.copyWith(appTheme: theme);
_persist();
}
Future<void> _persist() async { Future<void> _persist() async {
try { try {
final file = _file; final file = _file;

View file

@ -219,12 +219,12 @@ class _TabBar extends StatelessWidget {
); );
if (i < tabs.length - 1) { if (i < tabs.length - 1) {
children.add( children.add(
const Text('|', style: TextStyle(color: TimbreColors.border)), Text('|', style: TextStyle(color: TimbreColors.border)),
); );
} }
} }
return Container( return Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
border: Border(top: BorderSide(color: TimbreColors.border)), border: Border(top: BorderSide(color: TimbreColors.border)),
), ),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children),
@ -288,7 +288,7 @@ class _StatusBar extends ConsumerWidget {
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
), ),
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.lg), padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View file

@ -349,7 +349,9 @@ final visibleTracksProvider = Provider<List<Song>>((ref) {
final filter = ref.watch(trackFilterProvider); final filter = ref.watch(trackFilterProvider);
final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
final songs = ref.watch(libraryIndexProvider).songs; 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 /// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
@ -564,8 +566,9 @@ final playbackProvider =
coverArtUriFor: coverArtUriFor, coverArtUriFor: coverArtUriFor,
serverKeyGetter: () => ref.read(serverKeyProvider), serverKeyGetter: () => ref.read(serverKeyProvider),
onArt: (artUri) async { onArt: (artUri) async {
// Skip extraction entirely when the user has pinned a static accent. // Skip extraction entirely when the accent is pinned (static accent, or a
if (ref.read(settingsProvider).useStaticAccent) return; // theme that locks its accent like Lavender).
if (ref.read(settingsProvider).accentIsFixed) return;
final color = await extractAccent(NetworkImage(artUri.toString())); final color = await extractAccent(NetworkImage(artUri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color); if (color != null) ref.read(accentProvider.notifier).set(color);
}, },
@ -610,7 +613,7 @@ final remoteAccentSyncProvider = Provider<void>((ref) {
(s) => s.isAttached ? s.remoteState?.current?.coverArt : null, (s) => s.isAttached ? s.remoteState?.current?.coverArt : null,
), ),
(prev, coverArt) async { (prev, coverArt) async {
if (ref.read(settingsProvider).useStaticAccent) return; if (ref.read(settingsProvider).accentIsFixed) return;
if (coverArt == null) return; if (coverArt == null) return;
final client = ref.read(subsonicClientProvider); final client = ref.read(subsonicClientProvider);
if (client == null) return; if (client == null) return;

View file

@ -9,16 +9,18 @@ import 'tokens.dart';
/// (a close analog to the terminal fonts in Timbre's screenshots). The [accent] /// (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. /// is passed in so the theme rebuilds when album-art extraction changes it.
ThemeData buildTimbreTheme(Color accent) { ThemeData buildTimbreTheme(Color accent) {
final mono = GoogleFonts.jetBrainsMonoTextTheme( final brightness = TimbreColors.brightness;
ThemeData.dark().textTheme, final base = brightness == Brightness.light
).apply( ? ThemeData.light()
: ThemeData.dark();
final mono = GoogleFonts.jetBrainsMonoTextTheme(base.textTheme).apply(
bodyColor: TimbreColors.foreground, bodyColor: TimbreColors.foreground,
displayColor: TimbreColors.foreground, displayColor: TimbreColors.foreground,
); );
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: accent, seedColor: accent,
brightness: Brightness.dark, brightness: brightness,
surface: TimbreColors.surface, surface: TimbreColors.surface,
).copyWith( ).copyWith(
primary: accent, primary: accent,
@ -28,7 +30,7 @@ ThemeData buildTimbreTheme(Color accent) {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: brightness,
scaffoldBackgroundColor: TimbreColors.background, scaffoldBackgroundColor: TimbreColors.background,
canvasColor: TimbreColors.background, canvasColor: TimbreColors.background,
colorScheme: scheme, colorScheme: scheme,
@ -38,7 +40,7 @@ ThemeData buildTimbreTheme(Color accent) {
splashFactory: NoSplash.splashFactory, splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
// Keep chrome flat and quiet — the content (and accent) carry the look. // Keep chrome flat and quiet — the content (and accent) carry the look.
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: TimbreColors.background, backgroundColor: TimbreColors.background,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,

View file

@ -1,36 +1,96 @@
import 'package:flutter/widgets.dart'; 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 /// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults
/// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`). /// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`).
/// ///
/// The palette is intentionally near-black and low-contrast; the *accent* /// The palette is intentionally near-black and low-contrast in the default
/// is the one lively color and, in the `dynamic` theme, is extracted live /// theme; the *accent* is the one lively color and, in the `dynamic` theme, is
/// from the current album art (Phase 2). Everything else stays fixed. /// 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 { class TimbreColors {
const TimbreColors._(); const TimbreColors._();
/// App canvas — `background = #1a1a1a`. static TimbrePalette _active = TimbrePalette.dark;
static const Color background = Color(0xFF1A1A1A);
/// Panel/surface fill — `surface = #161616` (slightly darker than canvas). /// Swap the active palette. Called by `TimbreApp.build` from the persisted
static const Color surface = Color(0xFF161616); /// 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). /// Brightness of the active theme (drives `ThemeData`/`ColorScheme`).
static const Color foreground = Color(0xFFD4D0C8); static Brightness get brightness => _active.brightness;
/// Secondary/muted text — `dimmed = #5a5858`. /// App canvas.
static const Color dimmed = Color(0xFF5A5858); static Color get background => _active.background;
/// Hairline borders (inactive) — `border = #252525`. /// Panel/surface fill (slightly offset from the canvas).
static const Color border = Color(0xFF252525); static Color get surface => _active.surface;
/// Hairline borders when a pane is focused — `border_active`. /// Primary text.
/// Timbre tints this toward the accent; we start with a lighter grey and static Color get foreground => _active.foreground;
/// swap in the accent at runtime once art extraction lands.
static const Color borderActive = Color(0xFF3A3A3A);
/// Default accent — `accent = #ff8c00`. Overridden per-track in `dynamic`. /// Secondary/muted text.
static const Color accentDefault = Color(0xFFFF8C00); 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. /// Spacing scale — dense, "player as instrument" rather than airy mobile cards.

View file

@ -134,14 +134,14 @@ class _LabelArt extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (artUri == null) { if (artUri == null) {
return const ColoredBox(color: TimbreColors.surface); return ColoredBox(color: TimbreColors.surface);
} }
return Image.network( return Image.network(
artUri!, artUri!,
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, // square art → wide label: crop the sides/top fit: BoxFit.cover, // square art → wide label: crop the sides/top
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => const ColoredBox(color: TimbreColors.surface), errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface),
); );
} }
} }

View file

@ -16,7 +16,7 @@ class HairlinePanel extends StatelessWidget {
this.action, this.action,
this.active = false, this.active = false,
this.padding = const EdgeInsets.all(TimbreSpacing.lg), this.padding = const EdgeInsets.all(TimbreSpacing.lg),
this.backgroundColor = TimbreColors.background, this.backgroundColor,
}); });
/// Panel label, e.g. "Queue". Rendered uppercase-ish in mono. /// 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 /// Color painted behind the title to "cut" the border. Must match whatever
/// sits behind this panel (the canvas by default). /// sits behind this panel (the canvas by default).
final Color backgroundColor; final Color? backgroundColor;
static const double _titleStraddle = 8; static const double _titleStraddle = 8;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bg = backgroundColor ?? TimbreColors.background;
final borderColor = final borderColor =
active ? TimbreColors.borderActive : TimbreColors.border; active ? TimbreColors.borderActive : TimbreColors.border;
final titleColor = final titleColor =
@ -65,7 +66,7 @@ class HairlinePanel extends StatelessWidget {
left: TimbreSpacing.lg, left: TimbreSpacing.lg,
top: 0, top: 0,
child: Container( child: Container(
color: backgroundColor, color: bg,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.md, horizontal: TimbreSpacing.md,
), ),
@ -83,7 +84,7 @@ class HairlinePanel extends StatelessWidget {
if (trailing != null) if (trailing != null)
TextSpan( TextSpan(
text: ' $trailing', text: ' $trailing',
style: const TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
), ),
], ],
), ),
@ -96,7 +97,7 @@ class HairlinePanel extends StatelessWidget {
right: TimbreSpacing.md, right: TimbreSpacing.md,
top: 0, top: 0,
child: Container( child: Container(
color: backgroundColor, color: bg,
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: action, child: action,
), ),

View file

@ -68,7 +68,7 @@ class MiniPlayer extends ConsumerWidget {
current.title ?? 'Untitled', current.title ?? 'Untitled',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: TimbreColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -77,7 +77,7 @@ class MiniPlayer extends ConsumerWidget {
current.artist ?? 'Unknown artist', current.artist ?? 'Unknown artist',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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, Widget _btn(IconData icon, VoidCallback onTap, {Color? color}) {
{Color color = TimbreColors.foreground}) {
return IconButton( return IconButton(
onPressed: onTap, onPressed: onTap,
visualDensity: VisualDensity.compact, 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 { class _ArtFallback extends StatelessWidget {
const _ArtFallback(); const _ArtFallback();
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined, child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 22), color: TimbreColors.dimmed, size: 22),
); );

View file

@ -28,7 +28,7 @@ void showToast(BuildContext context, String message, {IconData? icon}) {
Expanded( Expanded(
child: Text( child: Text(
message, message,
style: const TextStyle(color: TimbreColors.foreground), style: TextStyle(color: TimbreColors.foreground),
), ),
), ),
], ],

View file

@ -176,6 +176,7 @@ void main() {
albumSort: AlbumSort.recentlyAdded, albumSort: AlbumSort.recentlyAdded,
trackSort: TrackSort.yearDesc, trackSort: TrackSort.yearDesc,
nowPlayingCassette: true, nowPlayingCassette: true,
appTheme: AppTheme.lavender,
); );
final back = AppSettings.fromJson(s.toJson()); final back = AppSettings.fromJson(s.toJson());
expect(back.useStaticAccent, isTrue); expect(back.useStaticAccent, isTrue);
@ -186,6 +187,7 @@ void main() {
expect(back.albumSort, AlbumSort.recentlyAdded); expect(back.albumSort, AlbumSort.recentlyAdded);
expect(back.trackSort, TrackSort.yearDesc); expect(back.trackSort, TrackSort.yearDesc);
expect(back.nowPlayingCassette, isTrue); expect(back.nowPlayingCassette, isTrue);
expect(back.appTheme, AppTheme.lavender);
}); });
test('fromJson falls back to defaults for missing/unknown values', () { test('fromJson falls back to defaults for missing/unknown values', () {
@ -201,6 +203,7 @@ void main() {
expect(back.albumSort, AlbumSort.nameAsc); expect(back.albumSort, AlbumSort.nameAsc);
expect(back.trackSort, TrackSort.titleAsc); expect(back.trackSort, TrackSort.titleAsc);
expect(back.nowPlayingCassette, isFalse); expect(back.nowPlayingCassette, isFalse);
expect(back.appTheme, AppTheme.standard);
// An unrecognised enum name also falls back rather than throwing. // An unrecognised enum name also falls back rather than throwing.
expect( expect(
AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode, AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode,
@ -533,5 +536,46 @@ void main() {
songs, const BrowseFilter(genre: 'rock'), TrackSort.titleAsc); songs, const BrowseFilter(genre: 'rock'), TrackSort.titleAsc);
expect(out.map((s) => s.id), ['b', 'a']); 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<Zed), then 2, then unrated (0).
expect(out.map((s) => 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']);
});
}); });
} }