// lib/widgets/tonearm.dart // // Tonearm that toggles play/pause on tap. // Animates smoothly between lifted (paused) and on-record (playing) positions. import 'dart:math' as math; import 'package:flutter/material.dart'; class Tonearm extends StatefulWidget { final bool isPlaying; final ValueChanged? onToggle; const Tonearm({super.key, required this.isPlaying, this.onToggle}); @override State createState() => _TonearmState(); } class _TonearmState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; static const _restAngle = -0.38; static const _playAngle = 0.05; static const _angleRange = _playAngle - _restAngle; double get _currentAngle => _restAngle + _controller.value * _angleRange; Size _lastSize = const Size(300, 300); @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), value: widget.isPlaying ? 1.0 : 0.0, ); } @override void didUpdateWidget(Tonearm old) { super.didUpdateWidget(old); if (widget.isPlaying != old.isPlaying) { widget.isPlaying ? _controller.forward() : _controller.reverse(); } } @override void dispose() { _controller.dispose(); super.dispose(); } // ─── Hit test — tap anywhere near the arm body ──────────────────────────── bool _isNearArm(Offset touch) { final unit = _lastSize.shortestSide; final isLandscape = _lastSize.width > _lastSize.height; final ref = isLandscape ? _lastSize.height : _lastSize.width; final pivot = isLandscape ? Offset(_lastSize.width * 0.5 + ref * 0.42, _lastSize.height * 0.12) : Offset(_lastSize.width * 0.8, _lastSize.height * 0.12); final armLength = unit * 0.5; final hitRadius = unit * 0.14; final a = _currentAngle; Offset toCanvas(double lx, double ly) { final rx = lx * math.cos(a) - ly * math.sin(a); final ry = lx * math.sin(a) + ly * math.cos(a); return pivot + Offset(rx, ry); } final p0 = pivot; final p1 = toCanvas(-armLength * 0.15, armLength * 0.75); final p2 = toCanvas(-armLength * 0.22, armLength); final p3 = toCanvas(-armLength * 0.28, armLength * 1.09); return _distToSegment(touch, p0, p1) < hitRadius || _distToSegment(touch, p1, p2) < hitRadius || _distToSegment(touch, p2, p3) < hitRadius; } double _distToSegment(Offset p, Offset a, Offset b) { final ab = b - a; final ap = p - a; final t = (ap.dx * ab.dx + ap.dy * ab.dy) / (ab.dx * ab.dx + ab.dy * ab.dy + 1e-9); return (p - (a + ab * t.clamp(0.0, 1.0))).distance; } void _onTap(Offset localPosition) { if (!_isNearArm(localPosition)) return; final next = !widget.isPlaying; widget.onToggle?.call(next); } // ─── Build ──────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { _lastSize = Size(constraints.maxWidth, constraints.maxHeight); return GestureDetector( behavior: HitTestBehavior.translucent, onTapUp: (d) => _onTap(d.localPosition), child: AnimatedBuilder( animation: _controller, builder: (context, _) => CustomPaint( painter: _TonearmPainter(angle: _currentAngle, size: _lastSize), size: _lastSize, ), ), ); }); } } // ─── Painter ───────────────────────────────────────────────────────────────── class _TonearmPainter extends CustomPainter { final double angle; final Size size; _TonearmPainter({required this.angle, required this.size}); @override void paint(Canvas canvas, Size size) { final unit = size.shortestSide; final isLandscape = size.width > size.height; final ref = isLandscape ? size.height : size.width; final pivot = isLandscape ? Offset(size.width * 0.5 + ref * 0.42, size.height * 0.12) : Offset(size.width * 0.8, size.height * 0.12); final armLength = unit * 0.5; final headshellExt = armLength * 0.09; canvas.save(); canvas.translate(pivot.dx, pivot.dy); canvas.rotate(angle); final arm = Paint() ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; canvas.drawLine(Offset.zero, Offset(-armLength * 0.15, armLength * 0.75), arm..color = const Color(0xFFB8B8B8)..strokeWidth = unit * 0.012); canvas.drawLine( Offset(-armLength * 0.15, armLength * 0.75), Offset(-armLength * 0.22, armLength), arm..strokeWidth = unit * 0.009); canvas.drawLine( Offset(-armLength * 0.22, armLength), Offset(-armLength * 0.28, armLength + headshellExt), arm..color = const Color(0xFF888888)..strokeWidth = unit * 0.008); canvas.drawCircle( Offset(-armLength * 0.28, armLength + headshellExt * 1.2), unit * 0.009, Paint()..color = const Color(0xFFE0E0E0)); canvas.drawCircle(Offset.zero, unit * 0.022, Paint()..color = const Color(0xFF999999)); canvas.drawCircle(Offset.zero, unit * 0.013, Paint()..color = const Color(0xFF555555)); canvas.restore(); } @override bool shouldRepaint(_TonearmPainter old) => old.angle != angle || old.size != size; }