offline updates and playhead fix

This commit is contained in:
Forrest 2026-08-16 12:46:30 -04:00
parent 7a199fe4df
commit 6663330260
14 changed files with 1673 additions and 545 deletions

View file

@ -5,6 +5,7 @@ import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/art_image.dart';
import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_tag_sheet.dart';
@ -24,8 +25,9 @@ class BrowserScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final mode = ref.watch(browseModeProvider);
final offline = ref.watch(subsonicClientProvider) == null;
final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(
@ -44,9 +46,9 @@ class BrowserScreen extends ConsumerWidget {
_Action(
icon: Icons.search,
label: 'Search',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SearchScreen()),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchScreen())),
),
_Action(
icon: Icons.favorite_border,
@ -65,9 +67,9 @@ class BrowserScreen extends ConsumerWidget {
_Action(
icon: Icons.label_outline,
label: 'Tags',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TagsScreen()),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const TagsScreen())),
),
_Action(
icon: Icons.download,
@ -82,7 +84,7 @@ class BrowserScreen extends ConsumerWidget {
_ModeSelector(mode: mode),
const SizedBox(height: TimbreSpacing.lg),
Expanded(
child: client == null
child: offline && !hasDownloads
? const HairlinePanel(
title: 'Browse',
active: true,
@ -115,8 +117,9 @@ class _ModeSelector extends ConsumerWidget {
return InkWell(
onTap: () => ref.read(browseModeProvider.notifier).state = m,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget,
),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center,
child: Text(
@ -124,8 +127,9 @@ class _ModeSelector extends ConsumerWidget {
style: TextStyle(
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
decoration: active
? TextDecoration.underline
: TextDecoration.none,
decorationColor: accent,
decorationThickness: 2,
),
@ -191,7 +195,6 @@ class _AlbumsPanel extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(visibleAlbumsProvider);
final filter = ref.watch(albumFilterProvider);
final client = ref.watch(subsonicClientProvider);
return HairlinePanel(
title: 'Albums',
active: true,
@ -215,18 +218,20 @@ class _AlbumsPanel extends ConsumerWidget {
Expanded(
child: list.isEmpty
? _Centered(
child: _ErrorText(filter.isActive
? 'No albums match these filters.'
: 'No albums on this server.'),
child: _ErrorText(
filter.isActive
? 'No albums match these filters.'
: 'No albums on this server.',
),
)
: LayoutBuilder(
builder: (context, constraints) {
final cols =
(constraints.maxWidth / 180).floor().clamp(2, 6);
final cols = (constraints.maxWidth / 180)
.floor()
.clamp(2, 6);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.md,
@ -237,13 +242,11 @@ class _AlbumsPanel extends ConsumerWidget {
itemCount: list.length,
itemBuilder: (context, i) => _AlbumTile(
album: list[i],
artUri:
(client != null && list[i].coverArt != null)
? client
.coverArtUri(list[i].coverArt!,
size: 300)
.toString()
: null,
artUri: resolveArtUriW(
ref,
coverArt: list[i].coverArt,
size: 300,
)?.toString(),
),
);
},
@ -266,25 +269,18 @@ class _AlbumTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
)
: const _AlbumArtFallback(),
child: ArtImage(
artUri,
fit: BoxFit.cover,
placeholder: const _AlbumArtFallback(),
),
),
const SizedBox(height: TimbreSpacing.xs),
@ -299,8 +295,7 @@ class _AlbumTile extends StatelessWidget {
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12),
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
@ -312,9 +307,8 @@ class _AlbumArtFallback extends StatelessWidget {
const _AlbumArtFallback();
@override
Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 32),
);
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32),
);
}
/// Flat alphabetical list of every song, backed by the crawled+cached library
@ -331,7 +325,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
// Offline the tracks come from the provider fallback (downloaded songs);
// only crawl the live library when we actually have a server connection.
if (ref.read(subsonicClientProvider) != null) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
}
});
}
@ -340,10 +338,12 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
final index = ref.watch(libraryIndexProvider);
final visible = ref.watch(visibleTracksProvider);
final playback = ref.read(playbackCommandsProvider);
final client = ref.watch(subsonicClientProvider);
final offline = ref.watch(subsonicClientProvider) == null;
final Widget body;
if (index.building) {
// Offline the crawled index is empty; `visible` is backed by the downloaded
// songs instead, so skip the online-only indexing / empty-index branches.
if (!offline && index.building) {
final total = index.total;
final label = total > 0
? 'Indexing ${index.done}/$total albums…'
@ -358,11 +358,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
],
),
);
} else if (index.songs.isEmpty) {
} else if (!offline && index.songs.isEmpty) {
body = _Centered(
child: _ErrorText(index.error != null
? 'Could not build the track index.'
: 'No tracks indexed yet.'),
child: _ErrorText(
index.error != null
? 'Could not build the track index.'
: 'No tracks indexed yet.',
),
);
} else {
final downloads = ref.watch(downloadManagerProvider);
@ -373,24 +375,24 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
Expanded(
child: visible.isEmpty
? const _Centered(
child: _ErrorText('No tracks match these filters.'))
child: _ErrorText('No tracks match these filters.'),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: visible.length,
itemBuilder: (context, i) {
final song = visible[i];
final artUri = (client != null && song.coverArt != null)
? client
.coverArtUri(song.coverArt!, size: 128)
.toString()
: null;
final artUri = resolveArtUriW(
ref,
coverArt: song.coverArt,
size: 128,
)?.toString();
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () =>
playback.playSongs(visible, startIndex: i),
onTap: () => playback.playSongs(visible, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
@ -414,7 +416,9 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
return HairlinePanel(
title: 'Tracks',
active: true,
trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
trailing: index.songs.isNotEmpty || visible.isNotEmpty
? '(${visible.length})'
: null,
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
action: Row(
mainAxisSize: MainAxisSize.min,
@ -425,8 +429,10 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: Text('↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
child: Text(
'↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
),
),
if (visible.isNotEmpty)
@ -445,19 +451,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download,
size: 16, color: TimbreColors.foreground),
Icon(
Icons.download,
size: 16,
color: TimbreColors.foreground,
),
SizedBox(width: TimbreSpacing.sm),
Text('Download all',
style: TextStyle(color: TimbreColors.foreground)),
Text(
'Download all',
style: TextStyle(color: TimbreColors.foreground),
),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon(Icons.more_vert,
size: 18, color: TimbreColors.dimmed),
child: Icon(
Icons.more_vert,
size: 18,
color: TimbreColors.dimmed,
),
),
),
],
@ -470,22 +484,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
/// library, so it's gated behind a dialog unlike per-album download-all.
/// [songs] is the currently-visible (filtered/sorted) set.
Future<void> _confirmDownloadAll(
BuildContext context, List<Song> songs) async {
BuildContext context,
List<Song> songs,
) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: const Text('Download these tracks?'),
content: Text(
'This queues all ${songs.length} listed tracks for offline '
'download. It may use significant storage and data.'),
'This queues all ${songs.length} listed tracks for offline '
'download. It may use significant storage and data.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download all')),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download all'),
),
],
),
);
@ -543,9 +562,7 @@ class ArtistScreen extends ConsumerWidget {
title: album.name ?? 'Unknown album',
trailing: album.year?.toString(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AlbumScreen(id: album.id),
),
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
),
);
},
@ -574,16 +591,13 @@ class AlbumScreen extends ConsumerWidget {
: [
IconButton(
tooltip: 'Add to playlist',
onPressed: () =>
showAddToPlaylistSheet(context, songs: songs),
onPressed: () => showAddToPlaylistSheet(context, songs: songs),
icon: const Icon(Icons.playlist_add),
),
IconButton(
tooltip: 'Download album',
onPressed: () {
ref
.read(downloadManagerProvider.notifier)
.downloadAll(songs);
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
showToast(context, 'Downloading album…');
},
icon: const Icon(Icons.download),
@ -678,39 +692,36 @@ class BrowseRow extends StatelessWidget {
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final isDone = downloadStatus == DownloadStatus.done;
final isActive = downloadStatus == DownloadStatus.queued ||
final isActive =
downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget,
),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
if (artUri != null) ...[
SizedBox(
ArtImage(
artUri,
width: 40,
height: 40,
child: ColoredBox(
color: TimbreColors.surface,
child: Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
),
),
fit: BoxFit.cover,
placeholder: const _AlbumArtFallback(),
),
const SizedBox(width: TimbreSpacing.md),
],
if (leading != null)
SizedBox(
width: 28,
child: Text(leading!,
style: TextStyle(color: TimbreColors.dimmed)),
child: Text(
leading!,
style: TextStyle(color: TimbreColors.dimmed),
),
),
Expanded(
child: Column(
@ -729,7 +740,9 @@ class BrowseRow extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12),
color: TimbreColors.dimmed,
fontSize: 12,
),
),
],
),
@ -737,8 +750,7 @@ class BrowseRow extends StatelessWidget {
if (isDone)
Padding(
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
child:
Icon(Icons.download_done, size: 14, color: accent),
child: Icon(Icons.download_done, size: 14, color: accent),
)
else if (isActive)
const Padding(
@ -751,8 +763,7 @@ class BrowseRow extends StatelessWidget {
),
if (trailing != null) ...[
const SizedBox(width: TimbreSpacing.md),
Text(trailing!,
style: TextStyle(color: TimbreColors.dimmed)),
Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)),
],
if (onPlayNext != null)
_RowIcon(
@ -812,8 +823,7 @@ class _RowMenu extends StatelessWidget {
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
onSelected: (v) {
switch (v) {
case 'playlist':
@ -829,17 +839,22 @@ class _RowMenu extends StatelessWidget {
itemBuilder: (_) => [
if (onAddToPlaylist != null)
const PopupMenuItem(
value: 'playlist', child: Text('Add to playlist')),
value: 'playlist',
child: Text('Add to playlist'),
),
if (onAddToTag != null)
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
if (isDownloaded && onRemoveDownload != null)
const PopupMenuItem(
value: 'remove_download', child: Text('Remove download'))
value: 'remove_download',
child: Text('Remove download'),
)
else if (onDownload != null)
PopupMenuItem(
value: 'download',
enabled: !isDownloading,
child: Text(isDownloading ? 'Downloading…' : 'Download')),
value: 'download',
enabled: !isDownloading,
child: Text(isDownloading ? 'Downloading…' : 'Download'),
),
],
);
}
@ -881,10 +896,12 @@ class _DetailScaffold extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700),
),
actions: actions,
),
body: SafeArea(child: child),
@ -901,12 +918,16 @@ class _NotConnected extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Not connected.',
style: TextStyle(color: TimbreColors.foreground)),
Text(
"You're offline.",
style: TextStyle(color: TimbreColors.foreground),
),
SizedBox(height: TimbreSpacing.sm),
Text('Tap the status bar to add a Subsonic server.',
textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed)),
Text(
'Download music to browse it here.',
textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed),
),
],
),
);
@ -918,19 +939,19 @@ class _Centered extends StatelessWidget {
final Widget child;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(TimbreSpacing.xl),
child: Center(child: child),
);
padding: const EdgeInsets.all(TimbreSpacing.xl),
child: Center(child: child),
);
}
class _Loading extends StatelessWidget {
const _Loading();
@override
Widget build(BuildContext context) => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
class _ErrorText extends StatelessWidget {
@ -938,10 +959,10 @@ class _ErrorText extends StatelessWidget {
final String message;
@override
Widget build(BuildContext context) => Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed),
);
message,
textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed),
);
}
String? _fmtDuration(int? seconds) {

View file

@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../debug/log_store.dart';
import '../theme/tokens.dart';
/// Beta-testing console: shows the app's captured `print`/`debugPrint` output
/// and uncaught errors (see [LogStore], wired up in `main`). Read-only view
/// with copy-all / clear / follow controls — no debugger needed on-device.
class DebugScreen extends StatefulWidget {
const DebugScreen({super.key});
@override
State<DebugScreen> createState() => _DebugScreenState();
}
class _DebugScreenState extends State<DebugScreen> {
static const _errorColor = Color(0xFFE06C75);
final _controller = ScrollController();
/// When true, new lines keep the view pinned to the bottom (tail -f style).
/// Flipped off automatically when the user scrolls up to read history.
bool _follow = true;
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
LogStore.instance.addListener(_onLog);
}
@override
void dispose() {
LogStore.instance.removeListener(_onLog);
_controller.removeListener(_onScroll);
_controller.dispose();
super.dispose();
}
void _onScroll() {
if (!_controller.hasClients) return;
final atBottom =
_controller.offset >= _controller.position.maxScrollExtent - 24;
if (atBottom != _follow) setState(() => _follow = atBottom);
}
void _onLog() {
if (!mounted) return;
setState(() {});
if (_follow) {
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToBottom());
}
}
void _jumpToBottom() {
if (!_controller.hasClients) return;
_controller.jumpTo(_controller.position.maxScrollExtent);
}
Future<void> _copyAll() async {
await Clipboard.setData(ClipboardData(text: LogStore.instance.asText()));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Log copied to clipboard')),
);
}
@override
Widget build(BuildContext context) {
return Container(
color: TimbreColors.background,
child: Column(
children: [
_header(),
Expanded(
child: ListenableBuilder(
listenable: LogStore.instance,
builder: (context, _) {
final entries = LogStore.instance.entries;
if (entries.isEmpty) {
return Center(
child: Text(
'No output captured yet.',
style: TextStyle(color: TimbreColors.dimmed),
),
);
}
return Scrollbar(
controller: _controller,
child: ListView.builder(
controller: _controller,
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
itemCount: entries.length,
itemBuilder: (context, i) => _line(entries[i]),
),
);
},
),
),
],
),
);
}
Widget _header() {
return Container(
decoration: BoxDecoration(
color: TimbreColors.surface,
border: Border(bottom: BorderSide(color: TimbreColors.border)),
),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Row(
children: [
Expanded(
child: ListenableBuilder(
listenable: LogStore.instance,
builder: (context, _) => Text(
'console · ${LogStore.instance.length} lines',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
),
),
_action(
_follow ? Icons.vertical_align_bottom : Icons.pause,
_follow ? 'follow' : 'paused',
() {
setState(() => _follow = !_follow);
if (_follow) _jumpToBottom();
},
active: _follow,
),
_action(Icons.copy, 'copy', _copyAll),
_action(Icons.delete_outline, 'clear', LogStore.instance.clear),
],
),
);
}
Widget _action(IconData icon, String label, VoidCallback onTap,
{bool active = false}) {
final accent = Theme.of(context).colorScheme.primary;
final color = active ? accent : TimbreColors.dimmed;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: TimbreSpacing.xs),
Text(label, style: TextStyle(color: color, fontSize: 12)),
],
),
),
);
}
Widget _line(LogEntry e) {
final isError = e.level == LogLevel.error;
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${e.timeLabel} ',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
),
TextSpan(
text: e.text,
style: TextStyle(
color: isError ? _errorColor : TimbreColors.foreground,
fontSize: 12,
height: 1.35,
),
),
],
),
),
);
}
}

View file

@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/art_image.dart';
import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
/// Manage offline downloads: what's saved, how much space it uses, and any
/// in-flight transfers. Tapping a completed track plays it.
@ -19,25 +21,33 @@ class DownloadsScreen extends ConsumerWidget {
final active = downloads.byId.values.where((d) => d.isActive).toList();
final completed = downloads.completed;
// Ordered play list — index i here matches the i-th rendered saved row.
final savedSongs = completed.map((d) => d.song).toList();
return Scaffold(
appBar: AppBar(
title: const Text('Downloads',
style: TextStyle(fontWeight: FontWeight.w700)),
title: const Text(
'Downloads',
style: TextStyle(fontWeight: FontWeight.w700),
),
actions: [
if (completed.isNotEmpty)
TextButton(
onPressed: () => _confirmClear(context, controller),
child: Text('Clear all',
style: TextStyle(color: TimbreColors.dimmed)),
child: Text(
'Clear all',
style: TextStyle(color: TimbreColors.dimmed),
),
),
],
),
body: SafeArea(
child: (active.isEmpty && completed.isEmpty)
? Center(
child: Text('No downloads yet.',
style: TextStyle(color: TimbreColors.dimmed)),
child: Text(
'No downloads yet.',
style: TextStyle(color: TimbreColors.dimmed),
),
)
: ListView(
padding: const EdgeInsets.all(TimbreSpacing.lg),
@ -47,11 +57,10 @@ class DownloadsScreen extends ConsumerWidget {
title: 'Downloading',
trailing: '(${active.length})',
padding: const EdgeInsets.symmetric(
vertical: TimbreSpacing.md),
vertical: TimbreSpacing.md,
),
child: Column(
children: [
for (final d in active) _ActiveRow(info: d),
],
children: [for (final d in active) _ActiveRow(info: d)],
),
),
const SizedBox(height: TimbreSpacing.xl),
@ -62,21 +71,68 @@ class DownloadsScreen extends ConsumerWidget {
trailing: completed.isEmpty
? null
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
padding:
const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
action: Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: savedSongs.isEmpty
? null
: () => playback.playSongs(savedSongs),
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon(
Icons.play_arrow,
size: 18,
color: TimbreColors.dimmed,
),
),
),
InkWell(
onTap: savedSongs.isEmpty
? null
: () {
playback.toggleShuffle();
playback.playSongs(savedSongs);
},
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon(
Icons.shuffle,
size: 18,
color: TimbreColors.dimmed,
),
),
),
],
),
padding: const EdgeInsets.symmetric(
vertical: TimbreSpacing.md,
),
child: completed.isEmpty
? Padding(
padding: EdgeInsets.all(TimbreSpacing.lg),
child: Text('Nothing saved for offline yet.',
style: TextStyle(color: TimbreColors.dimmed)),
child: Text(
'Nothing saved for offline yet.',
style: TextStyle(color: TimbreColors.dimmed),
),
)
: Column(
children: [
for (final d in completed)
for (final (i, d) in completed.indexed)
_SavedRow(
info: d,
onPlay: () =>
playback.playSongs([d.song]),
artUri: resolveArtUriW(
ref,
coverArt: d.song.coverArt,
size: 128,
)?.toString(),
onPlay: () => playback.playSongs(
savedSongs,
startIndex: i,
),
onPlayNext: () => playback.playNext(d.song),
onAddToQueue: () =>
playback.addToQueue(d.song),
onRemove: () => controller.remove(d.song.id),
),
],
@ -89,21 +145,26 @@ class DownloadsScreen extends ConsumerWidget {
}
Future<void> _confirmClear(
BuildContext context, DownloadController controller) async {
BuildContext context,
DownloadController controller,
) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: const Text('Remove all downloads?'),
content: const Text(
'This deletes every saved file for this server. It cannot be undone.'),
'This deletes every saved file for this server. It cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove all')),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove all'),
),
],
),
);
@ -121,21 +182,27 @@ class _ActiveRow extends StatelessWidget {
final failed = info.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs),
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.xs,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground)),
Text(
info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
const SizedBox(height: TimbreSpacing.xs),
if (failed)
const Text('Failed',
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
const Text(
'Failed',
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12),
)
else
LinearProgressIndicator(
value: info.progress > 0 ? info.progress : null,
@ -162,35 +229,55 @@ class _ActiveRow extends StatelessWidget {
class _SavedRow extends StatelessWidget {
const _SavedRow({
required this.info,
required this.artUri,
required this.onPlay,
required this.onPlayNext,
required this.onAddToQueue,
required this.onRemove,
});
final DownloadInfo info;
/// Resolved cover-art URI (downloaded art is local, so it shows offline).
final String? artUri;
final VoidCallback onPlay;
final VoidCallback onPlayNext;
final VoidCallback onAddToQueue;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final quality = info.format ??
final quality =
info.format ??
(info.bitRate != null ? '${info.bitRate} kbps' : 'Original');
return InkWell(
onTap: onPlay,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget,
),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
ArtImage(
artUri,
width: 40,
height: 40,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground)),
Text(
info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
Text(
[
info.song.artist,
@ -198,21 +285,37 @@ class _SavedRow extends StatelessWidget {
].where((e) => e != null && e.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: TimbreColors.dimmed, fontSize: 12),
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
),
InkWell(
onTap: onRemove,
customBorder: const CircleBorder(),
child: SizedBox(
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.delete_outline,
size: 20, color: TimbreColors.dimmed),
),
PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {
switch (v) {
case 'next':
onPlayNext();
showToast(context, 'Playing next', icon: Icons.check);
case 'queue':
onAddToQueue();
showToast(context, 'Added to queue', icon: Icons.check);
case 'remove':
onRemove();
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'next', child: Text('Play next')),
const PopupMenuItem(
value: 'queue',
child: Text('Add to queue'),
),
const PopupMenuItem(
value: 'remove',
child: Text('Remove download'),
),
],
),
],
),

View file

@ -5,6 +5,7 @@ import '../history/play_history.dart';
import '../subsonic/models.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/art_image.dart';
import '../widgets/block_progress_bar.dart';
import 'browser_screen.dart';
@ -22,9 +23,7 @@ class HomeScreen extends ConsumerWidget {
final random = ref.watch(randomAlbumsProvider);
String? artFor(String? coverArt, {int size = 300}) =>
(client != null && coverArt != null)
? client.coverArtUri(coverArt, size: size).toString()
: null;
resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString();
return ListView(
padding: const EdgeInsets.fromLTRB(
@ -55,11 +54,7 @@ class HomeScreen extends ConsumerWidget {
),
// Recently Added — server discovery shelf.
_AlbumShelf(
title: 'Recently Added',
albums: newest,
artFor: artFor,
),
_AlbumShelf(title: 'Recently Added', albums: newest, artFor: artFor),
// Random — a single spotlighted album, re-rolled via the shuffle action.
_RandomAlbum(
@ -81,9 +76,9 @@ class HomeScreen extends ConsumerWidget {
}
static void _pushAlbum(BuildContext context, String albumId) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)),
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)));
}
}
@ -96,7 +91,6 @@ class _HeroCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final client = ref.watch(subsonicClientProvider);
final current = ref.watch(activePlaybackProvider.select((s) => s.current));
// Fall back to the most recent track so the hero is useful before playback.
@ -104,9 +98,11 @@ class _HeroCard extends ConsumerWidget {
final PlayRecord? fallback = recent.isEmpty ? null : recent.first;
final String? coverArt = current?.coverArt ?? fallback?.coverArt;
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 240).toString()
: null;
final artUri = resolveArtUriW(
ref,
coverArt: coverArt,
size: 240,
)?.toString();
final title = current?.title ?? fallback?.title;
final subtitle = current?.artist ?? fallback?.artist;
@ -118,12 +114,17 @@ class _HeroCard extends ConsumerWidget {
onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
child: Row(
children: [
Icon(Icons.library_music_outlined,
color: TimbreColors.dimmed, size: 40),
Icon(
Icons.library_music_outlined,
color: TimbreColors.dimmed,
size: 40,
),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Text('Browse your library to start listening',
style: TextStyle(color: TimbreColors.foreground)),
child: Text(
'Browse your library to start listening',
style: TextStyle(color: TimbreColors.foreground),
),
),
],
),
@ -145,18 +146,7 @@ class _HeroCard extends ConsumerWidget {
SizedBox(
width: 64,
height: 64,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed))
: Icon(Icons.album_outlined,
color: TimbreColors.dimmed),
),
child: ArtImage(artUri, fit: BoxFit.cover),
),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
@ -166,29 +156,40 @@ class _HeroCard extends ConsumerWidget {
children: [
Row(
children: [
Icon(hasCurrent ? Icons.play_arrow : Icons.history,
size: 14, color: accent),
Icon(
hasCurrent ? Icons.play_arrow : Icons.history,
size: 14,
color: accent,
),
const SizedBox(width: TimbreSpacing.xs),
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
fontSize: 11,
letterSpacing: 1,
fontWeight: FontWeight.w700)),
Text(
hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
fontSize: 11,
letterSpacing: 1,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: TimbreSpacing.xs),
Text(title,
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null)
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
if (hasCurrent) ...[
const SizedBox(height: TimbreSpacing.md),
const _HeroProgress(),
@ -210,14 +211,19 @@ class _HeroProgress extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
final progress = ref.watch(
activePlaybackProvider.select((s) => s.progress),
);
return BlockProgressBar(progress: progress, cells: 32, height: 6);
}
}
class _HeroShell extends StatelessWidget {
const _HeroShell(
{required this.child, required this.accent, required this.onTap});
const _HeroShell({
required this.child,
required this.accent,
required this.onTap,
});
final Widget child;
final Color accent;
@ -282,11 +288,14 @@ class _ShelfHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Row(
children: [
Text(title,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5)),
Text(
title,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
const Spacer(),
if (onShuffle != null)
InkWell(
@ -321,7 +330,11 @@ class _AlbumShelf extends StatelessWidget {
return albums.when(
loading: () => _Shelf(
title: title,
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
cards: const [
_ArtCardSkeleton(),
_ArtCardSkeleton(),
_ArtCardSkeleton(),
],
),
error: (_, _) => const SizedBox.shrink(),
data: (list) => _Shelf(
@ -332,9 +345,9 @@ class _AlbumShelf extends StatelessWidget {
artUri: artFor(a.coverArt),
title: a.name ?? 'Unknown album',
subtitle: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
),
],
),
@ -378,9 +391,9 @@ class _RandomAlbum extends StatelessWidget {
const _RandomSkeleton()
else
InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
child: _RandomBody(album: a, artUri: artFor(a.coverArt)),
),
],
@ -402,20 +415,11 @@ class _RandomBody extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
child: ArtImage(
artUri,
fit: BoxFit.cover,
width: _RandomAlbum._size,
height: _RandomAlbum._size,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed))
: Icon(Icons.album_outlined, color: TimbreColors.dimmed),
),
),
),
const SizedBox(width: TimbreSpacing.lg),
@ -424,28 +428,35 @@ class _RandomBody extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(album.name ?? 'Unknown album',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700)),
Text(
album.name ?? 'Unknown album',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: TimbreSpacing.xs),
if (album.artist != null)
Text(album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed)),
Text(
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed),
),
if (album.year != null)
Text('${album.year}',
style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)),
Text(
'${album.year}',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
if (album.genre != null)
Text(album.genre!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12)),
Text(
album.genre!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
),
@ -497,34 +508,27 @@ class _ArtCard extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
child: ArtImage(
artUri,
fit: BoxFit.cover,
width: _size,
height: _size,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => Icon(
Icons.album_outlined, color: TimbreColors.dimmed))
: Icon(Icons.album_outlined,
color: TimbreColors.dimmed),
),
),
),
const SizedBox(height: TimbreSpacing.sm),
Text(title,
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
),

View file

@ -10,6 +10,7 @@ import '../state/providers.dart';
import '../state/remote_providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/art_image.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/cassette_view.dart';
import '../widgets/hairline_panel.dart';
@ -57,20 +58,25 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final current = state.current;
// Re-seed the favorites store whenever the track changes.
ref.listen(activePlaybackProvider.select((s) => s.current?.id),
(_, _) => _seedFavorites());
ref.listen(
activePlaybackProvider.select((s) => s.current?.id),
(_, _) => _seedFavorites(),
);
if (current == null) {
return Center(
child: Text('Nothing playing.',
style: TextStyle(color: TimbreColors.dimmed)),
child: Text(
'Nothing playing.',
style: TextStyle(color: TimbreColors.dimmed),
),
);
}
// Cross-device control is offered next to the queue toggle (compact) or
// beneath the transport (wide); hidden where the platform can't host/browse.
final remoteSupported =
ref.watch(remoteControlProvider.select((s) => s.supported));
final remoteSupported = ref.watch(
remoteControlProvider.select((s) => s.supported),
);
// Everything below the art region — shared by both layouts. The queue
// toggle is deliberately excluded: it belongs only to the compact layout
@ -288,8 +294,10 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
if (index == null || index < 0) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_controller.hasClients) return;
final target =
(index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent);
final target = (index * _rowExtent).clamp(
0.0,
_controller.position.maxScrollExtent,
);
_controller.animateTo(
target,
duration: const Duration(milliseconds: 300),
@ -375,8 +383,9 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: titleColor,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
fontWeight: isCurrent
? FontWeight.w700
: FontWeight.w400,
),
),
if (song.artist != null)
@ -392,17 +401,21 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
],
),
),
Text(_fmt(song.duration),
style: TextStyle(color: TimbreColors.dimmed)),
Text(
_fmt(song.duration),
style: TextStyle(color: TimbreColors.dimmed),
),
InkWell(
onTap: () =>
ref.read(playbackCommandsProvider).removeAt(i),
onTap: () => ref.read(playbackCommandsProvider).removeAt(i),
customBorder: const CircleBorder(),
child: SizedBox(
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: TimbreColors.dimmed),
child: Icon(
Icons.close,
size: 18,
color: TimbreColors.dimmed,
),
),
),
],
@ -430,8 +443,9 @@ class _FittedArt extends StatelessWidget {
alignment: Alignment.topCenter,
child: LayoutBuilder(
builder: (context, c) {
final side =
c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth;
final side = c.maxHeight.isFinite
? c.maxHeight.clamp(0.0, c.maxWidth)
: c.maxWidth;
return SizedBox(
width: side,
height: side,
@ -452,14 +466,17 @@ class _AlbumArtPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final coverArt =
ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider);
final cassette =
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 512).toString()
: null;
final coverArt = ref.watch(
activePlaybackProvider.select((s) => s.current?.coverArt),
);
final cassette = ref.watch(
settingsProvider.select((s) => s.nowPlayingCassette),
);
final artUri = resolveArtUriW(
ref,
coverArt: coverArt,
size: 512,
)?.toString();
return HairlinePanel(
title: cassette ? 'Cassette' : 'Album Art',
@ -468,17 +485,10 @@ class _AlbumArtPanel extends ConsumerWidget {
? Center(child: CassetteView(artUri: artUri))
: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
child: ArtImage(
artUri,
fit: BoxFit.cover,
placeholder: const _ArtFallback(),
),
),
);
@ -497,13 +507,16 @@ class _FavRating extends ConsumerWidget {
final fav = ref.watch(favoritesProvider);
final accent = Theme.of(context).colorScheme.primary;
final starred = fav.isSongStarred(song.id);
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
final rating = fav.ratingFor(song.id) != 0
? fav.ratingFor(song.id)
: (song.userRating ?? 0);
final downloadStatus =
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
final downloadStatus = ref.watch(
downloadManagerProvider.select((s) => s.byId[song.id]?.status),
);
final isDownloaded = downloadStatus == DownloadStatus.done;
final isDownloading = downloadStatus == DownloadStatus.queued ||
final isDownloading =
downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return Row(
@ -541,8 +554,11 @@ class _FavRating extends ConsumerWidget {
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.sm),
child: Icon(Icons.playlist_add,
size: 22, color: TimbreColors.dimmed),
child: Icon(
Icons.playlist_add,
size: 22,
color: TimbreColors.dimmed,
),
),
),
InkWell(
@ -637,12 +653,16 @@ class _InfoStrip extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist',
style: TextStyle(color: TimbreColors.foreground)),
Text(
song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700),
),
Text(
song.artist ?? 'Unknown artist',
style: TextStyle(color: TimbreColors.foreground),
),
if (album.isNotEmpty)
Text(album, style: TextStyle(color: TimbreColors.dimmed)),
],
@ -659,29 +679,35 @@ class _NowPlayingProgress extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(activePlaybackProvider.select((s) => s.position));
final duration =
ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration));
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
final position = ref.watch(
activePlaybackProvider.select((s) => s.position),
);
final duration = ref.watch(
activePlaybackProvider.select((s) => s.effectiveDuration),
);
final progress = ref.watch(
activePlaybackProvider.select((s) => s.progress),
);
return Row(
children: [
Text(_fmtDur(position),
style: TextStyle(color: TimbreColors.dimmed)),
Text(_fmtDur(position), 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: TextStyle(color: TimbreColors.dimmed)),
Text(_fmtDur(duration), style: TextStyle(color: TimbreColors.dimmed)),
],
);
}
}
class _Transport extends StatelessWidget {
const _Transport(
{required this.state, required this.ref, required this.accent});
const _Transport({
required this.state,
required this.ref,
required this.accent,
});
final PlaybackState state;
final WidgetRef ref;
@ -697,21 +723,34 @@ class _Transport extends StatelessWidget {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : TimbreColors.dimmed),
_btn(
Icons.shuffle,
controller.toggleShuffle,
color: state.shuffle ? accent : TimbreColors.dimmed,
),
_btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent, size: 40),
_btn(
state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent,
size: 40,
),
_btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed),
_btn(
loopIcon,
controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed,
),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color? color, double size = 28}) {
Widget _btn(
IconData icon,
VoidCallback onTap, {
Color? color,
double size = 28,
}) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size),
@ -723,9 +762,8 @@ class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 48),
);
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 48),
);
}
String _fmt(int? seconds) {