mobile-music/lib/widgets/cassette_view.dart
2026-08-04 16:39:18 -04:00

252 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(activePlaybackProvider);
_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);
}
}