169 lines
5.6 KiB
Dart
169 lines
5.6 KiB
Dart
// 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<bool>? onToggle;
|
|
|
|
const Tonearm({super.key, required this.isPlaying, this.onToggle});
|
|
|
|
@override
|
|
State<Tonearm> createState() => _TonearmState();
|
|
}
|
|
|
|
class _TonearmState extends State<Tonearm>
|
|
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;
|
|
} |