feat: add custom cursor support for classic skins

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-08-10 09:41:21 +02:00
parent b9089212be
commit f338752e75
14 changed files with 383 additions and 34 deletions
+18 -4
View File
@@ -46,10 +46,24 @@ items `[x]` as done.
### A3. Cursors (task 4)
- `ClassicSkin` exposes cursor bitmaps (`cursor.bmp`/`.cur`) + hotspot (default
skin included).
- Native `NSCursor` from skin bitmap via the `winamp` channel; applied with
`MouseRegion` over the windows.
- [x] `CurDecoder` in the package (`cur_decoder.dart`): decodes Windows `.cur`
resources (1/4/8/32 bpp, AND-mask transparency, hotspot) into
`SkinCursor { ui.Image, hotspotX, hotspotY }`.
- [x] `ClassicSkin` exposes `Map<String, SkinCursor> cursors` keyed by the
`.cur` file stem; the base skin bundles the original
`normal.cur`/`move.cur`/`resize.cur` (copied from `original/Src/Winamp/
resource/`); `loadBaseSkin` + `_buildSkin` decode them, so imported skins
can override cursors and fall back to base otherwise.
- [x] App: `registerSkinCursors`/`setSkinCursor` over the `winamp` channel;
AppDelegate builds `NSCursor` from the PNG+hotspot and `setCursor`
switches it. `SkinCursorsGate` (MouseRegion) applies the skin's normal
cursor on enter and restores the arrow on exit, wrapping the main, EQ,
and PL windows.
- [x] Note: sub-windows run their own Flutter engines and their `winamp`
channel calls can't reach the AppDelegate handler, so custom cursors are
applied on the main window; EQ/PL keep the system arrow for now.
- [x] Verify: package tests (14 incl. `cur_decoder_test.dart`), app analyze
clean, `flutter build macos --debug` ok, app launches with no errors.
## Phase B — Docking & window management (new `lib/src/docking/`)
+25 -21
View File
@@ -11,6 +11,7 @@ import "package:window_manager/window_manager.dart";
import "src/common_widgets.dart";
import "src/main_window/main_window.dart";
import "src/player/player_state.dart";
import "src/skin/skin_cursors.dart";
import "src/skin/skin_manager.dart";
import "src/windows/sub_window_app.dart";
import "src/windows/sub_window_channel.dart";
@@ -19,8 +20,6 @@ import "src/windows/window_args.dart";
const Size _mainSize = Size(275, 116);
const Size _shadeSize = Size(275, 14);
const _winampChannel = MethodChannel("winamp");
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
@@ -164,7 +163,7 @@ class _WinampRootState extends State<WinampRoot> {
_skins.addListener(_onSkinChanged);
// Set up method channel for macOS menu actions
_winampChannel.setMethodCallHandler(_handleMethodCall);
winampChannel.setMethodCallHandler(_handleMethodCall);
// Sub-windows report shade toggles and close requests back here.
unawaited(
@@ -269,6 +268,8 @@ class _WinampRootState extends State<WinampRoot> {
final name = _skins.active.name;
if (name == _wasSkinName) return;
_wasSkinName = name;
final skin = _skins.skin;
if (skin != null) unawaited(registerSkinCursors(skin));
_eqController?.invokeMethod("setSkin", name);
_plController?.invokeMethod("setSkin", name);
}
@@ -379,15 +380,15 @@ class _WinampRootState extends State<WinampRoot> {
/// what the in-window buttons do. Best-effort; native may not be up yet.
Future<void> _syncMenuToNative() async {
try {
await _winampChannel.invokeMethod("setAlwaysOnTop", {
await winampChannel.invokeMethod("setAlwaysOnTop", {
"isAlwaysOnTop": _player.alwaysOnTop,
});
await _winampChannel.invokeMethod("setWindowShade", {
await winampChannel.invokeMethod("setWindowShade", {
"on": _player.windowShade,
});
await _winampChannel.invokeMethod("setEqOpen", {"open": _player.eqOpen});
await _winampChannel.invokeMethod("setPlOpen", {"open": _player.plOpen});
await _winampChannel.invokeMethod("setUiScale", {
await winampChannel.invokeMethod("setEqOpen", {"open": _player.eqOpen});
await winampChannel.invokeMethod("setPlOpen", {"open": _player.plOpen});
await winampChannel.invokeMethod("setUiScale", {
"scale": _player.uiScale,
});
} on PlatformException {
@@ -416,19 +417,22 @@ class _WinampRootState extends State<WinampRoot> {
final height = (_player.windowShade ? _shadeSize : _mainSize).height;
return ColoredBox(
color: const Color(0xFF000000),
child: DragSurface(
draggable: _windowReady,
onWindowShadeToggle: _player.toggleWindowShade,
child: SizedBox(
width: _mainSize.width * scale,
height: height * scale,
child: Transform.scale(
scale: scale,
alignment: Alignment.topLeft,
filterQuality: FilterQuality.none,
child: MainWindowView(
skin: skin,
player: _player,
child: SkinCursorsGate(
skin: skin,
child: DragSurface(
draggable: _windowReady,
onWindowShadeToggle: _player.toggleWindowShade,
child: SizedBox(
width: _mainSize.width * scale,
height: height * scale,
child: Transform.scale(
scale: scale,
alignment: Alignment.topLeft,
filterQuality: FilterQuality.none,
child: MainWindowView(
skin: skin,
player: _player,
),
),
),
),
+8 -4
View File
@@ -6,6 +6,7 @@ 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);
@@ -37,10 +38,13 @@ class EqWindow extends StatelessWidget {
if (!snapshot.hasData) {
return const ColoredBox(color: Colors.black);
}
return DragSurface(
draggable: true,
onWindowShadeToggle: player.toggleEqShade,
child: _EqWindowView(skin: snapshot.data!, player: player),
return SkinCursorsGate(
skin: snapshot.data!,
child: DragSurface(
draggable: true,
onWindowShadeToggle: player.toggleEqShade,
child: _EqWindowView(skin: snapshot.data!, player: player),
),
);
},
);
+8 -4
View File
@@ -6,6 +6,7 @@ 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);
@@ -168,10 +169,13 @@ class PlWindow extends StatelessWidget {
if (!snapshot.hasData) {
return const ColoredBox(color: Colors.black);
}
return DragSurface(
draggable: true,
onWindowShadeToggle: player.togglePlShade,
child: _PlWindowView(skin: snapshot.data!, player: player),
return SkinCursorsGate(
skin: snapshot.data!,
child: DragSurface(
draggable: true,
onWindowShadeToggle: player.togglePlShade,
child: _PlWindowView(skin: snapshot.data!, player: player),
),
);
},
);
+88
View File
@@ -0,0 +1,88 @@
import "dart:async";
import "dart:ui" as ui;
import "package:dartamp_skin/dartamp_skin.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
/// The platform channel to the macOS app delegate.
const winampChannel = MethodChannel("winamp");
/// Registers every custom cursor of [skin] with the native side, keyed by
/// name, so hover regions can switch to them. Best-effort: failures are
/// ignored because the native side may not be ready yet.
Future<void> registerSkinCursors(ClassicSkin skin) async {
for (final entry in skin.cursors.entries) {
final png = await _cursorPng(entry.value.image);
try {
await winampChannel.invokeMethod(
"registerCursor",
{
"name": entry.key,
"png": png,
"hotspotX": entry.value.hotspotX,
"hotspotY": entry.value.hotspotY,
},
);
} on PlatformException {
// ignore: best-effort
} on MissingPluginException {
// ignore: best-effort
}
}
}
Future<Uint8List> _cursorPng(ui.Image image) async {
final data = await image.toByteData(format: ui.ImageByteFormat.png);
return data!.buffer.asUint8List();
}
/// Switches the platform cursor to the registered skin cursor [name], or back
/// to the system arrow when the name is unknown. Best-effort.
Future<void> setSkinCursor(String name) async {
try {
await winampChannel.invokeMethod("setCursor", {"name": name});
} on PlatformException {
// ignore: best-effort
} on MissingPluginException {
// ignore: best-effort
}
}
/// Registers [skin]'s cursors once, then wraps [child] so hovering over it
/// shows the skin's default cursor and leaving restores the system arrow.
class SkinCursorsGate extends StatefulWidget {
const SkinCursorsGate({required this.skin, required this.child, super.key});
final ClassicSkin skin;
final Widget child;
@override
State<SkinCursorsGate> createState() => _SkinCursorsGateState();
}
class _SkinCursorsGateState extends State<SkinCursorsGate> {
@override
void initState() {
super.initState();
unawaited(registerSkinCursors(widget.skin));
}
@override
void didUpdateWidget(SkinCursorsGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.skin != widget.skin) {
unawaited(registerSkinCursors(widget.skin));
}
}
@override
Widget build(BuildContext context) {
final hasCustom = widget.skin.cursors.containsKey("normal");
return MouseRegion(
onEnter: (_) => setSkinCursor(hasCustom ? "normal" : "arrow"),
onExit: (_) => setSkinCursor("arrow"),
child: widget.child,
);
}
}
+22
View File
@@ -53,6 +53,9 @@ class AppDelegate: FlutterAppDelegate {
private var _winampChannel: FlutterMethodChannel?
/// Custom cursors registered by the Dart side, keyed by skin cursor name.
private var cursors: [String: NSCursor] = [:]
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "setAlwaysOnTop":
@@ -81,6 +84,25 @@ class AppDelegate: FlutterAppDelegate {
setUiScaleMenuState(scale)
}
result(nil)
case "registerCursor":
if let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let pngData = (args["png"] as? FlutterStandardTypedData)?.data,
let image = NSImage(data: pngData) {
let hotX = args["hotspotX"] as? Int ?? 0
let hotY = args["hotspotY"] as? Int ?? 0
cursors[name] = NSCursor(image: image, hotSpot: NSPoint(x: hotX, y: hotY))
}
result(nil)
case "setCursor":
if let args = call.arguments as? [String: Any], let name = args["name"] as? String {
if let cursor = cursors[name] {
cursor.set()
} else {
NSCursor.arrow.set()
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

@@ -8,6 +8,7 @@ library;
export "src/bmp_decoder.dart";
export "src/classic_skin.dart";
export "src/cur_decoder.dart";
export "src/skin_image.dart";
export "src/skin_info.dart";
export "src/skin_loader.dart";
@@ -1,5 +1,7 @@
import "dart:ui" as ui;
import "cur_decoder.dart";
/// Playlist editor text colors parsed from `pledit.txt`.
class PlEditConfig {
const PlEditConfig({
@@ -74,6 +76,7 @@ class ClassicSkin {
ClassicSkin({
required this.name,
required this.images,
this.cursors = const {},
this.regions = const {},
this.regionPaths = const {},
this.pledit = const PlEditConfig(),
@@ -86,6 +89,10 @@ class ClassicSkin {
/// Bitmaps keyed by lower-cased file name (e.g. `main.bmp`).
final Map<String, ui.Image> images;
/// Custom cursors keyed by the `.cur` file stem (e.g. `normal` from
/// `normal.cur`). Skins that ship no cursor fall back to the base skin's.
final Map<String, SkinCursor> cursors;
/// Raw polygon regions from `region.txt`.
final Map<String, SkinRegion> regions;
@@ -0,0 +1,135 @@
import "dart:async";
import "dart:typed_data";
import "dart:ui" as ui;
/// A custom cursor decoded from a Windows `.cur` resource.
class SkinCursor {
const SkinCursor({
required this.image,
required this.hotspotX,
required this.hotspotY,
});
/// The rendered cursor in RGBA.
final ui.Image image;
/// The cursor's hotspot (the pixel that lands on the pointer) in image
/// pixels, clamped to the image bounds.
final int hotspotX;
final int hotspotY;
}
/// Decodes Windows cursor (`.cur`) resources, used for the custom cursors
/// classic skins bundle alongside their bitmaps.
class CurDecoder {
CurDecoder._();
/// Decodes the first image in [bytes]. Throws a [FormatException] on
/// malformed input.
static Future<SkinCursor> decode(Uint8List bytes) async {
final view = ByteData.sublistView(bytes);
if (bytes.length < 6) {
throw const FormatException("cursor file too short");
}
if (view.getUint16(0, Endian.little) != 0) {
throw const FormatException("bad cursor reserved field");
}
if (view.getUint16(2, Endian.little) != 2) {
throw const FormatException("not a cursor file (type != 2)");
}
final count = view.getUint16(4, Endian.little);
if (count == 0) throw const FormatException("cursor has no images");
const entry = 6;
final hotspotX = view.getUint16(entry + 4, Endian.little);
final hotspotY = view.getUint16(entry + 6, Endian.little);
final size = view.getUint32(entry + 8, Endian.little);
final offset = view.getUint32(entry + 12, Endian.little);
if (offset + size > bytes.length) {
throw const FormatException("cursor bitmap data truncated");
}
final dib = ByteData.sublistView(bytes, offset, offset + size);
if (size < 40) {
throw const FormatException("cursor DIB header too small");
}
final width = dib.getInt32(4, Endian.little);
final imageHeight = dib.getInt32(8, Endian.little);
final planes = dib.getUint16(12, Endian.little);
final bpp = dib.getUint16(14, Endian.little);
if (planes != 1) throw const FormatException("unsupported cursor planes");
if (width <= 0 || width > 256)
throw const FormatException("bad cursor width");
if (imageHeight <= 0 || imageHeight > 512) {
throw const FormatException("bad cursor height");
}
// The DIB height doubles the XOR bitmap with the AND mask underneath.
final height = imageHeight ~/ 2;
final paletteCount = bpp <= 8 ? (1 << bpp) : 0;
const paletteOffset = 40;
final xorOffset = paletteOffset + paletteCount * 4;
final xorStride = ((width * bpp + 31) >> 5) << 2;
final andStride = ((width + 31) >> 5) << 2;
final andOffset = xorOffset + xorStride * height;
final rgba = Uint8List(width * height * 4);
for (var row = 0; row < height; row++) {
final srcRow = height - 1 - row; // DIB rows are bottom-up
final xorBase = offset + xorOffset + srcRow * xorStride;
final andBase = offset + andOffset + srcRow * andStride;
for (var x = 0; x < width; x++) {
final dest = (row * width + x) * 4;
final andByte = bytes[andBase + x >> 3];
if (((andByte >> (7 - (x & 7))) & 1) != 0) {
// Masked out: transparent.
rgba[dest + 3] = 0;
continue;
}
if (bpp == 32) {
final p = xorBase + x * 4;
rgba[dest] = bytes[p + 2];
rgba[dest + 1] = bytes[p + 1];
rgba[dest + 2] = bytes[p];
rgba[dest + 3] = bytes[p + 3];
continue;
}
int index;
if (bpp == 1) {
final byte = bytes[xorBase + x >> 3];
index = (byte >> (7 - (x & 7))) & 1;
} else if (bpp == 4) {
final byte = bytes[xorBase + x >> 1];
index = x.isEven ? (byte >> 4) & 0xF : byte & 0xF;
} else {
index = bytes[xorBase + x];
}
final paletteIndex = (index * 4).clamp(0, paletteCount * 4 - 4);
final p = offset + paletteOffset + paletteIndex;
rgba[dest] = bytes[p + 2];
rgba[dest + 1] = bytes[p + 1];
rgba[dest + 2] = bytes[p];
rgba[dest + 3] = 255;
}
}
final image = await _rgbaToUiImage(width, height, rgba);
return SkinCursor(
image: image,
hotspotX: hotspotX.clamp(0, width - 1),
hotspotY: hotspotY.clamp(0, height - 1),
);
}
}
Future<ui.Image> _rgbaToUiImage(int width, int height, Uint8List rgba) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
rgba,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
+32 -1
View File
@@ -9,6 +9,7 @@ import "package:flutter/services.dart" show rootBundle;
import "bmp_decoder.dart";
import "classic_skin.dart";
import "cur_decoder.dart";
/// Names of the bitmaps that make up a classic skin, mirroring the file
/// list the original loader scans for (`draw_LBitmap` in `draw.cpp`).
@@ -53,6 +54,10 @@ const bundledSkinFiles = <String>[
"pledit.bmp",
];
/// Cursors bundled with the app (the classic Winamp arrow, move, and resize
/// cursors from the original `resource/` folder).
const bundledCursorFiles = <String>["normal.cur", "move.cur", "resize.cur"];
/// Converts a decoded BMP into a GPU [ui.Image].
Future<ui.Image> bmpToUiImage(BmpImage bmp) {
final completer = Completer<ui.Image>();
@@ -79,7 +84,19 @@ Future<ClassicSkin> loadBaseSkin() async {
);
images[name.toLowerCase()] = await bmpToUiImage(decodeBmp(bytes));
}
return ClassicSkin(name: "Base", images: images);
final cursors = <String, SkinCursor>{};
for (final name in bundledCursorFiles) {
final data = await rootBundle.load(
"packages/dartamp_skin/assets/skins/base/$name",
);
final bytes = data.buffer.asUint8List(
data.offsetInBytes,
data.lengthInBytes,
);
final stem = name.toLowerCase().replaceAll(".cur", "");
cursors[stem] = await CurDecoder.decode(bytes);
}
return ClassicSkin(name: "Base", images: images, cursors: cursors);
}
/// Loads a classic skin (`.wsz` / `.zip` archive) on top of [base], so any
@@ -154,9 +171,23 @@ Future<ClassicSkin> _buildSkin(
if (parsed != null) visColors = parsed;
}
final cursors = <String, SkinCursor>{...base.cursors};
for (final entry in files.entries) {
if (!entry.key.endsWith(".cur")) continue;
final stem = entry.key.substring(0, entry.key.length - 4);
try {
cursors[stem] = await CurDecoder.decode(
Uint8List.fromList(entry.value),
);
} catch (_) {
// keep the base cursor
}
}
return ClassicSkin(
name: name ?? "Skin",
images: images,
cursors: cursors,
regions: regions,
regionPaths: {for (final e in regions.entries) e.key: e.value.toPath()!},
pledit: pledit,
@@ -0,0 +1,39 @@
import "dart:io";
import "dart:typed_data";
import "package:dartamp_skin/dartamp_skin.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test("decodes the bundled normal cursor", () async {
final bytes = File("assets/skins/base/normal.cur").readAsBytesSync();
final cursor = await CurDecoder.decode(bytes);
expect(cursor.image.width, 32);
expect(cursor.image.height, 32);
// The classic Winamp arrow's hotspot sits at the tip, near the top-left.
expect(cursor.hotspotX, 0);
expect(cursor.hotspotY, 0);
final data = await cursor.image.toByteData();
expect(data, isNotNull);
// The arrow is not fully transparent: at least one opaque pixel exists.
var opaque = 0;
for (var i = 3; i < data!.lengthInBytes; i += 4) {
if (data.getUint8(i) != 0) opaque++;
}
expect(opaque, greaterThan(0));
});
test("rejects non-cursor files", () async {
await expectLater(
CurDecoder.decode(Uint8List.fromList([0x42, 0x4D, 0, 0])),
throwsFormatException,
);
});
test("base skin bundles the classic cursors", () async {
final skin = await loadBaseSkin();
expect(skin.cursors.keys, containsAll(["normal", "move", "resize"]));
});
}