import "dart:async"; import "dart:ui" as ui; import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter/material.dart"; import "../common_widgets.dart"; import "../player/player_state.dart"; import "../skin/skin_cursors.dart"; import "../windows/sub_window_channel.dart"; const Size _eqMainSize = Size(275, 116); const Size _eqShadeSize = Size(275, 14); /// The Equalizer window, rendered directly from the skin bitmaps. /// /// Mirrors the original C++ `draw_eq.cpp` sprite blits: the window background is /// the top-left 275×116 of `eqmain.bmp`, with the titlebar strip, ON/AUTO and /// presets buttons, the response graph, and 11 draggable sliders (preamp + 10 /// bands) drawn on top. Windowshade mode uses `eq_ex.bmp`. class EqWindow extends StatelessWidget { const EqWindow({required this.player, this.skinFuture, super.key}); final PlayerState player; final Future? skinFuture; @override Widget build(BuildContext context) { return FutureBuilder( future: skinFuture ?? loadBaseSkin(), builder: (context, snapshot) { if (snapshot.hasError) { return ColoredBox( color: const Color(0xFF00FF00), child: Center(child: Text("Skin load failed: ${snapshot.error}")), ); } if (!snapshot.hasData) { return const ColoredBox(color: Colors.black); } return SkinCursorsGate( skin: snapshot.data!, child: DragSurface( draggable: true, onWindowShadeToggle: player.toggleEqShade, child: _EqWindowView(skin: snapshot.data!, player: player), ), ); }, ); } } class _EqWindowView extends StatelessWidget { const _EqWindowView({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: player, builder: (context, _) { final size = player.eqShade ? _eqShadeSize : _eqMainSize; return Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, top: 0, child: SizedBox.fromSize( size: size, child: player.eqShade ? _EqShadeLayout(skin: skin, player: player) : _EqMainLayout(skin: skin, player: player), ), ), ], ); }, ); } } /// The full 275×116 Equalizer window (normal mode). class _EqMainLayout extends StatelessWidget { const _EqMainLayout({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; return Stack( children: [ Positioned( left: 0, top: 0, child: SkinImage( image: eqmain, src: const Rect.fromLTWH(0, 0, 275, 116), width: 275, height: 116, ), ), Positioned( left: 0, top: 0, child: SkinImage( image: eqmain, src: const Rect.fromLTWH(0, 134, 275, 14), width: 275, height: 14, ), ), _EqOnAutoButtons(skin: skin, player: player), _EqPresetsButton(skin: skin, player: player), _EqGraph(skin: skin, player: player), _EqSliders(skin: skin, player: player), _EqTitleButtons(skin: skin, player: player), ], ); } } /// Windowshade (275×14) built from `eq_ex.bmp`. class _EqShadeLayout extends StatelessWidget { const _EqShadeLayout({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqex = skin.image("eq_ex.bmp")!; return Stack( children: [ Positioned( left: 0, top: 0, child: SkinImage( image: eqex, src: const Rect.fromLTWH(0, 0, 275, 14), width: 275, height: 14, ), ), _EqWsSlider( image: eqex, left: _wsVolumeThumbX(player.volume), thumbSrcX: player.volume * 3 ~/ 256 * 3 + 1, value: player.volume, onChanged: player.setVolume, ), _EqWsSlider( image: eqex, left: _wsBalanceThumbX(player.balance), thumbSrcX: (player.balance + 128) * 3 ~/ 256 * 3 + 11, value: player.balance, onChanged: player.setBalance, ), Positioned( left: 254, top: 3, child: SkinPressButton( image: eqex, src: const Rect.fromLTWH(254, 3, 9, 9), srcPressed: const Rect.fromLTWH(1, 47, 9, 9), onTap: player.toggleEqShade, ), ), Positioned( left: 264, top: 3, child: SkinPressButton( image: eqex, src: const Rect.fromLTWH(11, 38, 9, 9), srcPressed: const Rect.fromLTWH(11, 47, 9, 9), onTap: () => unawaited(_close()), ), ), ], ); } static double _wsVolumeThumbX(int volume) { final r = 63 + (94 * volume) / 255; return r - 2; } static double _wsBalanceThumbX(int balance) { final r = 166 + (39 * (balance + 128)) / 255; return r - 2; } static Future _close() async { await subWindowChannel.invokeMethod("eqClose"); } } /// The 3×7 windowshade volume/balance thumbs from `eq_ex.bmp` row 30. class _EqWsSlider extends StatelessWidget { const _EqWsSlider({ required this.image, required this.left, required this.thumbSrcX, required this.value, required this.onChanged, }); final ui.Image image; final double left; final int thumbSrcX; final int value; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Positioned( left: left, top: 4, width: 94, height: 7, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanDown: (details) => _setFrom(details.localPosition.dx), onPanUpdate: (details) => _setFrom(details.localPosition.dx), onTapDown: (details) => _setFrom(details.localPosition.dx), child: SkinImage( image: image, src: Rect.fromLTWH(thumbSrcX.toDouble(), 30, 3, 7), width: 3, height: 7, ), ), ); } void _setFrom(double dx) { onChanged((dx / 94 * 255).round().clamp(0, 255)); } } /// The ON/AUTO toggle buttons (`draw_eq_onauto`). class _EqOnAutoButtons extends StatelessWidget { const _EqOnAutoButtons({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; final onX = 10 + (player.eqEnabled ? 59 : 0); final autoX = 35 + (player.eqAuto ? 59 : 0); return Stack( children: [ Positioned( left: 14, top: 18, child: SkinPressButton( image: eqmain, src: Rect.fromLTWH(onX.toDouble(), 119, 25, 12), srcPressed: Rect.fromLTWH((onX + 118).toDouble(), 119, 25, 12), onTap: () => player.setEqEnabled(!player.eqEnabled), ), ), Positioned( left: 39, top: 18, child: SkinPressButton( image: eqmain, src: Rect.fromLTWH(autoX.toDouble(), 119, 33, 12), srcPressed: Rect.fromLTWH((autoX + 118).toDouble(), 119, 33, 12), onTap: () => player.setEqAuto(!player.eqAuto), ), ), ], ); } } /// The presets button (`draw_eq_presets`). No presets are wired up yet, so the /// button only shows its pressed state. class _EqPresetsButton extends StatelessWidget { const _EqPresetsButton({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; return Positioned( left: 217, top: 18, child: SkinPressButton( image: eqmain, src: const Rect.fromLTWH(224, 164, 44, 12), srcPressed: const Rect.fromLTWH(224, 176, 44, 12), onTap: () {}, ), ); } } /// The response graph (`draw_eq_graphthingy`): the empty graph backdrop, the /// preamp reference line, and the spline-smoothed curve through the 10 bands. class _EqGraph extends StatelessWidget { const _EqGraph({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; return Positioned( left: 86, top: 17, width: 113, height: 19, child: Stack( children: [ Positioned( left: 0, top: 0, child: SkinImage( image: eqmain, src: const Rect.fromLTWH(0, 294, 113, 19), width: 113, height: 19, ), ), Positioned.fill( child: CustomPaint( painter: _EqCurvePainter( image: eqmain, preamp: player.preamp, bands: player.eqBands, enabled: player.eqEnabled, ), ), ), ], ), ); } } class _EqCurvePainter extends CustomPainter { const _EqCurvePainter({ required this.image, required this.preamp, required this.bands, required this.enabled, }); final ui.Image image; final int preamp; final List bands; final bool enabled; @override void paint(Canvas canvas, Size size) { final paint = Paint()..filterQuality = FilterQuality.none; final lineY = 35 - (preamp * 19 / 64).toInt(); canvas.drawImageRect( image, const Rect.fromLTWH(0, 314, 113, 1), Rect.fromLTWH(0, lineY.toDouble(), 113, 1), paint, ); final keys = List.filled(12, 0); for (var i = 0; i < 10; i++) { keys[i + 1] = (enabled ? bands[i] : 32) * 19 / 64; } keys[0] = keys[1]; keys[11] = keys[10]; for (var x = 0; x < 109; x++) { final p = _splinePoint(keys, 1.0 + x / 12.0); final thisP = p.toInt().clamp(0, 18); canvas.drawImageRect( image, Rect.fromLTWH(115, (294 + thisP).toDouble(), 1, 1), Rect.fromLTWH(2 + x.toDouble(), thisP.toDouble(), 1, 1), paint, ); } } /// A 1-D Kochanek–Bartels spline, mirroring `plSplineGetPoint` from the /// original plush library (`original/Src/Winamp/plush/SPLINE.C`). double _splinePoint(List keys, double frame) { const tens = 0.1; const cont = 0.0; const bias = 0.0; const a = (1 - tens) * (1 + cont) * (1 + bias); const b = (1 - tens) * (1 - cont) * (1 - bias); const c = (1 - tens) * (1 - cont) * (1 + bias); const d = (1 - tens) * (1 + cont) * (1 - bias); const t1 = -a / 2; const v1 = t1; const u1 = a; const u2 = (-6 - 2 * a + 2 * b + c) / 2; const v2 = (a - b) / 2; const t2 = (4 + a - b - c) / 2; const t3 = (-4 + b + c - d) / 2; const u3 = (6 - 2 * b - c + d) / 2; const v3 = b / 2; const t4 = d / 2; const u4 = -t4; final i0 = frame.toInt(); var i_1 = i0 - 1; while (i_1 < 0) { i_1 += 12; } var i1 = i0 + 1; while (i1 >= 12) { i1 -= 12; } var i2 = i0 + 2; while (i2 >= 12) { i2 -= 12; } final time1 = frame - frame.toInt(); final time2 = time1 * time1; final time3 = time2 * time1; final ka = t1 * keys[i_1] + t2 * keys[i0] + t3 * keys[i1] + t4 * keys[i2]; final kb = u1 * keys[i_1] + u2 * keys[i0] + u3 * keys[i1] + u4 * keys[i2]; final kc = v1 * keys[i_1] + v2 * keys[i0] + v3 * keys[i1]; return ka * time3 + kb * time2 + kc * time1 + keys[i0]; } @override bool shouldRepaint(_EqCurvePainter oldDelegate) { return oldDelegate.preamp != preamp || oldDelegate.bands != bands || oldDelegate.enabled != enabled; } } /// The 11 vertical sliders (`draw_eq_slid`): preamp plus the 10 bands. class _EqSliders extends StatelessWidget { const _EqSliders({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; return Stack( children: [ _EqSlider( image: eqmain, x: 21, value: player.preamp, onChanged: player.setPreamp, ), for (var i = 0; i < 10; i++) _EqSlider( image: eqmain, x: (78 + 18 * i).toDouble(), value: player.eqBands[i], onChanged: (v) => player.setEqBand(i, v), ), ], ); } } /// A vertical Equalizer slider: a 14×63 track crop that shifts with the value /// (two source rows encode the filled/empty portions) plus an 11×11 thumb. class _EqSlider extends StatefulWidget { const _EqSlider({ required this.image, required this.x, required this.value, required this.onChanged, }); final ui.Image image; final double x; final int value; final ValueChanged onChanged; @override State<_EqSlider> createState() => _EqSliderState(); } class _EqSliderState extends State<_EqSlider> { bool _pressed = false; static const double _top = 38; static const double _trackHeight = 63; static const double _thumbSize = 11; Rect get _trackSrc { var n = 27 - widget.value * 28 ~/ 64; if (n < 0) n = 0; final row = n < 14 ? 164 : 229; final srcX = n < 14 ? 13 + n * 15 : 13 + (n - 14) * 15; return Rect.fromLTWH(srcX.toDouble(), row.toDouble(), 14, _trackHeight); } double get _thumbY { return (89 - ((63 - widget.value) * 52) ~/ 64).toDouble(); } void _setFrom(double dy) { final clamped = dy.clamp(_top, _top + 51); final pos = 63 - (89 - clamped) * 64 / 52; widget.onChanged(pos.round().clamp(0, 64)); } @override Widget build(BuildContext context) { return Positioned( left: widget.x, top: _top, width: 14, height: _trackHeight, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) { setState(() => _pressed = true); _setFrom(details.localPosition.dy); }, onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), onPanStart: (details) { setState(() => _pressed = true); _setFrom(details.localPosition.dy); }, onPanUpdate: (details) => _setFrom(details.localPosition.dy), onPanEnd: (_) => setState(() => _pressed = false), onPanCancel: () => setState(() => _pressed = false), child: Stack( children: [ SkinImage( image: widget.image, src: _trackSrc, width: 14, height: _trackHeight, ), Positioned( left: 1, top: _thumbY, child: SkinImage( image: widget.image, src: Rect.fromLTWH( 0, _pressed ? 176 : 164, _thumbSize, _thumbSize, ), width: _thumbSize, height: _thumbSize, ), ), ], ), ), ); } } /// The titlebar window-shade and close buttons (`draw_eq_tbutton`, normal /// mode). The shade button sprite is static in skin-based EQ windows. class _EqTitleButtons extends StatelessWidget { const _EqTitleButtons({required this.skin, required this.player}); final ClassicSkin skin; final PlayerState player; @override Widget build(BuildContext context) { final eqmain = skin.image("eqmain.bmp")!; return Stack( children: [ Positioned( left: 254, top: 3, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: player.toggleEqShade, child: SkinImage( image: eqmain, src: const Rect.fromLTWH(254, 137, 9, 9), width: 9, height: 9, ), ), ), Positioned( left: 264, top: 3, child: SkinPressButton( image: eqmain, src: const Rect.fromLTWH(0, 116, 9, 9), srcPressed: const Rect.fromLTWH(0, 125, 9, 9), onTap: () => unawaited(_close()), ), ), ], ); } static Future _close() async { await subWindowChannel.invokeMethod("eqClose"); } }