Files
dartamp/lib/src/main_window/playlist_window.dart
T
hans 212f8e16d2 feat: Implement window dragging and docking functionality
- Added WindowDragController to manage custom dragging of sub-windows.
- Integrated dragging logic into EqWindow and PlWindow, allowing for smooth window movement.
- Introduced docking logic to snap windows together and maintain their positions relative to each other.
- Implemented persistence of window positions using SharedPreferences.
- Enhanced AppDelegate to support screen bounds and mouse location retrieval for accurate window positioning.
- Updated pubspec.yaml to include screen_retriever dependency.
- Added comprehensive tests for dock controller and dock logic to ensure correct behavior during window interactions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-08-10 15:53:29 +02:00

1042 lines
28 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 _plMainSize = Size(280, 290);
const Size _plShadeSize = Size(280, 14);
const double _plWidth = 280;
const double _plShadeHeight = 14;
const double _listLeft = 12;
const double _listRight = 260;
/// Visible rows for a 290-tall window: `(290 - 58) / pe_fontheight(13)`.
const int _numSongs = 17;
/// Row stride (`pe_fontheight` = 13 for the base-skin Arial 12).
const double _rowStride = 13;
/// First row's top in song-list-local coordinates (absolute 22 - 20).
const double _rowsLocalTop = 2;
/// Vertically centers the 6 px bitmap glyphs in the 13 px line.
const double _rowGlyphOffset = 4;
/// The green of the `text.bmp` digital font (`mfont_fgcolor`).
const Color _displayGreen = Color(0xFF00E200);
/// One playlist entry.
class _Track {
const _Track(this.name, this.seconds);
final String name;
final int seconds;
}
/// Stand-in playlist (25 tracks so the vertical scrollbar has range: 25 − 17).
const List<_Track> _playlist = [
_Track("01. In Your Eyes - Peter Gabriel", 335),
_Track("02. Sledgehammer - Peter Gabriel", 312),
_Track("03. Steam - Peter Gabriel", 384),
_Track("04. Games Without Frontiers - Peter Gabriel", 275),
_Track("05. Shock the Monkey - Peter Gabriel", 344),
_Track("06. Red Rain - Peter Gabriel", 322),
_Track("07. Digging in the Dirt - Peter Gabriel", 353),
_Track("08. Big Time - Peter Gabriel", 294),
_Track("09. Don't Give Up - Peter Gabriel", 402),
_Track("10. Solsbury Hill - Peter Gabriel", 262),
_Track("11. Biko - Peter Gabriel", 435),
_Track("12. Darkness - Peter Gabriel", 381),
_Track("13. Mercy Street - Peter Gabriel", 379),
_Track("14. San Jacinto - Peter Gabriel", 421),
_Track("15. No Self Control - Peter Gabriel", 296),
_Track("16. Wallflower - Peter Gabriel", 442),
_Track("17. Here Comes the Flood - Peter Gabriel", 303),
_Track("18. I Have the Touch - Peter Gabriel", 315),
_Track("19. Family Snapshot - Peter Gabriel", 285),
_Track("20. Intruder - Peter Gabriel", 294),
_Track("21. I Don't Remember - Peter Gabriel", 264),
_Track("22. Mother of Violence - Peter Gabriel", 298),
_Track("23. Lead a Normal Life - Peter Gabriel", 255),
_Track("24. Humdrum - Peter Gabriel", 203),
_Track("25. On the Air - Peter Gabriel", 266),
];
String _fmtTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
if (seconds >= 3600) {
final h = seconds ~/ 3600;
return "$h:${m.toString().padLeft(2, "0")}:${s.toString().padLeft(2, "0")}";
}
return "$m:${s.toString().padLeft(2, "0")}";
}
/// Draws `text.bmp` glyphs with the background chroma-keyed out and tinted to
/// a target color, so the green digital font can render as white/yellow/etc.
class _KeyedFont {
_KeyedFont(this.font);
final TextFont font;
final Map<Color, Paint> _paints = {};
Paint _paintFor(Color color) {
return _paints.putIfAbsent(color, () {
return Paint()
..filterQuality = FilterQuality.none
..colorFilter = ColorFilter.matrix(<double>[
0,
0,
0,
0,
color.r * 255,
0,
0,
0,
0,
color.g * 255,
0,
0,
0,
0,
color.b * 255,
-1.5,
3.0,
-1.5,
0,
-1,
]);
});
}
/// Draws [text] left-to-right at ([x], [y]); `clipWidth` clips the line.
void draw(
Canvas canvas,
String text,
double x,
double y,
Color color, {
double? clipWidth,
}) {
if (text.isEmpty) return;
final paint = _paintFor(color);
canvas.save();
if (clipWidth != null) {
canvas.clipRect(Rect.fromLTWH(x, y, clipWidth, TextFont.charHeight));
}
var xp = x;
for (final ch in text.characters) {
canvas.drawImageRect(
font.image,
font.glyphRect(ch),
Rect.fromLTWH(xp, y, TextFont.charWidth, TextFont.charHeight),
paint,
);
xp += TextFont.charWidth;
}
canvas.restore();
}
}
/// The Playlist window, rendered directly from the skin bitmaps.
///
/// Lives in its own OS window (its own Flutter engine); the parent
/// [SubWindowRoot] manages native window sizing and cross-window messaging.
class PlWindow extends StatelessWidget {
const PlWindow({
required this.player,
this.skinFuture,
this.dragController,
super.key,
});
final PlayerState player;
final Future<ClassicSkin>? skinFuture;
final WindowDragController? dragController;
@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: WindowDragSurface(
dragController: dragController,
onWindowShadeToggle: player.togglePlShade,
child: _PlWindowView(skin: snapshot.data!, player: player),
),
);
},
);
}
}
class _PlWindowView extends StatelessWidget {
const _PlWindowView({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.plShade ? _plShadeSize : _plMainSize;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
top: 0,
child: SizedBox.fromSize(
size: size,
child: player.plShade
? _PlShadeLayout(skin: skin, player: player)
: _PlMainLayout(skin: skin, player: player),
),
),
],
);
},
);
}
}
/// The full 280×290 Playlist window (normal mode).
class _PlMainLayout extends StatefulWidget {
const _PlMainLayout({required this.skin, required this.player});
final ClassicSkin skin;
final PlayerState player;
@override
State<_PlMainLayout> createState() => _PlMainLayoutState();
}
class _PlMainLayoutState extends State<_PlMainLayout> {
int _offset = 0;
int _selected = -1;
bool _dragging = false;
_KeyedFont? _keyedFont;
_KeyedFont get _keyed {
return _keyedFont ??= _KeyedFont(TextFont(widget.skin.image("text.bmp")!));
}
void _onTapDown(TapDownDetails details) {
if (details.localPosition.dx >= _listRight) return;
final row = ((details.localPosition.dy - _rowsLocalTop) / _rowStride)
.floor();
final absIndex = _offset + row;
if (row >= 0 && row < _numSongs && absIndex < _playlist.length) {
setState(() => _selected = absIndex);
}
}
void _onDragStart(DragStartDetails details) {
if (details.localPosition.dx >= _listRight) {
_dragging = true;
}
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_dragging) return;
final t = _playlist.length - _numSongs;
final pos = ((details.localPosition.dy / 214) * t)
.round()
.clamp(0, t)
.toInt();
if (pos != _offset) setState(() => _offset = pos);
}
void _onDragEnd(DragEndDetails details) => _dragging = false;
void _onDragCancel() => _dragging = false;
String _infoString() {
final selTime = _selected >= 0 ? _playlist[_selected].seconds : 0;
final totalTime = _playlist.fold<int>(0, (sum, t) => sum + t.seconds);
return "${_fmtTime(selTime)}/${_fmtTime(totalTime)}";
}
@override
Widget build(BuildContext context) {
final skin = widget.skin;
final player = widget.player;
final pledit = skin.image("pledit.bmp")!;
final elapsedSec = player.position * player.durationSec ~/ 256;
return Stack(
children: [
const Positioned.fill(
child: ColoredBox(color: Color(0xFF000000)),
),
Positioned(
top: 0,
left: 0,
child: CustomPaint(
size: const Size(_plWidth, 20),
painter: _PlTitlebarPainter(image: pledit, active: true),
),
),
Positioned(
top: 20,
left: 0,
child: CustomPaint(
size: const Size(_plWidth, 232),
painter: _PlBodyPainter(pledit),
),
),
Positioned(
top: 20,
left: 0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _onTapDown,
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
onVerticalDragCancel: _onDragCancel,
child: CustomPaint(
size: const Size(_plWidth, 232),
painter: _PlSongListPainter(
skin: skin,
keyed: _keyed,
offset: _offset,
selected: _selected,
dragging: _dragging,
),
),
),
),
Positioned(
top: 252,
left: 0,
child: CustomPaint(
size: const Size(_plWidth, 38),
painter: _PlBottomPainter(pledit),
),
),
_PlButtons(pledit: pledit),
Positioned(
top: 262,
left: 137,
child: CustomPaint(
size: const Size(90, 6),
painter: _PlInfoPainter(keyed: _keyed, text: _infoString()),
),
),
Positioned(
top: 275,
left: 194,
child: CustomPaint(
size: const Size(32, 6),
painter: _PlTimePainter(
keyed: _keyed,
minutes: elapsedSec ~/ 60,
seconds: elapsedSec % 60,
),
),
),
_PlTitleButtons(pledit: pledit, player: player),
],
);
}
}
/// The 20 px titlebar strip (`draw_pe_tbar`), tiling `pledit.bmp` row 0/21.
class _PlTitlebarPainter extends CustomPainter {
const _PlTitlebarPainter({required this.image, required this.active});
final ui.Image image;
final bool active;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..filterQuality = FilterQuality.none;
final state = active ? 0.0 : 21.0;
final w = size.width;
var xp = 0.0;
canvas.drawImageRect(
image,
Rect.fromLTWH(0, state, 25, 20),
Rect.fromLTWH(xp, 0, 25, 20),
paint,
);
xp += 25;
var nt = ((w - 150) / 25).floor();
if ((nt & 1) != 0) {
canvas.drawImageRect(
image,
Rect.fromLTWH(127, state, 12, 20),
Rect.fromLTWH(xp, 0, 12, 20),
paint,
);
xp += 12;
}
nt ~/= 2;
while (nt > 0) {
canvas.drawImageRect(
image,
Rect.fromLTWH(127, state, 25, 20),
Rect.fromLTWH(xp, 0, 25, 20),
paint,
);
xp += 25;
nt--;
}
canvas.drawImageRect(
image,
Rect.fromLTWH(26, state, 100, 20),
Rect.fromLTWH(xp, 0, 100, 20),
paint,
);
xp += 100;
nt = ((w - 150) / 25).floor();
if ((nt & 1) != 0) {
canvas.drawImageRect(
image,
Rect.fromLTWH(127, state, 13, 20),
Rect.fromLTWH(xp, 0, 13, 20),
paint,
);
xp += 13;
}
nt ~/= 2;
while (nt > 0) {
canvas.drawImageRect(
image,
Rect.fromLTWH(127, state, 25, 20),
Rect.fromLTWH(xp, 0, 25, 20),
paint,
);
xp += 25;
nt--;
}
final rem = (w - 150) % 25;
if (rem > 0) {
canvas.drawImageRect(
image,
Rect.fromLTWH(127, state, 25, 20),
Rect.fromLTWH(xp, 0, rem, 20),
paint,
);
xp += rem;
}
canvas.drawImageRect(
image,
Rect.fromLTWH(153, state, 25, 20),
Rect.fromLTWH(xp, 0, 25, 20),
paint,
);
}
@override
bool shouldRepaint(_PlTitlebarPainter oldDelegate) {
return oldDelegate.active != active;
}
}
/// The 14 px windowshade titlebar strip (`draw_pe_tbar` shade path).
class _PlShadeTitlebarPainter extends CustomPainter {
const _PlShadeTitlebarPainter({required this.image, required this.active});
final ui.Image image;
final bool active;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..filterQuality = FilterQuality.none;
final state = active ? 57.0 : 42.0;
final w = size.width;
var xp = 0.0;
canvas.drawImageRect(
image,
const Rect.fromLTWH(72, 42, 25, 14),
Rect.fromLTWH(xp, 0, 25, 14),
paint,
);
xp += 25;
var n = ((w - 50) / 25).floor() - 1;
while (n > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(72, 57, 25, 14),
Rect.fromLTWH(xp, 0, 25, 14),
paint,
);
xp += 25;
n--;
}
final rem = (w - 50) % 25;
if (rem > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(72, 57, 24, 14),
Rect.fromLTWH(xp, 0, rem, 14),
paint,
);
xp += rem;
}
canvas.drawImageRect(
image,
Rect.fromLTWH(99, state, 50, 14),
Rect.fromLTWH(xp, 0, 50, 14),
paint,
);
}
@override
bool shouldRepaint(_PlShadeTitlebarPainter oldDelegate) {
return oldDelegate.active != active;
}
}
/// The left/right 29 px body border tiles (`draw_pl`).
class _PlBodyPainter extends CustomPainter {
const _PlBodyPainter(this.image);
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..filterQuality = FilterQuality.none;
final w = size.width;
var yp = 0.0;
var count = (size.height / 29).floor();
while (count > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(0, 42, 12, 29),
Rect.fromLTWH(0, yp, 12, 29),
paint,
);
canvas.drawImageRect(
image,
const Rect.fromLTWH(31, 42, 5, 29),
Rect.fromLTWH(w - 20, yp, 5, 29),
paint,
);
canvas.drawImageRect(
image,
const Rect.fromLTWH(44, 42, 7, 29),
Rect.fromLTWH(w - 7, yp, 7, 29),
paint,
);
yp += 29;
count--;
}
final rem = size.height % 29;
if (rem > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(0, 42, 12, 29),
Rect.fromLTWH(0, yp, 12, rem),
paint,
);
canvas.drawImageRect(
image,
const Rect.fromLTWH(31, 42, 5, 29),
Rect.fromLTWH(w - 20, yp, 5, rem),
paint,
);
canvas.drawImageRect(
image,
const Rect.fromLTWH(44, 42, 7, 29),
Rect.fromLTWH(w - 7, yp, 7, rem),
paint,
);
}
}
@override
bool shouldRepaint(_PlBodyPainter oldDelegate) {
return oldDelegate.image != image;
}
}
/// The 38 px bottom bar (`draw_pl`), including the five button-column backs.
class _PlBottomPainter extends CustomPainter {
const _PlBottomPainter(this.image);
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..filterQuality = FilterQuality.none;
final w = size.width;
canvas.drawImageRect(
image,
const Rect.fromLTWH(0, 72, 125, 38),
const Rect.fromLTWH(0, 0, 125, 38),
paint,
);
var xp = 125.0;
final stretch = w - 125 - 150;
var count = (stretch / 25).floor();
var wide = false;
if (count >= 3) {
count -= 3;
wide = true;
}
while (count > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(179, 0, 25, 38),
Rect.fromLTWH(xp, 0, 25, 38),
paint,
);
xp += 25;
count--;
}
final rem = stretch % 25;
if (rem > 0) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(179, 0, 25, 38),
Rect.fromLTWH(xp, 0, rem, 38),
paint,
);
xp += rem;
}
if (wide) {
canvas.drawImageRect(
image,
const Rect.fromLTWH(205, 0, 75, 38),
Rect.fromLTWH(xp, 0, 75, 38),
paint,
);
xp += 75;
}
canvas.drawImageRect(
image,
const Rect.fromLTWH(126, 72, 150, 38),
Rect.fromLTWH(xp, 0, 150, 38),
paint,
);
}
@override
bool shouldRepaint(_PlBottomPainter oldDelegate) {
return oldDelegate.image != image;
}
}
/// The song rows and vertical scrollbar (`draw_pl2` + `draw_pe_vslide`).
class _PlSongListPainter extends CustomPainter {
_PlSongListPainter({
required this.skin,
required this.keyed,
required this.offset,
required this.selected,
required this.dragging,
});
final ClassicSkin skin;
final _KeyedFont keyed;
final int offset;
final int selected;
final bool dragging;
@override
void paint(Canvas canvas, Size size) {
_paintRows(canvas);
_paintScrollbar(canvas);
}
void _paintRows(Canvas canvas) {
final colors = skin.pledit;
canvas.drawRect(
const Rect.fromLTWH(_listLeft, 0, _listRight - _listLeft, 3),
Paint()..color = colors.normalBg,
);
const rowsBottom = _rowsLocalTop + _numSongs * _rowStride;
canvas.drawRect(
const Rect.fromLTWH(
_listLeft,
rowsBottom,
_listRight - _listLeft,
232 - rowsBottom,
),
Paint()..color = colors.normalBg,
);
final visible = (_playlist.length - offset).clamp(0, _numSongs);
for (var i = 0; i < visible; i++) {
final absIndex = offset + i;
final y = _rowsLocalTop + i * _rowStride;
final isCurrent = absIndex == 0;
final isSelected = absIndex == selected;
canvas.drawRect(
Rect.fromLTWH(_listLeft, y, _listRight - _listLeft, _rowStride),
Paint()..color = isSelected ? colors.selectedBg : colors.normalBg,
);
final color = isCurrent ? colors.current : colors.normal;
_drawRowText(canvas, _playlist[absIndex], y + _rowGlyphOffset, color);
}
}
void _drawRowText(Canvas canvas, _Track track, double y, Color color) {
final time = _fmtTime(track.seconds);
final timeLen = time.length;
final nameBudget = 49 - (timeLen + 1);
var name = track.name;
if (name.length > nameBudget) {
name = "${name.substring(0, nameBudget - 3)}...";
}
keyed.draw(
canvas,
name,
_listLeft,
y,
color,
clipWidth: nameBudget * TextFont.charWidth,
);
keyed.draw(
canvas,
" ",
_listLeft + name.length * TextFont.charWidth,
y,
color,
);
keyed.draw(
canvas,
time,
_plWidth - 24 - timeLen * TextFont.charWidth,
y,
color,
);
}
void _paintScrollbar(Canvas canvas) {
final pledit = skin.image("pledit.bmp")!;
const trackH = 232;
const slidH = 18;
final t = _playlist.length - _numSongs;
int yp;
if (t < 1) {
yp = 0;
} else {
final p = offset.clamp(0, t);
yp = (214 * p) ~/ t;
}
const x = _plWidth - 15;
final paint = Paint()..filterQuality = FilterQuality.none;
var y = 0;
for (; y < yp - 28; y += 29) {
canvas.drawImageRect(
pledit,
const Rect.fromLTWH(36, 42, 8, 29),
Rect.fromLTWH(x, y.toDouble(), 8, 29),
paint,
);
}
var oy = y + 29;
if (slidH + yp > oy) oy += 29;
if (y < yp) {
canvas.drawImageRect(
pledit,
Rect.fromLTWH(36, 42, 8, (yp - y).toDouble()),
Rect.fromLTWH(x, y.toDouble(), 8, (yp - y).toDouble()),
paint,
);
}
canvas.drawImageRect(
pledit,
Rect.fromLTWH(dragging ? 61.0 : 52.0, 53, 8, 18),
Rect.fromLTWH(x, yp.toDouble(), 8, 18),
paint,
);
y = yp + 18;
if (y < oy && y < trackH) {
final h = y < trackH - 29 ? oy - y : trackH - y;
canvas.drawImageRect(
pledit,
Rect.fromLTWH(36, (42 + 29 - (oy - y)).toDouble(), 8, h.toDouble()),
Rect.fromLTWH(x, y.toDouble(), 8, h.toDouble()),
paint,
);
}
y = oy;
for (; y < trackH; y += 29) {
final h = y < trackH - 29 ? 29.0 : (trackH - y).toDouble();
canvas.drawImageRect(
pledit,
const Rect.fromLTWH(36, 42, 8, 29),
Rect.fromLTWH(x, y.toDouble(), 8, h),
paint,
);
}
}
@override
bool shouldRepaint(_PlSongListPainter oldDelegate) {
return oldDelegate.offset != offset ||
oldDelegate.selected != selected ||
oldDelegate.dragging != dragging;
}
}
/// The bottom-left `seltime/totaltime` string (`draw_pe_infostr`).
class _PlInfoPainter extends CustomPainter {
const _PlInfoPainter({required this.keyed, required this.text});
final _KeyedFont keyed;
final String text;
@override
void paint(Canvas canvas, Size size) {
var s = text;
s = s.length > 18 ? s.substring(0, 18) : s.padRight(18, " ");
keyed.draw(canvas, s, 0, 0, _displayGreen);
}
@override
bool shouldRepaint(_PlInfoPainter oldDelegate) {
return oldDelegate.text != text;
}
}
/// The bottom-right `M:SS` elapsed display (`draw_pe_timedisp`).
class _PlTimePainter extends CustomPainter {
const _PlTimePainter({
required this.keyed,
required this.minutes,
required this.seconds,
});
final _KeyedFont keyed;
final int minutes;
final int seconds;
@override
void paint(Canvas canvas, Size size) {
final m = minutes.clamp(0, 599);
final s = seconds.clamp(0, 59);
if (m < 100) {
keyed.draw(canvas, " ", 0, 0, _displayGreen, clipWidth: 4);
keyed.draw(canvas, " ", 4, 0, _displayGreen, clipWidth: 3);
keyed.draw(canvas, "${(m ~/ 10) % 10}", 9, 0, _displayGreen);
} else {
keyed.draw(canvas, " ", 0, 0, _displayGreen, clipWidth: 3);
keyed.draw(canvas, "${(m ~/ 100) % 10}", 4, 0, _displayGreen);
keyed.draw(canvas, "${(m ~/ 10) % 10}", 9, 0, _displayGreen);
}
keyed.draw(canvas, "${m % 10}", 14, 0, _displayGreen);
keyed.draw(canvas, "${(s ~/ 10) % 10}", 22, 0, _displayGreen);
keyed.draw(canvas, "${s % 10}", 27, 0, _displayGreen);
}
@override
bool shouldRepaint(_PlTimePainter oldDelegate) {
return oldDelegate.minutes != minutes || oldDelegate.seconds != seconds;
}
}
/// The 5 button columns (add/rem/sel/misc/load) over the bottom bar.
class _PlButtons extends StatelessWidget {
const _PlButtons({required this.pledit});
final ui.Image pledit;
static const List<(double, double, Rect)> _bars = [
(11, 224, Rect.fromLTWH(48, 111, 3, 54)),
(40, 206, Rect.fromLTWH(100, 111, 3, 72)),
(69, 224, Rect.fromLTWH(150, 111, 3, 54)),
(98, 224, Rect.fromLTWH(200, 111, 3, 54)),
(_plWidth - 47, 224, Rect.fromLTWH(250, 111, 3, 54)),
];
static const List<(double, double, Rect)> _buttons = [
(14, 224, Rect.fromLTWH(0, 111, 22, 18)),
(14, 242, Rect.fromLTWH(0, 130, 22, 18)),
(14, 260, Rect.fromLTWH(0, 149, 22, 18)),
(43, 206, Rect.fromLTWH(54, 168, 22, 18)),
(43, 224, Rect.fromLTWH(54, 111, 22, 18)),
(43, 242, Rect.fromLTWH(54, 130, 22, 18)),
(43, 260, Rect.fromLTWH(54, 149, 22, 18)),
(72, 224, Rect.fromLTWH(104, 111, 22, 18)),
(72, 242, Rect.fromLTWH(104, 130, 22, 18)),
(72, 260, Rect.fromLTWH(104, 149, 22, 18)),
(101, 224, Rect.fromLTWH(154, 111, 22, 18)),
(101, 242, Rect.fromLTWH(154, 130, 22, 18)),
(101, 260, Rect.fromLTWH(154, 149, 22, 18)),
(_plWidth - 44, 224, Rect.fromLTWH(204, 111, 22, 18)),
(_plWidth - 44, 242, Rect.fromLTWH(204, 130, 22, 18)),
(_plWidth - 44, 260, Rect.fromLTWH(204, 149, 22, 18)),
];
@override
Widget build(BuildContext context) {
return Stack(
children: [
for (final b in _bars)
Positioned(
left: b.$1,
top: b.$2,
child: SkinImage(image: pledit, src: b.$3),
),
for (final b in _buttons)
Positioned(
left: b.$1,
top: b.$2,
child: SkinImage(image: pledit, src: b.$3),
),
],
);
}
}
/// The titlebar shade/close buttons (`draw_pe_tbutton`).
class _PlTitleButtons extends StatelessWidget {
const _PlTitleButtons({required this.pledit, required this.player});
final ui.Image pledit;
final PlayerState player;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: _plWidth - 20,
top: 3,
child: SkinPressButton(
image: pledit,
src: const Rect.fromLTWH(158, 3, 9, 9),
srcPressed: const Rect.fromLTWH(62, 42, 9, 9),
onTap: player.togglePlShade,
),
),
Positioned(
left: _plWidth - 11,
top: 3,
child: SkinPressButton(
image: pledit,
src: const Rect.fromLTWH(167, 3, 9, 9),
srcPressed: const Rect.fromLTWH(52, 42, 9, 9),
onTap: () => unawaited(_close()),
),
),
],
);
}
static Future<void> _close() async {
await subWindowChannel.invokeMethod("plClose");
}
}
/// Windowshade (280×14) built from `pledit.bmp`.
class _PlShadeLayout extends StatelessWidget {
const _PlShadeLayout({required this.skin, required this.player});
final ClassicSkin skin;
final PlayerState player;
@override
Widget build(BuildContext context) {
final pledit = skin.image("pledit.bmp")!;
final keyed = _KeyedFont(TextFont(skin.image("text.bmp")!));
return Stack(
children: [
CustomPaint(
size: const Size(_plWidth, _plShadeHeight),
painter: _PlShadeTitlebarPainter(image: pledit, active: true),
),
CustomPaint(
size: const Size(_plWidth, _plShadeHeight),
painter: _PlShadeSongPainter(keyed: keyed, track: _playlist[0]),
),
Positioned(
left: _plWidth - 20,
top: 3,
child: SkinPressButton(
image: pledit,
src: const Rect.fromLTWH(128, 45, 9, 9),
srcPressed: const Rect.fromLTWH(150, 42, 9, 9),
onTap: player.togglePlShade,
),
),
Positioned(
left: _plWidth - 11,
top: 3,
child: SkinPressButton(
image: pledit,
src: const Rect.fromLTWH(167, 3, 9, 9),
srcPressed: const Rect.fromLTWH(52, 42, 9, 9),
onTap: () => unawaited(_close()),
),
),
],
);
}
static Future<void> _close() async {
await subWindowChannel.invokeMethod("plClose");
}
}
/// The windowshade current-song line (`draw_pe_song` with `pos = -1`).
class _PlShadeSongPainter extends CustomPainter {
const _PlShadeSongPainter({required this.keyed, required this.track});
final _KeyedFont keyed;
final _Track track;
@override
void paint(Canvas canvas, Size size) {
final time = _fmtTime(track.seconds);
final timeLen = time.length;
final budget = 49 - (timeLen + 1);
var name = track.name;
if (name.length > budget) {
name = "${name.substring(0, budget - 3)}...";
}
const y = 4.0;
keyed.draw(
canvas,
name,
4,
y,
_displayGreen,
clipWidth: budget * TextFont.charWidth,
);
keyed.draw(
canvas,
" ",
4 + name.length * TextFont.charWidth,
y,
_displayGreen,
);
keyed.draw(
canvas,
time,
_plWidth - 29 - timeLen * TextFont.charWidth,
y,
_displayGreen,
);
}
@override
bool shouldRepaint(_PlShadeSongPainter oldDelegate) {
return oldDelegate.track != track;
}
}