f338752e75
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
617 lines
17 KiB
Dart
617 lines
17 KiB
Dart
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<ClassicSkin>? skinFuture;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FutureBuilder<ClassicSkin>(
|
||
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<void> _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<int> 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<int> 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<double>.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<double> 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<int> 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<void> _close() async {
|
||
await subWindowChannel.invokeMethod("eqClose");
|
||
}
|
||
}
|