This commit is contained in:
Forrest 2026-08-05 21:57:06 -04:00
parent 2ce32cac4e
commit b6639fb1c9
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
/// 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<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].
/// Takes bare [Comparable] so both `int` (`Comparable<num>`) and `DateTime` work.
int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) {
@ -112,11 +134,15 @@ List<Album> applyAlbumQuery(
List<Song> applyTrackQuery(
List<Song> songs,
BrowseFilter filter,
TrackSort sort,
) {
TrackSort sort, {
Map<String, int> 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<Song> 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;
}

View file

@ -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<void> 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))),
);
}
}

View file

@ -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)),
],
),
),

View file

@ -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<String?> 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),
),

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)
_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)),
],
),

View file

@ -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<String>(
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<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,
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),
);
}

View file

@ -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),
),

View file

@ -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)),
),
],
),

View file

@ -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,

View file

@ -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)),
);

View file

@ -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),

View file

@ -61,7 +61,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
(_, _) => _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<NowPlayingScreen> {
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),
);

View file

@ -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<String>(
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<PlaylistDetailScreen> {
),
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<String>(
icon: const Icon(Icons.more_vert,
icon: Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {

View file

@ -32,9 +32,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
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<SearchScreen> {
),
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)),
);

View file

@ -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<int>(
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<int>(
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<bool>(
label: 'Static accent',
values: const [false, true],
selected: settings.useStaticAccent,
labelFor: (v) => v ? 'On' : 'Off',
onSelect: controller.setUseStaticAccent,
_ChoiceChips<AppTheme>(
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<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 _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<T> 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,

View file

@ -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<String>(
icon: const Icon(Icons.more_vert,
icon: Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {

View file

@ -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<int> 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<String, dynamic> 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<AppSettings> {
_persist();
}
void setAppTheme(AppTheme theme) {
state = state.copyWith(appTheme: theme);
_persist();
}
Future<void> _persist() async {
try {
final file = _file;

View file

@ -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,

View file

@ -349,7 +349,9 @@ final visibleTracksProvider = Provider<List<Song>>((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<void>((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;

View file

@ -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,

View file

@ -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.

View file

@ -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),
);
}
}

View file

@ -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,
),

View file

@ -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),
);

View file

@ -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),
),
),
],

View file

@ -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<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']);
});
});
}