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

@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// Cover art that renders from EITHER a local file (`file://` URI) or a network
/// URL, picking [FileImage] vs [NetworkImage] purely by the URI scheme. This is
/// the single place all art-rendering call sites go through, so a downloaded
/// track's art shows offline (the caller hands us the already-built URI string;
/// resolving download-vs-network happens upstream in `providers.dart`).
///
/// Behaviour preserved from the old scattered `Image.network(...)` call sites:
/// * `fit: BoxFit.cover`, `gaplessPlayback: true`, and a `ValueKey(uri)` so
/// switching tracks keeps the previous frame until the new art decodes (no
/// flash) and rebuilds cleanly.
/// * a surface-filled [placeholder] with a muted album icon for the null,
/// loading-error, and missing-file cases (via `errorBuilder`).
///
/// Sizing is never hardcoded — [width]/[height]/[fit] are respected as passed.
/// Optional [borderRadius] clips the art with a [ClipRRect]; callers that pass
/// it should drop their own outer `ClipRRect` so we don't double-clip.
class ArtImage extends StatelessWidget {
const ArtImage(
this.uri, {
super.key,
this.fit = BoxFit.cover,
this.width,
this.height,
this.placeholder,
this.borderRadius,
});
/// The already-built art URI. A `file://` URI loads from disk; anything else
/// (http/https) loads over the network. `null` → [placeholder].
final String? uri;
final BoxFit fit;
final double? width;
final double? height;
/// Shown for null/error/missing art. Defaults to [_ArtPlaceholder].
final Widget? placeholder;
/// If set, the art (and placeholder) are clipped to these rounded corners.
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final fallback = placeholder ?? const _ArtPlaceholder();
Widget child;
if (uri == null) {
child = fallback;
} else {
final parsed = Uri.tryParse(uri!);
final ImageProvider provider = (parsed != null && parsed.scheme == 'file')
? FileImage(File(parsed.toFilePath()))
: NetworkImage(uri!);
child = Image(
image: provider,
key: ValueKey(uri),
fit: fit,
gaplessPlayback: true,
errorBuilder: (_, _, _) => fallback,
);
}
// Back the art with the surface fill so transparent/loading gaps read as
// a panel rather than the bare canvas (matches the old ColoredBox wrap).
child = ColoredBox(color: TimbreColors.surface, child: child);
if (width != null || height != null) {
child = SizedBox(width: width, height: height, child: child);
}
if (borderRadius != null) {
child = ClipRRect(borderRadius: borderRadius!, child: child);
}
return child;
}
}
/// The default art placeholder: a muted album glyph on the surface fill. Used
/// for null art and as the loading/error fallback.
class _ArtPlaceholder extends StatelessWidget {
const _ArtPlaceholder();
@override
Widget build(BuildContext context) => ColoredBox(
color: TimbreColors.surface,
child: Center(
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed),
),
);
}

View file

@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'art_image.dart';
/// Animated cassette for the Now Playing screen. Composites, in the shell's
/// `469×298` coordinate space, from back to front:
@ -44,13 +45,18 @@ class _CassetteViewState extends ConsumerState<CassetteView>
}
void _onTick(Duration elapsed) {
final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
final dt =
(elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
_last = elapsed;
if (dt <= 0) return;
// Read (not watch) inside the ticker: the model drives repaints itself, and
// watching here would rebuild the whole widget every position tick.
final s = ref.read(activePlaybackProvider);
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
_model.update(
dt: dt,
playing: s.playing && s.supported,
progress: s.progress,
);
}
@override
@ -118,8 +124,7 @@ class _CassetteViewState extends ConsumerState<CassetteView>
child: AnimatedBuilder(
animation: _model,
child: cog,
builder: (_, child) =>
Transform.rotate(angle: angle(), child: child),
builder: (_, child) => Transform.rotate(angle: angle(), child: child),
),
);
}
@ -133,15 +138,12 @@ class _LabelArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (artUri == null) {
return ColoredBox(color: TimbreColors.surface);
}
return Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover, // square art → wide label: crop the sides/top
gaplessPlayback: true,
errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface),
// Square art → wide label: crop the sides/top. A plain surface fill backs
// the null/error cases (no album glyph here — the shell frames the label).
return ArtImage(
artUri,
fit: BoxFit.cover,
placeholder: ColoredBox(color: TimbreColors.surface),
);
}
}
@ -193,9 +195,15 @@ class _TapePainter extends CustomPainter {
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
final tape = Paint()..color = cfg.tapeColor;
canvas.drawCircle(
cfg.leftReel, cfg.radius(model.progress, supply: true), tape);
cfg.leftReel,
cfg.radius(model.progress, supply: true),
tape,
);
canvas.drawCircle(
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
cfg.rightReel,
cfg.radius(model.progress, supply: false),
tape,
);
canvas.restore();
}
@ -245,7 +253,8 @@ class _CassetteConfig {
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
double radius(double progress, {required bool supply}) {
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
final r2 = hubRadius * hubRadius +
final r2 =
hubRadius * hubRadius +
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
return math.sqrt(r2);
}

View file

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'art_image.dart';
/// Persistent mini-player pinned above the tab bar. Visible only while a track
/// is loaded; tapping the body jumps to the Now Playing tab.
@ -23,11 +24,12 @@ class MiniPlayer extends ConsumerWidget {
final playing = ref.watch(activePlaybackProvider.select((s) => s.playing));
final controller = ref.read(playbackCommandsProvider);
final client = ref.watch(subsonicClientProvider);
final accent = Theme.of(context).colorScheme.primary;
final artUri = (client != null && current.coverArt != null)
? client.coverArtUri(current.coverArt!, size: 128).toString()
: null;
final artUri = resolveArtUriW(
ref,
coverArt: current.coverArt,
size: 128,
)?.toString();
return Column(
mainAxisSize: MainAxisSize.min,
@ -45,17 +47,16 @@ class MiniPlayer extends ConsumerWidget {
SizedBox(
width: 40,
height: 40,
child: ColoredBox(
color: TimbreColors.background,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
// Keep the mini player's darker `background` fill behind the
// art (ArtImage's own fill is `surface`) by handing it a
// background-tinted placeholder for the null/error cases.
child: ArtImage(
artUri,
fit: BoxFit.cover,
placeholder: ColoredBox(
color: TimbreColors.background,
child: const _ArtFallback(),
),
),
),
const SizedBox(width: TimbreSpacing.md),
@ -83,9 +84,11 @@ class MiniPlayer extends ConsumerWidget {
),
),
_btn(Icons.skip_previous, controller.previous),
_btn(playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent),
_btn(
playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent,
),
_btn(Icons.skip_next, controller.next),
],
),
@ -111,7 +114,9 @@ class _MiniProgress 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),
);
final accent = Theme.of(context).colorScheme.primary;
return SizedBox(
height: 2,
@ -129,7 +134,6 @@ class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => Center(
child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 22),
);
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 22),
);
}