// lib/widgets/vinyl_record.dart // // Jitter fixes applied: // // 1. The rotation Transform is now the ONLY thing AnimatedBuilder rebuilds. // The disc and album art are passed as a static `child` — Flutter's // AnimatedBuilder skips rebuilding the child subtree on every tick, // so album art image decoding / layout never competes with the animation. // // 2. Album art crossfade is handled by AnimatedSwitcher inside the label, // keyed on the art bytes identity — it fades smoothly without touching // the rotation controller. // // 3. _VinylPainter is cached via RepaintBoundary so the groove canvas // is only repainted when size changes, not on every rotation frame. import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; class VinylRecord extends StatefulWidget { final Uint8List? albumArt; final bool isPlaying; final Duration rotationDuration; const VinylRecord({ super.key, this.albumArt, required this.isPlaying, this.rotationDuration = const Duration(milliseconds: 1800), }); @override State createState() => _VinylRecordState(); } class _VinylRecordState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: widget.rotationDuration, ); if (widget.isPlaying) _controller.repeat(); } @override void didUpdateWidget(VinylRecord old) { super.didUpdateWidget(old); if (widget.isPlaying && !_controller.isAnimating) { _controller.repeat(); } else if (!widget.isPlaying && _controller.isAnimating) { _controller .animateTo( 1.0, duration: const Duration(milliseconds: 600), curve: Curves.easeOut, ) .then((_) { if (!widget.isPlaying) _controller.reset(); }); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = math.min(constraints.maxWidth, constraints.maxHeight); // Build the static disc once and pass it as child — // AnimatedBuilder will NOT call builder on this subtree each tick. final disc = _VinylDisc(size: size, albumArt: widget.albumArt); return AnimatedBuilder( animation: _controller, child: disc, // <-- built once, reused every frame builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * math.pi, child: child, ); }, ); }, ); } } // ─── Static disc (grooves + label) ─────────────────────────────────────────── class _VinylDisc extends StatelessWidget { final double size; final Uint8List? albumArt; const _VinylDisc({required this.size, this.albumArt}); @override Widget build(BuildContext context) { return SizedBox.square( dimension: size, child: Stack( children: [ // Grooves — wrapped in RepaintBoundary so it is rasterised once // and never redrawn unless size changes Positioned.fill( child: RepaintBoundary( child: CustomPaint(painter: _VinylPainter()), ), ), // Album art label in the centre Center( child: _AlbumArtLabel(size: size * 0.38, albumArt: albumArt), ), ], ), ); } } // ─── Album art label with crossfade ────────────────────────────────────────── class _AlbumArtLabel extends StatelessWidget { final double size; final Uint8List? albumArt; const _AlbumArtLabel({required this.size, this.albumArt}); @override Widget build(BuildContext context) { return SizedBox.square( dimension: size, child: AnimatedSwitcher( duration: const Duration(milliseconds: 500), child: _LabelCircle( // Key on the bytes reference so switcher triggers on art change key: ValueKey(albumArt?.hashCode), size: size, albumArt: albumArt, ), ), ); } } class _LabelCircle extends StatelessWidget { final double size; final Uint8List? albumArt; const _LabelCircle({super.key, required this.size, this.albumArt}); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF1A1A1A), image: albumArt != null ? DecorationImage( image: MemoryImage(albumArt!), fit: BoxFit.cover, ) : null, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.4), blurRadius: 8, spreadRadius: 2, ), ], ), child: albumArt == null ? const Center( child: Icon(Icons.music_note, color: Colors.white38, size: 32), ) : null, ); } } // ─── Groove painter — shouldRepaint = false, wrapped in RepaintBoundary ─────── class _VinylPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final centre = Offset(size.width / 2, size.height / 2); final radius = size.width / 2; // Base disc canvas.drawCircle( centre, radius, Paint()..color = const Color(0xFF0D0D0D), ); // Soft radial fade at the outer edge — blends into the background // instead of leaving a hard ring final edgeFade = Paint() ..shader = RadialGradient( center: Alignment.center, radius: 1.0, colors: [ Colors.transparent, Colors.transparent, Colors.black.withOpacity(0.45), Colors.black.withOpacity(0.85), ], stops: const [0.0, 0.78, 0.92, 1.0], ).createShader(Rect.fromCircle(center: centre, radius: radius)); canvas.drawCircle(centre, radius, edgeFade); final groovePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.6; const grooveCount = 28; final innerEdge = radius * 0.42; final outerEdge = radius * 0.94; final step = (outerEdge - innerEdge) / grooveCount; for (int i = 0; i < grooveCount; i++) { final r = innerEdge + i * step; final brightness = (i % 2 == 0) ? 0x2A : 0x22; groovePaint.color = Color.fromARGB(255, brightness, brightness, brightness); canvas.drawCircle(centre, r, groovePaint); } // Sheen final sheen = Paint() ..shader = RadialGradient( center: const Alignment(-0.35, -0.35), radius: 0.85, colors: [Colors.white.withOpacity(0.07), Colors.transparent], ).createShader(Rect.fromCircle(center: centre, radius: radius)); canvas.drawCircle(centre, radius, sheen); // Rim canvas.drawCircle( centre, radius - 1, Paint() ..color = Colors.white.withOpacity(0.08) ..style = PaintingStyle.stroke ..strokeWidth = 1.5, ); } @override bool shouldRepaint(_VinylPainter old) => false; }