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