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