offline updates and playhead fix
This commit is contained in:
parent
7a199fe4df
commit
6663330260
14 changed files with 1673 additions and 545 deletions
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue