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

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