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'; import 'art_image.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 createState() => _CassetteViewState(); } class _CassetteViewState extends ConsumerState 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) { // 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), ); } } /// 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); } }