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

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