updates
This commit is contained in:
parent
2ce32cac4e
commit
71e131cb88
26 changed files with 454 additions and 204 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue