Files
dartamp/lib/src/main_window/eq_window.dart
T
2026-08-10 09:41:21 +02:00

617 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}