This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// The segmented block progress bar from Ratune's now-playing strip
/// The segmented block progress bar from Timbre's now-playing strip
/// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
/// empty cells the border grey, with hairline gaps between them.
class BlockProgressBar extends StatelessWidget {

View 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);
}
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// A thin-stroked panel with its title label sitting *on* the top border —
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
/// Timbre's signature container ("Album Art", "Queue (127)", "Lyrics",
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
/// then overlay the title with a background that occludes the segment behind
/// it, so the label appears to break the line.

38
lib/widgets/toast.dart Normal file
View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/tokens.dart';
/// Shows a themed, transient confirmation toast plus a light haptic tick.
///
/// Rendered as a fixed [SnackBar], so it settles just above the app's bottom
/// chrome (mini-player + tab bar) rather than over it. Styled to match the
/// dense, near-black terminal aesthetic — [TimbreColors.surface] fill, warm
/// [TimbreColors.foreground] text, with the live accent used for the leading
/// [icon]. Any in-flight toast is cleared first so rapid taps don't stack.
void showToast(BuildContext context, String message, {IconData? icon}) {
final accent = Theme.of(context).colorScheme.primary;
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
backgroundColor: TimbreColors.surface,
duration: const Duration(seconds: 2),
content: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: accent),
const SizedBox(width: TimbreSpacing.md),
],
Expanded(
child: Text(
message,
style: const TextStyle(color: TimbreColors.foreground),
),
),
],
),
),
);
}