updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
252
lib/widgets/cassette_view.dart
Normal file
252
lib/widgets/cassette_view.dart
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
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(playbackProvider);
|
||||
_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 const 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: (_, _, _) => const 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue