diff --git a/PLAN.md b/PLAN.md index 565d796..0cf7ed1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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/`) diff --git a/lib/main.dart b/lib/main.dart index 9207210..0976910 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main(List args) async { WidgetsFlutterBinding.ensureInitialized(); @@ -164,7 +163,7 @@ class _WinampRootState extends State { _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 { 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 { /// what the in-window buttons do. Best-effort; native may not be up yet. Future _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 { 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, + ), ), ), ), diff --git a/lib/src/main_window/eq_window.dart b/lib/src/main_window/eq_window.dart index f8dbdb5..11ef572 100644 --- a/lib/src/main_window/eq_window.dart +++ b/lib/src/main_window/eq_window.dart @@ -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), + ), ); }, ); diff --git a/lib/src/main_window/playlist_window.dart b/lib/src/main_window/playlist_window.dart index 9c187c7..96f62fa 100644 --- a/lib/src/main_window/playlist_window.dart +++ b/lib/src/main_window/playlist_window.dart @@ -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), + ), ); }, ); diff --git a/lib/src/skin/skin_cursors.dart b/lib/src/skin/skin_cursors.dart new file mode 100644 index 0000000..41cf459 --- /dev/null +++ b/lib/src/skin/skin_cursors.dart @@ -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 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 _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 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 createState() => _SkinCursorsGateState(); +} + +class _SkinCursorsGateState extends State { + @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, + ); + } +} diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 2f6b91b..c46365b 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -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) } diff --git a/packages/dartamp_skin/assets/skins/base/move.cur b/packages/dartamp_skin/assets/skins/base/move.cur new file mode 100644 index 0000000..8b73d0e Binary files /dev/null and b/packages/dartamp_skin/assets/skins/base/move.cur differ diff --git a/packages/dartamp_skin/assets/skins/base/normal.cur b/packages/dartamp_skin/assets/skins/base/normal.cur new file mode 100644 index 0000000..926f057 Binary files /dev/null and b/packages/dartamp_skin/assets/skins/base/normal.cur differ diff --git a/packages/dartamp_skin/assets/skins/base/resize.cur b/packages/dartamp_skin/assets/skins/base/resize.cur new file mode 100644 index 0000000..107a463 Binary files /dev/null and b/packages/dartamp_skin/assets/skins/base/resize.cur differ diff --git a/packages/dartamp_skin/lib/dartamp_skin.dart b/packages/dartamp_skin/lib/dartamp_skin.dart index 75a0e4f..678bcad 100644 --- a/packages/dartamp_skin/lib/dartamp_skin.dart +++ b/packages/dartamp_skin/lib/dartamp_skin.dart @@ -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"; diff --git a/packages/dartamp_skin/lib/src/classic_skin.dart b/packages/dartamp_skin/lib/src/classic_skin.dart index d0e5378..d748412 100644 --- a/packages/dartamp_skin/lib/src/classic_skin.dart +++ b/packages/dartamp_skin/lib/src/classic_skin.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 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 cursors; + /// Raw polygon regions from `region.txt`. final Map regions; diff --git a/packages/dartamp_skin/lib/src/cur_decoder.dart b/packages/dartamp_skin/lib/src/cur_decoder.dart new file mode 100644 index 0000000..5f62c3d --- /dev/null +++ b/packages/dartamp_skin/lib/src/cur_decoder.dart @@ -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 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 _rgbaToUiImage(int width, int height, Uint8List rgba) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; +} diff --git a/packages/dartamp_skin/lib/src/skin_loader.dart b/packages/dartamp_skin/lib/src/skin_loader.dart index fbda4cb..83e917f 100644 --- a/packages/dartamp_skin/lib/src/skin_loader.dart +++ b/packages/dartamp_skin/lib/src/skin_loader.dart @@ -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 = [ "pledit.bmp", ]; +/// Cursors bundled with the app (the classic Winamp arrow, move, and resize +/// cursors from the original `resource/` folder). +const bundledCursorFiles = ["normal.cur", "move.cur", "resize.cur"]; + /// Converts a decoded BMP into a GPU [ui.Image]. Future bmpToUiImage(BmpImage bmp) { final completer = Completer(); @@ -79,7 +84,19 @@ Future loadBaseSkin() async { ); images[name.toLowerCase()] = await bmpToUiImage(decodeBmp(bytes)); } - return ClassicSkin(name: "Base", images: images); + final cursors = {}; + 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 _buildSkin( if (parsed != null) visColors = parsed; } + final cursors = {...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, diff --git a/packages/dartamp_skin/test/cur_decoder_test.dart b/packages/dartamp_skin/test/cur_decoder_test.dart new file mode 100644 index 0000000..ae55d9f --- /dev/null +++ b/packages/dartamp_skin/test/cur_decoder_test.dart @@ -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"])); + }); +}