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 '../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),
),
],
),
),