This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -0,0 +1,252 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show Ticker;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
/// Animated cassette for the Now Playing screen. Composites, in the shell's
/// `469×298` coordinate space, from back to front:
/// 1. the cover art on the label (the big inner body panel, cropped to fill),
/// 2. the two tape reels — a dark "well" plus the winding tape,
/// 3. the two spindle cogs, rotating while the track plays,
/// 4. the static shell SVG on top, which frames everything — the reels and
/// cogs show through its circular reel holes, so they read as sitting
/// *inside* the cassette rather than pasted over it.
///
/// The reels are modelled on a real compact cassette: tape moves at a constant
/// *linear* speed, so a reel's wound radius scales with √(remaining length) and
/// its angular speed with 1/radius (the fuller reel turns slower). See
/// [_CassetteConfig].
class CassetteView extends ConsumerStatefulWidget {
const CassetteView({super.key, required this.artUri});
/// Current track's cover-art URL (already sized by the caller), or null.
final String? artUri;
@override
ConsumerState<CassetteView> createState() => _CassetteViewState();
}
class _CassetteViewState extends ConsumerState<CassetteView>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
Duration _last = Duration.zero;
final _model = _CassetteModel();
@override
void initState() {
super.initState();
_ticker = createTicker(_onTick)..start();
}
void _onTick(Duration elapsed) {
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(playbackProvider);
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
}
@override
void dispose() {
_ticker.dispose();
_model.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const cfg = _CassetteConfig.instance;
return AspectRatio(
aspectRatio: cfg.viewBox.width / cfg.viewBox.height,
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: cfg.viewBox.width,
height: cfg.viewBox.height,
child: Stack(
children: [
// 1. Cover art on the label — the big inner panel. The shell (on
// top) is transparent there, so the art shows as the "label".
Positioned.fromRect(
rect: cfg.labelRect,
child: ClipRect(child: _LabelArt(artUri: widget.artUri)),
),
// 2. Tape reels (well + winding tape). Repaints off the model.
Positioned.fill(
child: CustomPaint(painter: _TapePainter(_model)),
),
// 3. Spinning cogs — one SvgPicture each, rotated by an
// AnimatedBuilder so only the transform rebuilds per frame.
_spindle(cfg.leftReel, () => _model.angleLeft),
_spindle(cfg.rightReel, () => _model.angleRight),
// 4. Static shell on top: frames the reels through its holes.
Positioned.fill(
child: SvgPicture.asset(
'assets/casette/casette_shell.svg',
fit: BoxFit.fill,
),
),
],
),
),
),
);
}
Widget _spindle(Offset center, double Function() angle) {
const size = _CassetteConfig.spindleSize;
// Built once; AnimatedBuilder rotates this cached child rather than
// re-inflating the SVG every frame.
final cog = SvgPicture.asset(
'assets/casette/spindle.svg',
width: size,
height: size,
);
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: AnimatedBuilder(
animation: _model,
child: cog,
builder: (_, child) =>
Transform.rotate(angle: angle(), child: child),
),
);
}
}
/// Cover art (or a neutral fallback) for the label region.
class _LabelArt extends StatelessWidget {
const _LabelArt({required this.artUri});
final String? artUri;
@override
Widget build(BuildContext context) {
if (artUri == null) {
return const 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: (_, _, _) => const ColoredBox(color: TimbreColors.surface),
);
}
}
/// Holds the live reel angles + progress and advances them each tick. A
/// [ChangeNotifier] so the spindle transforms and tape painter repaint without
/// rebuilding the whole [CassetteView].
class _CassetteModel extends ChangeNotifier {
double angleLeft = 0;
double angleRight = 0;
double progress = 0;
void update({
required double dt,
required bool playing,
required double progress,
}) {
final progressChanged = progress != this.progress;
this.progress = progress;
if (playing) {
const cfg = _CassetteConfig.instance;
// ω = v / R, same (clockwise) direction for both reels.
angleLeft += cfg.linearSpeed / cfg.radius(progress, supply: true) * dt;
angleRight += cfg.linearSpeed / cfg.radius(progress, supply: false) * dt;
}
// Idle + no seek ⇒ nothing moved, so skip the repaint.
if (playing || progressChanged) notifyListeners();
}
}
/// Paints the two reels as large tape discs centred on the spindles, plus a
/// light pad behind them. Everything is clipped to the window band so it never
/// bleeds onto the label; the shell (painted on top) then masks it to the parts
/// that should show — the reel holes and the centre window, where each disc's
/// inner edge appears as a crescent that grows/shrinks with
/// [_CassetteModel.progress] as tape winds from one reel to the other.
class _TapePainter extends CustomPainter {
_TapePainter(this.model) : super(repaint: model);
final _CassetteModel model;
@override
void paint(Canvas canvas, Size size) {
const cfg = _CassetteConfig.instance;
canvas.save();
// Keep the tape inside the window band — off the (transparent) label.
canvas.clipRect(cfg.windowRect);
// Light pad behind the tape; the shell masks it down to the centre window.
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);
canvas.drawCircle(
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
canvas.restore();
}
// Repaint is driven by `repaint: model`; geometry is fully derived from it.
@override
bool shouldRepaint(_TapePainter oldDelegate) => false;
}
/// All the fixed geometry, measured off `casette_shell.svg` (viewBox 469×298),
/// plus the reel physics. Grouped and tunable in one place.
class _CassetteConfig {
const _CassetteConfig();
static const instance = _CassetteConfig();
final Size viewBox = const Size(469, 298);
/// The big inner body panel — the transparent "label" the cover art fills.
/// Slightly oversized so it fully backs the shell's hole (excess is masked
/// by the shell frame on top).
final Rect labelRect = const Rect.fromLTRB(25, 27, 444, 220);
// The window band the tape is confined to (clip), so it never touches the
// label; the shell then masks it to the reel holes + centre window.
final Rect windowRect = const Rect.fromLTRB(62.5, 98.5, 405.5, 172);
// Reel + spindle centres (the two window holes).
final Offset leftReel = const Offset(135, 135);
final Offset rightReel = const Offset(334, 135);
// Wound-tape radius bounds: [hubRadius] = empty reel (just covers the hole),
// [fullRadius] = full reel (its crescent reaches well into the centre window).
final double hubRadius = 36;
final double fullRadius = 80;
// Cog display size (native art is 105×105, centred on the axle). ~ the reel
// hole so the cog seats in the well with a thin tape rim showing around it.
static const double spindleSize = 36;
// Tape linear speed in viewBox units/second — sets how fast the cogs spin
// (ω = linearSpeed / radius). Purely cosmetic.
final double linearSpeed = 40;
final Color tapeColor = const Color(0xFF121212); // wound tape
final Color padColor = const Color(0xFF1A1A1A); // centre-window backing
/// Wound radius at [progress]. Radius scales with √(wound length): the supply
/// 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 +
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
return math.sqrt(r2);
}
}