252 lines
9.1 KiB
Dart
252 lines
9.1 KiB
Dart
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 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),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|