From 27a30b06688c558278d91860539ac2acfaa8062a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sun, 16 Aug 2026 17:29:00 +0200 Subject: [PATCH] Improve window snapping/dragging Signed-off-by: Hans Kokx --- TODO.md | 3 +- analysis_options.yaml | 9 + icon.icon/Assets/WinampIcon.png | Bin 0 -> 21149 bytes icon.icon/icon.json | 35 ++ lib/main.dart | 218 +++++++- lib/src/common_widgets.dart | 2 +- lib/src/docking/dock_controller.dart | 14 +- lib/src/docking/dock_logic.dart | 15 + lib/src/main_window/eq_window.dart | 13 +- lib/src/main_window/playlist_window.dart | 248 ++++++--- lib/src/main_window/presets_window.dart | 385 +++++++++++++ lib/src/player/eq_presets.dart | 205 +++++++ lib/src/player/player_state.dart | 21 + lib/src/skin/skin_cursors.dart | 2 +- lib/src/skin/skin_manager.dart | 2 +- lib/src/windows/sub_window_app.dart | 524 ++++++++++++++++-- lib/src/windows/window_args.dart | 11 +- macos/Podfile | 2 +- macos/Podfile.lock | 4 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/AppDelegate.swift | 39 -- .../Assets.xcassets/icon.icon/icon.json | 35 ++ packages/dartamp_skin/analysis_options.yaml | 9 + packages/dartamp_skin/pubspec.yaml | 1 + pubspec.yaml | 1 + test/dock_logic_test.dart | 32 ++ test/eq_presets_test.dart | 80 +++ test/presets_window_test.dart | 63 +++ test_driver/app.dart | 2 +- 29 files changed, 1746 insertions(+), 235 deletions(-) create mode 100644 icon.icon/Assets/WinampIcon.png create mode 100644 icon.icon/icon.json create mode 100644 lib/src/main_window/presets_window.dart create mode 100644 lib/src/player/eq_presets.dart create mode 100644 macos/Runner/Assets.xcassets/icon.icon/icon.json create mode 100644 test/eq_presets_test.dart create mode 100644 test/presets_window_test.dart diff --git a/TODO.md b/TODO.md index 6a0f3d3..69c0ab0 100644 --- a/TODO.md +++ b/TODO.md @@ -4,9 +4,8 @@ - [ ] There should be a preferences window that matches the original application's look/feel/layout. The options can be no-op stubs, except for the skins. - [ ] Skins should be importable from .wsz files. Imported skins should no longer rely on the original file and should be available in the skins list. - [ ] Cursors should be supported. If a skin includes a custom cursor, we should change the cursor to the custom version from the skin. This includes the original cursors from the default skin. -- [X] The "classic" winamp skin code should be a project-level package. This will allow us to, later, add a "modern" winamp skin codepath. The app itself should handle logic, but skin-related items should be abstracted into its own package (including any tooling that was developed to assist in debugging the skin loading/functionality). - [ ] The playlist window should be resizable if you drag from the bottom right corner. -- [ ] The presets button on the equalizer doesn't open the presets window as it should. +- [x] The presets button on the equalizer doesn't open the presets window as it should. - [ ] The playlist window has 5 action buttons along the bottom that are currently implemented incorrectly. Right now, the sprites are drawn full-size, but they should be clipped. However, when you tap and hold on the button, it should open the full sprite sheet as a pop-up. Releasing the cursor on any of the items should trigger the action. Verify this against the original source. - [ ] Right-clicking the dock icon should give us the same window menu options for the main window, equalizer, and playlist window visibility. The window menu currently lacks an option for the main window, so it should be added. Closing all windows shouldn't exit the program. diff --git a/analysis_options.yaml b/analysis_options.yaml index e764191..1620c43 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:arcane_analysis/analysis_options.yaml diff --git a/icon.icon/Assets/WinampIcon.png b/icon.icon/Assets/WinampIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..bc8b0344e667ec03c4e7d67c9d701a7c2d36a544 GIT binary patch literal 21149 zcmZU*bzGCt_W(S)TS96mA{LC4W*`bG2o@a!k?v+NHX5Z>x`t9JCDOS864KHkFuGy% zfU&*v`>Xf;y!+?5&wcJa=bpOf+;g61@1N^xG12qV0{{S~CyyV#1OUiLO)>y2HR)pJ z`PKH{o!3jP2Y|8x{teR2a~q>4w$Gjc?vUEF0CF;R0L8x{|E5Jb04V>{1^{l7t^fc< zF4_M*%_aYzr(}R!ivMf>8|Wh^V@LvG?C{#i+vu5&qP3fgxYb*?H#Xw_F7E#Z0F?a| zNlh0UZz~~x7iU*5MSqp+{~4i3YX9p7T^IV#5N{`y>qgI>3u(A{+6dhjmk^h@u1YT? zB&6*5)>iT5L(Tu5PP$XMZtv~wt_TA8`T2?aNsGIA+JPh$6cj)bQXnZQG13SzFQ}`x zmA{y)m+*f9`5!nBZM>{K9o)Sg++2nJ!L@qh=Hsn${rW#d|NHx|b$UD4{vRe+um2{C zL?Gzj6Og311n7TZlcp;F>s8eAbg&^|{s&)GQu#k4|37{IjYk>u5BdLVG5|mSul>n(vgw*c^n=#)KttEH<({!yzqW7Hxyj3UL;i^- z#|_PFd5%_xM>Be=w|TzY7`V|%%fl`#e(R3-Cpz&ee<1y=T4mLC%+NDAp^HuO2O1R5 zIC2{ysM5yyfVI<$iL(h6{o382TGz4W+RYY4q~U3n;u=CX``j>yXjlB*PG{pOQxfkj z+2@YAqiWwmUQ!fL|NlD9jVT0m%cA4p{~4R6!Hab-3x~+yFz74k`4uWvSEOgb-G6uzWH1Y6iH5ZA&vT1Qw$saEt;OR z3Lj_A?Hiu=YQ6u_uxvyjxS2w^ng8=1%6rGb(l~Ic$E<+bWF(lTB44dmcyKAH%!i4e z`HzpUlZxSOV@IJ7YUS=43a>_}f77t+1e*PZ;g4_e6SCZ(SK3TfMPw{fM>n?$2bWUB zEhy}S0$4KxRi09?mKRIikDyjw_Hp~^8U@7=BtSo>;_f?TwCPbi=MAX=4PEWNDzGLu6664wHt- zw0$#ER*W9uK>4~_?4rN(tp5nY8=RtVM&C)|*xO1z=kY2{Y^tV^?yQeTY3yO9dDt*u z^jlS|y*`B?W#+6|Wnn)xX*RD#^o=FF?Zjsp0F%DiO_`D4s9&~n<0THIZ4lm&hF*8- zHy{7aj`=scHa6`UX?Di!>&ikgNJ~?L(?r=XzQY2Q`v;YTN8UT}hnXAB=4_lV#_Rn^ zqF%R^8!d6zX)C9;X66m4`iCZ82GDH%&0T9(0q@mE`&M=U#H6lse>Qb3$KVDTe3y)CUJY8_l<@g7~&iwQ4ub)57CxWAd?>C0nKGP;A zE-x>eW%Fz=%cG`b?=Y!Ne7G|}CLMwp%uyLLs7X&x|K4D*Y@SYjN+|_g10wz+is2ZS zawqEMOatEFMeO8Q#t9=q;Vq?V$lZ)7d17}v1k!xg12iad#Ty+TamJW+XGUmK4C$9d zd;G|0pI_6&*#D*0vvQ)g?RC&*QO<2Rm>(!HfCN0ol)K8;$pD_aZcLLn=`1!6^Z2

vcR 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ξ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 main(List args) async { initialShade: subWindowArgs.shade, initialAlwaysOnTop: subWindowArgs.alwaysOnTop, initialSkinName: subWindowArgs.skinName, + initialMode: subWindowArgs.mode, ), ), ); @@ -147,12 +149,17 @@ class _WinampRootState extends State { String _wasSkinName = baseSkinName; WindowController? _eqController; WindowController? _plController; + WindowController? _presetsController; StreamSubscription? _windowsSubscription; /// Initial rects queued for a sub-window that has not booted yet; pushed /// when it reports `subReady`. final Map _pendingSubBounds = {}; + /// The initial rect queued for the presets dialog (not docked), pushed once + /// its control channel registers. + Rect? _pendingPresetsBounds; + /// The latest desired top-left per window, coalesced by [_flushMoves]. final Map _pendingMoves = {}; Timer? _moveFlushTimer; @@ -163,6 +170,9 @@ class _WinampRootState extends State { /// Saved top-left positions of the sub-windows, loaded at startup. final Map _savedSubPositions = {}; + /// Saved sizes (width, height) of sub-windows, loaded at startup. + final Map _savedSubSizes = {}; + @override void initState() { super.initState(); @@ -225,17 +235,23 @@ class _WinampRootState extends State { await windowManager.setAlwaysOnTop(_player.alwaysOnTop); await windowManager.setTitle("Winamp"); await _loadScreenBounds(); - await windowManager.setSize(_scaledSize()); + // Try to restore a saved size first (user-resized main window), falling + // back to the default scaled size when none saved. + final savedMainSize = await PlayerState.loadWindowSize("main"); + final sizeToUse = savedMainSize == null + ? _scaledSize() + : Size(savedMainSize.$1, savedMainSize.$2); + await windowManager.setSize(sizeToUse); final savedMain = await PlayerState.loadWindowPos("main"); if (savedMain != null) { final saved = _clampToScreen( Rect.fromLTWH( savedMain.$1, savedMain.$2, - _scaledSize().width, - _scaledSize().height, + sizeToUse.width, + sizeToUse.height, ), - _scaledSize(), + sizeToUse, ); await windowManager.setPosition(saved.topLeft); } else { @@ -280,8 +296,16 @@ class _WinampRootState extends State { unawaited(_syncMenuToNative()); final savedEq = await PlayerState.loadWindowPos("eq"); final savedPl = await PlayerState.loadWindowPos("pl"); + final savedEqSize = await PlayerState.loadWindowSize("eq"); + final savedPlSize = await PlayerState.loadWindowSize("pl"); if (savedEq != null) _savedSubPositions[SubWindowType.eq] = savedEq; if (savedPl != null) _savedSubPositions[SubWindowType.pl] = savedPl; + if (savedEqSize != null) { + _savedSubSizes[SubWindowType.eq] = (savedEqSize.$1, savedEqSize.$2); + } + if (savedPlSize != null) { + _savedSubSizes[SubWindowType.pl] = (savedPlSize.$1, savedPlSize.$2); + } if (_player.eqOpen) unawaited(_openSubWindow(SubWindowType.eq)); if (_player.plOpen) unawaited(_openSubWindow(SubWindowType.pl)); } @@ -377,6 +401,7 @@ class _WinampRootState extends State { _wasAlwaysOnTop = _player.alwaysOnTop; _invokeSub(DockWindow.eq, "setAlwaysOnTop", _player.alwaysOnTop); _invokeSub(DockWindow.pl, "setAlwaysOnTop", _player.alwaysOnTop); + _invokePresets("setAlwaysOnTop", _player.alwaysOnTop); } if (_player.eqOpen != _wasEqOpen) { _wasEqOpen = _player.eqOpen; @@ -485,7 +510,13 @@ class _WinampRootState extends State { final mainRect = _dock.rectOf(DockWindow.main); if (mainRect == null) return; final shade = type == SubWindowType.eq ? _player.eqShade : _player.plShade; - final size = DockController.scaledSize(window, shade, _player.uiScale); + var size = DockController.scaledSize(window, shade, _player.uiScale); + // If a saved size exists for this sub-window, prefer it over the + // computed default so user resizes persist across launches. + final savedSize = _savedSubSizes[type]; + if (savedSize != null) { + size = Size(savedSize.$1, savedSize.$2); + } var rect = _defaultSubRect(type, mainRect, size); final saved = _savedSubPositions[type]; if (saved != null) { @@ -529,6 +560,8 @@ class _WinampRootState extends State { await controller.invokeMethod("setBounds", { "x": rect.left, "y": rect.top, + "w": rect.width, + "h": rect.height, }); _pendingSubBounds.remove(window); } on WindowChannelException catch (e) { @@ -538,6 +571,123 @@ class _WinampRootState extends State { } } + /// Opens the EQ presets dialog (spawned by the EQ presets button), switching + /// an already-open dialog to [mode]. + Future _openPresetsWindow(String mode) async { + final existing = _presetsController; + if (existing != null) { + try { + await existing.invokeMethod("setMode", mode); + await existing.show(); + return; + } on WindowChannelException { + _handlePresetsWindowLost(); + } catch (_) { + // The window is mid-boot; fall through and show it once ready. + try { + await existing.show(); + return; + } catch (_) { + _handlePresetsWindowLost(); + } + } + } + final mainRect = _dock.rectOf(DockWindow.main); + final eqRect = _dock.rectOf(DockWindow.eq); + try { + final controller = await WindowController.create( + WindowConfiguration( + arguments: SubWindowArgs( + type: SubWindowType.presets, + shade: false, + alwaysOnTop: _player.alwaysOnTop, + uiScale: 1.0, + skinName: _skins.active.name, + mode: mode, + ).encode(), + hiddenAtLaunch: true, + ), + ); + _presetsController = controller; + _setInitialPresetsRect(eqRect ?? mainRect); + unawaited(_pushPresetsBounds()); + } catch (_) { + // ignore: failed to open the presets window + } + } + + /// Positions the presets dialog next to [anchor] (the Equalizer window, or + /// the main window when the Equalizer is closed), kept fully on screen. + void _setInitialPresetsRect(Rect? anchor) { + const size = presetsWindowSize; + if (anchor == null) { + _pendingPresetsBounds = Rect.fromLTWH(120, 120, size.width, size.height); + return; + } + final screen = _dock.screenRect; + if (screen == Rect.zero) { + _pendingPresetsBounds = Rect.fromLTWH( + anchor.right, + anchor.top, + size.width, + size.height, + ); + return; + } + final x = anchor.right.clamp(screen.left, screen.right - size.width); + final y = anchor.top.clamp(screen.top, screen.bottom - size.height); + _pendingPresetsBounds = Rect.fromLTWH(x, y, size.width, size.height); + } + + /// Pushes the queued initial bounds to the presets dialog, retrying until + /// its control channel registers. + Future _pushPresetsBounds() async { + final rect = _pendingPresetsBounds; + final controller = _presetsController; + if (rect == null || controller == null) return; + try { + await controller.invokeMethod("setBounds", { + "x": rect.left, + "y": rect.top, + "w": rect.width, + "h": rect.height, + }); + _pendingPresetsBounds = null; + } on WindowChannelException { + _handlePresetsWindowLost(); + } catch (_) { + if (mounted && _presetsController != null) { + Timer(const Duration(milliseconds: 120), () { + if (mounted && _presetsController != null) { + unawaited(_pushPresetsBounds()); + } + }); + } + } + } + + /// Invokes [method] on the presets dialog's control channel, dropping the + /// controller when its channel is gone (the window was closed). + void _invokePresets(String method, [Object? args]) { + final controller = _presetsController; + if (controller == null) return; + unawaited( + controller.invokeMethod(method, args).catchError((Object e) { + if (e is WindowChannelException && e.code == "CHANNEL_UNREGISTERED") { + _handlePresetsWindowLost(); + } + return null; + }), + ); + } + + /// Drops the presets dialog whose native window/channel is gone. + void _handlePresetsWindowLost() { + if (_presetsController == null) return; + debugPrint("presets window lost"); + _presetsController = null; + } + Future _onSubWindowCall(MethodCall call) async { final args = call.arguments is Map ? call.arguments as Map : const {}; switch (call.method) { @@ -551,10 +701,33 @@ class _WinampRootState extends State { case "plClose": _dock.removeWindow(DockWindow.pl); _player.setPlOpen(false); + case "openPresets": + final mode = args["mode"] is String ? args["mode"] as String : "load"; + unawaited(_openPresetsWindow(mode)); + case "applyPreset": + // Forward a preset loaded from the presets dialog to the Equalizer. + _invokeSub(DockWindow.eq, "applyPreset", { + "preamp": args["preamp"], + "bands": args["bands"], + }); + case "getEqSettings": + // The presets dialog pulls the Equalizer's current settings to save. + final controller = _eqController; + if (controller == null) return null; + try { + return await controller.invokeMethod("getEqSettings"); + } on WindowChannelException { + return null; + } on MissingPluginException { + return null; + } case "subReady": final window = _subWindowFromArgs(args); - if (window == null) return; - unawaited(_pushPendingSubBounds(window)); + if (window != null) { + unawaited(_pushPendingSubBounds(window)); + } else if (args["type"] == "presets") { + unawaited(_pushPresetsBounds()); + } await _pushDockContext(); case "subBounds": final window = _subWindowFromArgs(args); @@ -604,6 +777,7 @@ class _WinampRootState extends State { }; _invokeSub(DockWindow.eq, "setDockContext", args); _invokeSub(DockWindow.pl, "setDockContext", args); + _invokePresets("setDockContext", args); } /// Invokes [method] on a sub-window's control channel without ever leaking @@ -690,10 +864,13 @@ class _WinampRootState extends State { _savedSubPositions[type] = (entry.value.left, entry.value.top); } unawaited( - PlayerState.saveWindowPos( + PlayerState.saveWindowPos(name, entry.value.left, entry.value.top), + ); + unawaited( + PlayerState.saveWindowSize( name, - entry.value.left, - entry.value.top, + entry.value.width, + entry.value.height, ), ); } @@ -702,18 +879,21 @@ class _WinampRootState extends State { Future _reconcileSubWindows() async { WindowController? eq; WindowController? pl; + WindowController? presets; try { for (final window in await WindowController.getAll()) { final args = SubWindowArgs.tryDecode(window.arguments); if (args == null) continue; if (args.type == SubWindowType.eq) eq = window; if (args.type == SubWindowType.pl) pl = window; + if (args.type == SubWindowType.presets) presets = window; } } catch (_) { return; } _eqController = eq; _plController = pl; + _presetsController = presets; if (eq == null && _player.eqOpen) { _dock.removeWindow(DockWindow.eq); _player.setEqOpen(false); @@ -810,21 +990,7 @@ class _WinampRootState extends State { }, ); } - return Listener( - onPointerDown: (e) => debugPrint( - "ptr down ${e.position.dx.toStringAsFixed(1)}," - "${e.position.dy.toStringAsFixed(1)}", - ), - onPointerMove: (e) => debugPrint( - "ptr move ${e.position.dx.toStringAsFixed(1)}," - "${e.position.dy.toStringAsFixed(1)}", - ), - onPointerUp: (e) => debugPrint( - "ptr up ${e.position.dx.toStringAsFixed(1)}," - "${e.position.dy.toStringAsFixed(1)}", - ), - child: body, - ); + return body; } } diff --git a/lib/src/common_widgets.dart b/lib/src/common_widgets.dart index 446d0fb..92b6295 100644 --- a/lib/src/common_widgets.dart +++ b/lib/src/common_widgets.dart @@ -1,8 +1,8 @@ import "dart:ui" as ui; import "package:dartamp_skin/dartamp_skin.dart"; -import "package:flutter/material.dart"; import "package:flutter/widgets.dart"; +import "package:material_ui/material_ui.dart"; /// A button that shows a pressed state when tapped, using skin images. class SkinPressButton extends StatefulWidget { diff --git a/lib/src/docking/dock_controller.dart b/lib/src/docking/dock_controller.dart index 053b5ae..5ee4b6c 100644 --- a/lib/src/docking/dock_controller.dart +++ b/lib/src/docking/dock_controller.dart @@ -205,11 +205,7 @@ class DockController { } final doAttachedSnap = snapEnabled && !dragging; - if (dragging && snapEnabled) { - debugPrint( - "dock moveAttached: skipping attached-window snap while dragging", - ); - } + if (doAttachedSnap) { // Keep the attached windows on screen, propagating any shift to the // whole group (the original `config_keeponscreen` behavior). @@ -259,13 +255,7 @@ class DockController { } final updates = {driven: mwr, ...moved}; - debugPrint( - "dock moveAttached $driven target=${target.left.toStringAsFixed(1)}," - "${target.top.toStringAsFixed(1)} prev=${prevDriven.left.toStringAsFixed(1)}," - "${prevDriven.top.toStringAsFixed(1)} mwr=${mwr.left.toStringAsFixed(1)}," - "${mwr.top.toStringAsFixed(1)} eq=${updates[DockWindow.eq]?.top.toStringAsFixed(1)} " - "pl=${updates[DockWindow.pl]?.top.toStringAsFixed(1)}", - ); + for (final entry in updates.entries) { final old = _rects[entry.key]; _rects[entry.key] = entry.value; diff --git a/lib/src/docking/dock_logic.dart b/lib/src/docking/dock_logic.dart index ca69f67..50095d0 100644 --- a/lib/src/docking/dock_logic.dart +++ b/lib/src/docking/dock_logic.dart @@ -146,6 +146,21 @@ abstract final class DockLogic { static Rect moveBy(Rect r, double dx, double dy) => Rect.fromLTWH(r.left + dx, r.top + dy, r.width, r.height); + /// The next position for a window-local drag: the window is moved so its + /// grab offset to the pointer is preserved. + /// + /// [localPoint] is the pointer's position relative to the [applied] (native) + /// window bounds, so the window base cancels out and the result tracks the + /// cursor absolutely (`cursorScreen - grab`). This holds even when [applied] + /// is stale, so a lagging native window never accumulates error. + static Rect dragCandidate(Rect applied, Offset localPoint, Offset grab) => + Rect.fromLTWH( + applied.left + localPoint.dx - grab.dx, + applied.top + localPoint.dy - grab.dy, + applied.width, + applied.height, + ); + /// Port of `IsPointInRect` (DOCK.cpp:205). static bool pointInRect(Offset p, Rect r) => p.dx >= r.left && p.dx < r.right && p.dy >= r.top && p.dy < r.bottom; diff --git a/lib/src/main_window/eq_window.dart b/lib/src/main_window/eq_window.dart index 9d3adc7..0569c6a 100644 --- a/lib/src/main_window/eq_window.dart +++ b/lib/src/main_window/eq_window.dart @@ -2,7 +2,7 @@ import "dart:async"; import "dart:ui" as ui; import "package:dartamp_skin/dartamp_skin.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "../common_widgets.dart"; import "../player/player_state.dart"; @@ -288,8 +288,9 @@ class _EqOnAutoButtons extends StatelessWidget { } } -/// The presets button (`draw_eq_presets`). No presets are wired up yet, so the -/// button only shows its pressed state. +/// The presets button (`draw_eq_presets`), which opens the EQ presets dialog +/// window (the original shows a "Load preset" menu here; this app opens the +/// presets window directly in load mode). class _EqPresetsButton extends StatelessWidget { const _EqPresetsButton({required this.skin, required this.player}); @@ -306,10 +307,14 @@ class _EqPresetsButton extends StatelessWidget { image: eqmain, src: const Rect.fromLTWH(224, 164, 44, 12), srcPressed: const Rect.fromLTWH(224, 176, 44, 12), - onTap: () {}, + onTap: () => unawaited(_openPresets()), ), ); } + + static Future _openPresets() async { + await subWindowChannel.invokeMethod("openPresets", {"mode": "load"}); + } } /// The response graph (`draw_eq_graphthingy`): the empty graph backdrop, the diff --git a/lib/src/main_window/playlist_window.dart b/lib/src/main_window/playlist_window.dart index 928c43d..4279f13 100644 --- a/lib/src/main_window/playlist_window.dart +++ b/lib/src/main_window/playlist_window.dart @@ -2,23 +2,15 @@ import "dart:async"; import "dart:ui" as ui; import "package:dartamp_skin/dartamp_skin.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.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(275, 290); -const Size _plShadeSize = Size(275, 14); - -const double _plWidth = 275; const double _plShadeHeight = 14; const double _listLeft = 12; -const double _listRight = 255; - -/// 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; @@ -199,21 +191,26 @@ class _PlWindowView extends StatelessWidget { 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), - ), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + return SizedBox.fromSize( + size: Size(width, height), + child: player.plShade + ? _PlShadeLayout( + skin: skin, + player: player, + width: width, + ) + : _PlMainLayout( + skin: skin, + player: player, + width: width, + height: height, + ), + ); + }, ); }, ); @@ -222,10 +219,17 @@ class _PlWindowView extends StatelessWidget { /// The full 280×290 Playlist window (normal mode). class _PlMainLayout extends StatefulWidget { - const _PlMainLayout({required this.skin, required this.player}); + const _PlMainLayout({ + required this.skin, + required this.player, + required this.width, + required this.height, + }); final ClassicSkin skin; final PlayerState player; + final double width; + final double height; @override State<_PlMainLayout> createState() => _PlMainLayoutState(); @@ -242,25 +246,30 @@ class _PlMainLayoutState extends State<_PlMainLayout> { } void _onTapDown(TapDownDetails details) { - if (details.localPosition.dx >= _listRight) return; + final visibleRows = ((widget.height - 20 - 38) / _rowStride).floor(); final row = ((details.localPosition.dy - _rowsLocalTop) / _rowStride) .floor(); + if (row < 0 || row >= visibleRows) return; final absIndex = _offset + row; - if (row >= 0 && row < _numSongs && absIndex < _playlist.length) { + if (absIndex < _playlist.length) { setState(() => _selected = absIndex); } } void _onDragStart(DragStartDetails details) { - if (details.localPosition.dx >= _listRight) { + final listRight = widget.width - 20; + 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) + final visibleRows = ((widget.height - 20 - 38) / _rowStride).floor(); + final t = _playlist.length - visibleRows; + if (t < 1) return; + final bodyHeight = widget.height - 20 - 38; + final pos = ((details.localPosition.dy / bodyHeight) * t) .round() .clamp(0, t) .toInt(); @@ -283,6 +292,7 @@ class _PlMainLayoutState extends State<_PlMainLayout> { final player = widget.player; final pledit = skin.image("pledit.bmp")!; final elapsedSec = player.position * player.durationSec ~/ 256; + final bodyHeight = widget.height - 20 - 38; return Stack( children: [ const Positioned.fill( @@ -291,22 +301,27 @@ class _PlMainLayoutState extends State<_PlMainLayout> { Positioned( top: 0, left: 0, + right: 0, child: CustomPaint( - size: const Size(_plWidth, 20), + size: Size(widget.width, 20), painter: _PlTitlebarPainter(image: pledit, active: true), ), ), Positioned( top: 20, left: 0, + right: 0, + height: bodyHeight, child: CustomPaint( - size: const Size(_plWidth, 232), + size: Size(widget.width, bodyHeight), painter: _PlBodyPainter(pledit), ), ), Positioned( top: 20, left: 0, + right: 0, + height: bodyHeight, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: _onTapDown, @@ -315,7 +330,7 @@ class _PlMainLayoutState extends State<_PlMainLayout> { onVerticalDragEnd: _onDragEnd, onVerticalDragCancel: _onDragCancel, child: CustomPaint( - size: const Size(_plWidth, 232), + size: Size(widget.width, bodyHeight), painter: _PlSongListPainter( skin: skin, keyed: _keyed, @@ -327,25 +342,27 @@ class _PlMainLayoutState extends State<_PlMainLayout> { ), ), Positioned( - top: 252, left: 0, + right: 0, + bottom: 0, + height: 38, child: CustomPaint( - size: const Size(_plWidth, 38), + size: Size(widget.width, 38), painter: _PlBottomPainter(pledit), ), ), - _PlButtons(pledit: pledit), + _PlButtons(pledit: pledit, width: widget.width), Positioned( - top: 262, left: 137, + top: widget.height - 28, child: CustomPaint( size: const Size(90, 6), painter: _PlInfoPainter(keyed: _keyed, text: _infoString()), ), ), Positioned( - top: 275, - left: 194, + left: widget.width - 86, + top: widget.height - 15, child: CustomPaint( size: const Size(32, 6), painter: _PlTimePainter( @@ -355,7 +372,11 @@ class _PlMainLayoutState extends State<_PlMainLayout> { ), ), ), - _PlTitleButtons(pledit: pledit, player: player), + _PlTitleButtons( + pledit: pledit, + player: player, + width: widget.width, + ), ], ); } @@ -657,48 +678,70 @@ class _PlSongListPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - _paintRows(canvas); - _paintScrollbar(canvas); + _paintRows(canvas, size); + _paintScrollbar(canvas, size); } - void _paintRows(Canvas canvas) { + void _paintRows(Canvas canvas, Size paintSize) { final colors = skin.pledit; + final listRight = paintSize.width - 20; + final listWidth = listRight - _listLeft; + final visibleRows = ((paintSize.height - _rowsLocalTop) / _rowStride) + .floor(); + final rowsBottom = _rowsLocalTop + visibleRows * _rowStride; canvas.drawRect( - const Rect.fromLTWH(_listLeft, 0, _listRight - _listLeft, 3), + Rect.fromLTWH(_listLeft, 0, listWidth, 3), Paint()..color = colors.normalBg, ); - const rowsBottom = _rowsLocalTop + _numSongs * _rowStride; canvas.drawRect( - const Rect.fromLTWH( + Rect.fromLTWH( _listLeft, rowsBottom, - _listRight - _listLeft, - 232 - rowsBottom, + listWidth, + paintSize.height - rowsBottom, ), Paint()..color = colors.normalBg, ); - final visible = (_playlist.length - offset).clamp(0, _numSongs); + final visible = (_playlist.length - offset).clamp(0, visibleRows); 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), + Rect.fromLTWH(_listLeft, y, listWidth, _rowStride), Paint()..color = isSelected ? colors.selectedBg : colors.normalBg, ); final color = isCurrent ? colors.current : colors.normal; - _drawRowText(canvas, _playlist[absIndex], y + _rowGlyphOffset, color); + _drawRowText( + canvas, + _playlist[absIndex], + y + _rowGlyphOffset, + color, + paintSize.width, + ); } } - void _drawRowText(Canvas canvas, _Track track, double y, Color color) { + void _drawRowText( + Canvas canvas, + _Track track, + double y, + Color color, + double paintWidth, + ) { final time = _fmtTime(track.seconds); final timeLen = time.length; - final nameBudget = 49 - (timeLen + 1); + final availableWidth = + paintWidth - _listLeft - 24 - timeLen * TextFont.charWidth; + final nameBudget = availableWidth ~/ TextFont.charWidth; var name = track.name; if (name.length > nameBudget) { - name = "${name.substring(0, nameBudget - 3)}..."; + if (nameBudget > 3) { + name = "${name.substring(0, nameBudget - 3)}..."; + } else { + name = ""; + } } keyed.draw( canvas, @@ -706,7 +749,7 @@ class _PlSongListPainter extends CustomPainter { _listLeft, y, color, - clipWidth: nameBudget * TextFont.charWidth, + clipWidth: availableWidth.clamp(0.0, double.infinity), ); keyed.draw( canvas, @@ -718,25 +761,26 @@ class _PlSongListPainter extends CustomPainter { keyed.draw( canvas, time, - _plWidth - 24 - timeLen * TextFont.charWidth, + paintWidth - 24 - timeLen * TextFont.charWidth, y, color, ); } - void _paintScrollbar(Canvas canvas) { + void _paintScrollbar(Canvas canvas, Size paintSize) { final pledit = skin.image("pledit.bmp")!; - const trackH = 232; + final trackH = paintSize.height; const slidH = 18; - final t = _playlist.length - _numSongs; + final visibleRows = ((trackH - _rowsLocalTop) / _rowStride).floor(); + final t = _playlist.length - visibleRows; int yp; if (t < 1) { yp = 0; } else { final p = offset.clamp(0, t); - yp = (214 * p) ~/ t; + yp = (((trackH - slidH) * p) ~/ t).clamp(0, trackH - slidH).toInt(); } - const x = _plWidth - 15; + final x = paintSize.width - 15; final paint = Paint()..filterQuality = FilterQuality.none; var y = 0; for (; y < yp - 28; y += 29) { @@ -851,16 +895,19 @@ class _PlTimePainter extends CustomPainter { /// The 5 button columns (add/rem/sel/misc/load) over the bottom bar. class _PlButtons extends StatelessWidget { - const _PlButtons({required this.pledit}); + const _PlButtons({ + required this.pledit, + required this.width, + }); final ui.Image pledit; + final double width; 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 = [ @@ -877,13 +924,11 @@ class _PlButtons extends StatelessWidget { (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) { + final rightEdge = width - 47; return Stack( children: [ for (final b in _bars) @@ -892,12 +937,29 @@ class _PlButtons extends StatelessWidget { top: b.$2, child: SkinImage(image: pledit, src: b.$3), ), + Positioned( + left: rightEdge, + top: 224, + child: SkinImage( + image: pledit, + src: const Rect.fromLTWH(250, 111, 3, 54), + ), + ), for (final b in _buttons) Positioned( left: b.$1, top: b.$2, child: SkinImage(image: pledit, src: b.$3), ), + for (final top in [224.0, 242.0, 260.0]) + Positioned( + left: width - 44, + top: top, + child: SkinImage( + image: pledit, + src: const Rect.fromLTWH(204, 111, 22, 18), + ), + ), ], ); } @@ -905,17 +967,22 @@ class _PlButtons extends StatelessWidget { /// The titlebar shade/close buttons (`draw_pe_tbutton`). class _PlTitleButtons extends StatelessWidget { - const _PlTitleButtons({required this.pledit, required this.player}); + const _PlTitleButtons({ + required this.pledit, + required this.player, + required this.width, + }); final ui.Image pledit; final PlayerState player; + final double width; @override Widget build(BuildContext context) { return Stack( children: [ Positioned( - left: _plWidth - 20, + left: width - 20, top: 3, child: SkinPressButton( image: pledit, @@ -925,7 +992,7 @@ class _PlTitleButtons extends StatelessWidget { ), ), Positioned( - left: _plWidth - 11, + left: width - 11, top: 3, child: SkinPressButton( image: pledit, @@ -945,10 +1012,15 @@ class _PlTitleButtons extends StatelessWidget { /// Windowshade (280×14) built from `pledit.bmp`. class _PlShadeLayout extends StatelessWidget { - const _PlShadeLayout({required this.skin, required this.player}); + const _PlShadeLayout({ + required this.skin, + required this.player, + required this.width, + }); final ClassicSkin skin; final PlayerState player; + final double width; @override Widget build(BuildContext context) { @@ -957,15 +1029,19 @@ class _PlShadeLayout extends StatelessWidget { return Stack( children: [ CustomPaint( - size: const Size(_plWidth, _plShadeHeight), + size: Size(width, _plShadeHeight), painter: _PlShadeTitlebarPainter(image: pledit, active: true), ), CustomPaint( - size: const Size(_plWidth, _plShadeHeight), - painter: _PlShadeSongPainter(keyed: keyed, track: _playlist[0]), + size: Size(width, _plShadeHeight), + painter: _PlShadeSongPainter( + keyed: keyed, + track: _playlist[0], + width: width, + ), ), Positioned( - left: _plWidth - 20, + left: width - 20, top: 3, child: SkinPressButton( image: pledit, @@ -975,7 +1051,7 @@ class _PlShadeLayout extends StatelessWidget { ), ), Positioned( - left: _plWidth - 11, + left: width - 11, top: 3, child: SkinPressButton( image: pledit, @@ -995,19 +1071,29 @@ class _PlShadeLayout extends StatelessWidget { /// The windowshade current-song line (`draw_pe_song` with `pos = -1`). class _PlShadeSongPainter extends CustomPainter { - const _PlShadeSongPainter({required this.keyed, required this.track}); + const _PlShadeSongPainter({ + required this.keyed, + required this.track, + required this.width, + }); final _KeyedFont keyed; final _Track track; + final double width; @override void paint(Canvas canvas, Size size) { final time = _fmtTime(track.seconds); final timeLen = time.length; - final budget = 49 - (timeLen + 1); + final availableWidth = width - 29 - timeLen * TextFont.charWidth - 4; + final nameBudget = availableWidth ~/ TextFont.charWidth; var name = track.name; - if (name.length > budget) { - name = "${name.substring(0, budget - 3)}..."; + if (name.length > nameBudget) { + if (nameBudget > 3) { + name = "${name.substring(0, nameBudget - 3)}..."; + } else { + name = ""; + } } const y = 4.0; keyed.draw( @@ -1016,7 +1102,7 @@ class _PlShadeSongPainter extends CustomPainter { 4, y, _displayGreen, - clipWidth: budget * TextFont.charWidth, + clipWidth: availableWidth.clamp(0.0, double.infinity), ); keyed.draw( canvas, @@ -1028,7 +1114,7 @@ class _PlShadeSongPainter extends CustomPainter { keyed.draw( canvas, time, - _plWidth - 29 - timeLen * TextFont.charWidth, + width - 29 - timeLen * TextFont.charWidth, y, _displayGreen, ); diff --git a/lib/src/main_window/presets_window.dart b/lib/src/main_window/presets_window.dart new file mode 100644 index 0000000..2f7fead --- /dev/null +++ b/lib/src/main_window/presets_window.dart @@ -0,0 +1,385 @@ +import "dart:async"; +import "dart:collection"; + +import "package:material_ui/material_ui.dart"; +import "package:window_manager/window_manager.dart"; + +import "../player/eq_presets.dart"; +import "../windows/sub_window_channel.dart"; + +/// The fixed outer size of the presets window in logical pixels. +const Size presetsWindowSize = Size(340, 320); + +/// The EQ preset dialog mode, mirroring the original `IDD_LOADPRESET` / +/// `IDD_SAVEPRESET` dialogs (Eq.cpp): load/delete share one listbox dialog and +/// save adds a name field. +enum PresetsMode { load, save, delete } + +/// The Equalizer presets dialog window. +/// +/// A persistent dialog (spawned by the EQ presets button, tracked by the main +/// window) with a mode switcher for Load/Save/Delete, a preset list, a name +/// field for Save, and a Default reset. Loads/Defaults push the preset to the +/// Equalizer over [subWindowChannel]; Save pulls the Equalizer's current +/// settings back over the same channel and persists them via [EqPresets]. +class PresetsWindow extends StatefulWidget { + const PresetsWindow({required this.mode, super.key}); + + final PresetsMode mode; + + @override + State createState() => _PresetsWindowState(); +} + +class _PresetsWindowState extends State { + final TextEditingController _nameController = TextEditingController(); + late PresetsMode _mode; + Map _presets = const {}; + String? _selected; + bool _busy = false; + String? _message; + + @override + void initState() { + super.initState(); + _mode = widget.mode; + unawaited(_initPresets()); + } + + @override + void didUpdateWidget(covariant PresetsWindow oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.mode != _mode) { + setState(() => _mode = widget.mode); + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _initPresets() async { + final loaded = await EqPresets.load(); + var selected = await EqPresets.lastPreset(); + final sorted = _sorted(loaded); + if (selected != null && !sorted.containsKey(selected)) { + selected = sorted.isEmpty ? null : sorted.keys.first; + } + if (mounted) { + setState(() { + _presets = sorted; + _selected = selected; + }); + } + } + + static SplayTreeMap _sorted(Map presets) { + return SplayTreeMap( + (a, b) { + final byCase = a.toLowerCase().compareTo(b.toLowerCase()); + return byCase != 0 ? byCase : a.compareTo(b); + }, + )..addAll(presets); + } + + Future _reloadPresets({String? select}) async { + final loaded = await EqPresets.load(); + final sorted = _sorted(loaded); + final current = _selected; + final selection = + select ?? + (current != null && sorted.containsKey(current) ? current : null); + if (mounted) { + setState(() { + _presets = sorted; + _selected = selection; + _message = null; + }); + } + } + + Future _load() async { + final name = _selected; + if (name == null) return; + final preset = await EqPresets.byName(name); + if (preset == null) return; + await _applyPreset(preset); + } + + Future _applyDefault() async { + final stored = await EqPresets.byName("Default"); + await _applyPreset(stored ?? EqPresets.defaultPreset); + } + + Future _applyPreset(EqPreset preset) async { + setState(() { + _busy = true; + _message = null; + }); + try { + await subWindowChannel.invokeMethod( + "applyPreset", + {"preamp": preset.preamp, "bands": preset.bands}, + ); + await EqPresets.setLastPreset(preset.name); + } catch (_) { + if (mounted) { + setState(() => _message = "Could not reach the main window."); + } + } + if (mounted) setState(() => _busy = false); + } + + Future _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + setState(() => _message = "Enter a preset name."); + return; + } + setState(() { + _busy = true; + _message = null; + }); + try { + final settings = await subWindowChannel.invokeMethod( + "getEqSettings", + ); + if (settings == null) { + if (mounted) { + setState(() { + _busy = false; + _message = "The Equalizer window is closed."; + }); + } + return; + } + final preamp = (settings["preamp"] as num).toInt(); + final bands = (settings["bands"] as List) + .whereType() + .map((v) => v.toInt()) + .toList(); + await EqPresets.save( + EqPreset(name: name, preamp: preamp, bands: bands), + ); + await EqPresets.setLastPreset(name); + _nameController.clear(); + await _reloadPresets(select: name); + } catch (_) { + if (mounted) { + setState(() => _message = "Could not reach the main window."); + } + } + if (mounted) setState(() => _busy = false); + } + + Future _delete() async { + final name = _selected; + if (name == null) return; + await EqPresets.remove(name); + if (mounted) setState(() => _selected = null); + await _reloadPresets(); + } + + Future _close() async { + try { + await windowManager.close(); + } catch (_) { + // ignore: the native window may already be closing + } + } + + String get _actionLabel => switch (_mode) { + PresetsMode.load => "Load", + PresetsMode.save => "Save", + PresetsMode.delete => "Delete", + }; + + @override + Widget build(BuildContext context) { + final darkTheme = ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFF34A2A), + brightness: Brightness.dark, + ), + ); + final mode = _mode; + final hasSelection = _selected != null; + final canAct = switch (mode) { + PresetsMode.load || PresetsMode.delete => hasSelection, + PresetsMode.save => true, + }; + final action = switch (mode) { + PresetsMode.load => () => unawaited(_load()), + PresetsMode.save => () => unawaited(_save()), + PresetsMode.delete => () => unawaited(_delete()), + }; + return Theme( + data: darkTheme, + child: Material( + color: const Color(0xFF1E1F24), + child: SizedBox.expand( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 32, + padding: const EdgeInsets.only(left: 12), + alignment: Alignment.centerLeft, + child: Row( + children: [ + const Expanded( + child: Text( + "Winamp EQ Presets", + style: TextStyle(fontSize: 13), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 16), + tooltip: "Close", + onPressed: _busy ? null : () => unawaited(_close()), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SegmentedButton( + segments: const [ + ButtonSegment(value: PresetsMode.load, label: Text("Load")), + ButtonSegment(value: PresetsMode.save, label: Text("Save")), + ButtonSegment( + value: PresetsMode.delete, + label: Text("Delete"), + ), + ], + selected: {mode}, + onSelectionChanged: _busy + ? null + : (selection) { + setState(() => _mode = selection.first); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Container( + height: 140, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF3A3B40)), + borderRadius: BorderRadius.circular(4), + ), + child: _presets.isEmpty + ? const Center( + child: Text( + "No presets", + style: TextStyle(fontSize: 12), + ), + ) + : ListView.separated( + itemCount: _presets.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final name = _presets.keys.elementAt(index); + final isSelected = name == _selected; + return InkWell( + onTap: () { + setState(() { + _selected = name; + _message = null; + }); + }, + onDoubleTap: mode == PresetsMode.load + ? () => unawaited(_load()) + : null, + child: Container( + color: isSelected + ? darkTheme.colorScheme.primaryContainer + : null, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + name, + style: TextStyle( + fontSize: 12, + color: isSelected + ? darkTheme + .colorScheme + .onPrimaryContainer + : null, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ), + if (mode == PresetsMode.save) + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: TextField( + controller: _nameController, + autofocus: true, + enabled: !_busy, + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + labelText: "Preset name", + isDense: true, + border: OutlineInputBorder(), + ), + onSubmitted: (_) => unawaited(_save()), + ), + ), + SizedBox( + height: 18, + child: Center( + child: _message == null + ? null + : Text( + _message!, + style: TextStyle( + fontSize: 11, + color: darkTheme.colorScheme.error, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: _busy || !canAct ? null : action, + child: Text(_actionLabel), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: _busy + ? null + : () => unawaited(_applyDefault()), + child: const Text("Default"), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _busy ? null : () => unawaited(_close()), + child: const Text("Close"), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/player/eq_presets.dart b/lib/src/player/eq_presets.dart new file mode 100644 index 0000000..bead4f1 --- /dev/null +++ b/lib/src/player/eq_presets.dart @@ -0,0 +1,205 @@ +import "dart:convert"; + +import "package:shared_preferences/shared_preferences.dart"; + +const String _kPresetsKey = "winamp.eq_presets"; +const String _kLastPresetKey = "winamp.eq_last_preset"; + +/// A single saved Equalizer preset: the preamp gain plus the 10 band gains. +/// +/// Band values are 0..64 with 32 at 0 dB, mirroring the original `eq_tab` and +/// `config_preamp` state. +class EqPreset { + const EqPreset({ + required this.name, + required this.preamp, + required this.bands, + }); + + final String name; + final int preamp; + final List bands; + + @override + bool operator ==(Object other) { + return other is EqPreset && + other.name == name && + other.preamp == preamp && + _sameBands(other.bands); + } + + bool _sameBands(List other) { + if (other.length != bands.length) return false; + for (var i = 0; i < bands.length; i++) { + if (other[i] != bands[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hash(name, preamp, Object.hashAll(bands)); + + /// Decodes a single preset map value (from the JSON store), or `null` when + /// the payload is malformed. + static EqPreset? fromJson(String name, Object? json) { + final map = json is Map ? json : null; + if (map == null) return null; + final preamp = map["preamp"]; + final bands = map["bands"]; + if (preamp is! num || bands is! List) return null; + return EqPreset( + name: name, + preamp: preamp.toInt().clamp(0, 64), + bands: bands + .whereType() + .take(10) + .map((v) => v.toInt().clamp(0, 64)) + .toList(), + ); + } +} + +/// The Equalizer preset store, persisted as a JSON map in `SharedPreferences`. +/// +/// Mirrors the original `Winamp EQ library file` (`eqpresets/eqm.preset`, +/// Eq.cpp): a flat set of named presets seeded with the built-in set on first +/// use (main.cpp `CreateEQPresets`). Load/Delete/Save operate case-insensitively +/// on preset names, and the last applied preset is remembered so the presets +/// window can preselect it. +class EqPresets { + /// The 18 built-in presets with their 10 band gains, seeded on first run. + static const List<(String, List)> builtins = [ + ("Classical", [31, 31, 31, 31, 31, 31, 44, 44, 44, 48]), + ("Club", [31, 31, 38, 50, 56, 63, 50, 44, 38, 31]), + ("Dance", [44, 38, 44, 50, 38, 31, 25, 31, 31, 31]), + ("Flat", [31, 31, 31, 31, 31, 31, 31, 31, 31, 31]), + ("Laptop speakers/headphones", [31, 38, 44, 50, 56, 63, 56, 44, 38, 31]), + ("Large hall", [50, 50, 38, 38, 38, 38, 38, 38, 38, 50]), + ("Party", [38, 38, 31, 31, 31, 31, 50, 56, 50, 38]), + ("Pop", [38, 31, 31, 31, 38, 56, 56, 44, 31, 31]), + ("Reggae", [31, 44, 31, 38, 56, 38, 31, 31, 31, 31]), + ("Rock", [50, 38, 31, 38, 44, 50, 56, 56, 56, 50]), + ("Soft", [44, 38, 31, 31, 31, 31, 25, 25, 31, 44]), + ("Ska", [31, 31, 25, 38, 56, 63, 56, 38, 31, 31]), + ("Full Bass", [56, 56, 56, 38, 31, 31, 31, 31, 31, 31]), + ("Soft Rock", [50, 44, 38, 31, 31, 31, 31, 31, 31, 44]), + ("Full Treble", [31, 31, 31, 31, 31, 38, 56, 56, 56, 56]), + ("Full Bass & Treble", [56, 56, 56, 31, 31, 31, 56, 56, 56, 56]), + ("Live", [31, 31, 31, 38, 38, 38, 31, 31, 31, 31]), + ("Techno", [56, 50, 38, 31, 25, 25, 31, 38, 50, 56]), + ]; + + /// The neutral reset applied by the Default action when no "Default" preset + /// has been saved yet (the original `eq_tab` initialization). + static const EqPreset defaultPreset = EqPreset( + name: "Default", + preamp: 31, + bands: [31, 31, 31, 31, 31, 31, 31, 31, 31, 31], + ); + + /// Loads all presets, seeding the built-ins on first run. + static Future> load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kPresetsKey); + if (raw == null) { + final seeded = _builtinPresets(); + await prefs.setString(_kPresetsKey, jsonEncode(_encode(seeded))); + return seeded; + } + final decoded = _decode(raw); + if (decoded.isEmpty) return _builtinPresets(); + return decoded; + } + + /// Saves or overwrites [preset] (matched case-insensitively by name). + static Future save(EqPreset preset) async { + final prefs = await SharedPreferences.getInstance(); + final presets = await _readOrSeed(prefs); + final key = _findKey(presets, preset.name) ?? preset.name; + presets[key] = preset; + await prefs.setString(_kPresetsKey, jsonEncode(_encode(presets))); + } + + /// Deletes the preset named [name] (matched case-insensitively), if present. + static Future remove(String name) async { + final prefs = await SharedPreferences.getInstance(); + final presets = await _readOrSeed(prefs); + final key = _findKey(presets, name); + if (key == null) return; + presets.remove(key); + await prefs.setString(_kPresetsKey, jsonEncode(_encode(presets))); + } + + /// The preset named [name] (matched case-insensitively), or `null`. + static Future byName(String name) async { + final prefs = await SharedPreferences.getInstance(); + final presets = await _readOrSeed(prefs); + final key = _findKey(presets, name); + return key == null ? null : presets[key]; + } + + /// The name of the last applied preset, or `null`. + static Future lastPreset() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kLastPresetKey); + } + + /// Remembers [name] as the last applied preset. + static Future setLastPreset(String name) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLastPresetKey, name); + } + + static Map _builtinPresets() { + return { + for (final (name, bands) in builtins) + name: EqPreset(name: name, preamp: 31, bands: bands), + }; + } + + static Future> _readOrSeed( + SharedPreferences prefs, + ) async { + final raw = prefs.getString(_kPresetsKey); + if (raw == null) { + final seeded = _builtinPresets(); + await prefs.setString(_kPresetsKey, jsonEncode(_encode(seeded))); + return seeded; + } + final decoded = _decode(raw); + return decoded.isEmpty ? _builtinPresets() : decoded; + } + + static String? _findKey(Map presets, String name) { + for (final key in presets.keys) { + if (key.toLowerCase() == name.toLowerCase()) return key; + } + return null; + } + + static Map _encode(Map presets) { + return { + for (final entry in presets.entries) + entry.key: { + "preamp": entry.value.preamp, + "bands": entry.value.bands, + }, + }; + } + + static Map _decode(String raw) { + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return {}; + } + if (decoded is! Map) return {}; + final result = {}; + for (final entry in decoded.entries) { + final preset = EqPreset.fromJson(entry.key as String, entry.value); + if (preset != null) result[entry.key as String] = preset; + } + return result; + } +} diff --git a/lib/src/player/player_state.dart b/lib/src/player/player_state.dart index 35c2f18..0c74018 100644 --- a/lib/src/player/player_state.dart +++ b/lib/src/player/player_state.dart @@ -12,6 +12,10 @@ const String _kPlShadeKey = "winamp.pl_shade"; const String _kUiScaleKey = "winamp.ui_scale"; const String _kActiveSkinKey = "winamp.active_skin"; +/// The `winamp.window__w/h` preference keys for a window size. +String _windowSizeKey(String window, String axis) => + "winamp.window_${window}_$axis"; + /// The `winamp.window__x/y` preference keys for a window position. String _windowPosKey(String window, String axis) => "winamp.window_${window}_$axis"; @@ -329,6 +333,23 @@ class PlayerState extends ChangeNotifier { await prefs.setDouble(_windowPosKey(window, "y"), y); } + /// Loads the saved size (width, height) of the window named [window], or + /// `null` when never resized. + static Future<(double, double)?> loadWindowSize(String window) async { + final prefs = await SharedPreferences.getInstance(); + final w = prefs.getDouble(_windowSizeKey(window, "w")); + final h = prefs.getDouble(_windowSizeKey(window, "h")); + if (w == null || h == null) return null; + return (w, h); + } + + /// Persists the size (width, height) of the window named [window]. + static Future saveWindowSize(String window, double w, double h) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_windowSizeKey(window, "w"), w); + await prefs.setDouble(_windowSizeKey(window, "h"), h); + } + void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { diff --git a/lib/src/skin/skin_cursors.dart b/lib/src/skin/skin_cursors.dart index 0ba55bb..0315ec9 100644 --- a/lib/src/skin/skin_cursors.dart +++ b/lib/src/skin/skin_cursors.dart @@ -2,8 +2,8 @@ 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"; +import "package:material_ui/material_ui.dart"; /// The platform channel to the macOS app delegate. const winampChannel = MethodChannel("winamp"); diff --git a/lib/src/skin/skin_manager.dart b/lib/src/skin/skin_manager.dart index 2f9f312..1cc3f72 100644 --- a/lib/src/skin/skin_manager.dart +++ b/lib/src/skin/skin_manager.dart @@ -21,7 +21,7 @@ Future loadAppSkin(String name) async { try { final registry = await SkinRegistry.load(await skinsDirectory()); final info = registry.byName(name); - if (info == null) return loadBaseSkin(); + if (info == null) return await loadBaseSkin(); return await registry.loadSkin(info); } catch (_) { return loadBaseSkin(); diff --git a/lib/src/windows/sub_window_app.dart b/lib/src/windows/sub_window_app.dart index cdaa5c3..a841261 100644 --- a/lib/src/windows/sub_window_app.dart +++ b/lib/src/windows/sub_window_app.dart @@ -3,13 +3,14 @@ import "dart:ui" as ui; import "package:dartamp_skin/dartamp_skin.dart"; import "package:desktop_multi_window/desktop_multi_window.dart"; -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:window_manager/window_manager.dart"; import "../common_widgets.dart"; import "../docking/dock_logic.dart"; import "../main_window/eq_window.dart"; import "../main_window/playlist_window.dart"; +import "../main_window/presets_window.dart"; import "../player/player_state.dart"; import "../skin/skin_manager.dart"; import "sub_window_channel.dart"; @@ -20,7 +21,9 @@ const Size _eqShadeSize = Size(275, 14); const Size _plMainSize = Size(275, 290); const Size _plShadeSize = Size(275, 14); -/// The root of an Equalizer or Playlist sub-window. +enum _ResizeHandle { left, right, top, bottom, bottomRight } + +/// The root of an Equalizer, Playlist, or presets sub-window. /// /// Each sub-window is an independent Flutter engine that re-runs the app's /// `main`. This root configures the native window (size, frameless look, @@ -34,6 +37,7 @@ class SubWindowRoot extends StatefulWidget { required this.initialAlwaysOnTop, this.initialUiScale = 1.0, this.initialSkinName = "Base", + this.initialMode = "load", super.key, }); @@ -43,6 +47,10 @@ class SubWindowRoot extends StatefulWidget { final double initialUiScale; final String initialSkinName; + /// The EQ preset dialog mode for the presets window ("load", "save", or + /// "delete"); ignored for other window types. + final String initialMode; + @override State createState() => _SubWindowRootState(); } @@ -63,15 +71,33 @@ class _SubWindowRootState extends State { /// drag loop can compute grab offsets without async round-trips. Rect? _windowBounds; + /// The last non-shade size in logical pixels, used to restore normal-mode + /// height when toggling back from a windowshade. + Size? _lastNormalSize; + + /// When external `setBounds` arrives while the user is interacting (drag or + /// resize), it is stored here and applied once the interaction completes to + /// avoid feedback loops between native and the in-flight user operations. + Rect? _pendingExternalBounds; + /// The snapshot of the other windows' rects and the screen pushed by the /// main window, used to snap this window during its own drags. _SubDockContext? _dockContext; + /// The current EQ preset dialog mode, pushed by the main window. + late PresetsMode _presetsMode; + bool get _isEq => widget.type == SubWindowType.eq; + bool get _isPl => widget.type == SubWindowType.pl; + bool get _isPresets => widget.type == SubWindowType.presets; DockWindow get _dockWindow => _isEq ? DockWindow.eq : DockWindow.pl; - Size get _mainSize => _isEq ? _eqMainSize : _plMainSize; + Size get _mainSize => switch (widget.type) { + SubWindowType.eq => _eqMainSize, + SubWindowType.pl => _plMainSize, + SubWindowType.presets => presetsWindowSize, + }; Size get _shadeSize => _isEq ? _eqShadeSize : _plShadeSize; @override @@ -80,13 +106,16 @@ class _SubWindowRootState extends State { _player = PlayerState( alwaysOnTop: widget.initialAlwaysOnTop, eqShade: _isEq ? widget.initialShade : false, - plShade: _isEq ? false : widget.initialShade, - uiScale: widget.initialUiScale, + plShade: _isPl ? widget.initialShade : false, + uiScale: _isPresets ? 1.0 : widget.initialUiScale, ); + _presetsMode = _parsePresetsMode(widget.initialMode); _skinFuture = loadAppSkin(widget.initialSkinName); _drag = _SubWindowDragController(this); _wasShade = widget.initialShade; - _wasScale = widget.initialUiScale; + // The presets dialog is not UI-scaled; track 1.0 so a shade/always-on-top + // push never rescales it from the main window's zoom. + _wasScale = _isPresets ? 1.0 : widget.initialUiScale; _player.addListener(_onPlayerChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _readyWindow(); @@ -94,6 +123,13 @@ class _SubWindowRootState extends State { _controlWired = _wireWindowControl(); } + static PresetsMode _parsePresetsMode(String mode) { + return PresetsMode.values.firstWhere( + (m) => m.name == mode, + orElse: () => PresetsMode.load, + ); + } + @override void dispose() { _player.removeListener(_onPlayerChanged); @@ -125,17 +161,63 @@ class _SubWindowRootState extends State { final args = call.arguments; if (args is Map) { final bounds = _windowBounds; - final size = bounds?.size ?? _scaledSize(); + final currentSize = bounds?.size ?? _scaledSize(); final x = (args["x"] as num).toDouble(); final y = (args["y"] as num).toDouble(); - _windowBounds = Rect.fromLTWH(x, y, size.width, size.height); - await windowManager.setPosition(Offset(x, y)); + final width = args["w"] is num + ? (args["w"] as num).toDouble() + : currentSize.width; + final height = args["h"] is num + ? (args["h"] as num).toDouble() + : currentSize.height; + final newBounds = Rect.fromLTWH(x, y, width, height); + // If the user is currently resizing or dragging, stash the bounds + // to avoid feedback loops where both sides repeatedly override + // each other. Apply after interaction ends. + if (_resizing || _drag._active) { + _pendingExternalBounds = newBounds; + } else { + if (mounted) { + setState(() { + _windowBounds = newBounds; + }); + } else { + _windowBounds = newBounds; + } + await windowManager.setSize(Size(width, height)); + await windowManager.setPosition(Offset(x, y)); + } } case "setDockContext": final args = call.arguments; if (args is Map) { _dockContext = _SubDockContext.decode(args); } + case "setMode": + if (call.arguments is String) { + final mode = _parsePresetsMode(call.arguments as String); + if (mode != _presetsMode) { + setState(() => _presetsMode = mode); + } + } + case "applyPreset": + // The main window forwards a loaded preset to the Equalizer. + if (_isEq && call.arguments is Map) { + final args = call.arguments as Map; + final preamp = args["preamp"]; + final bands = args["bands"]; + if (preamp is num && bands is List) { + _player.setPreamp(preamp.toInt()); + final values = bands.whereType().toList(); + for (var i = 0; i < values.length && i < 10; i++) { + _player.setEqBand(i, values[i].toInt()); + } + } + } + case "getEqSettings": + if (_isEq) { + return {"preamp": _player.preamp, "bands": _player.eqBands}; + } } }); } catch (_) { @@ -147,14 +229,32 @@ class _SubWindowRootState extends State { if (!mounted || _windowReady) return; try { await windowManager.setPreventClose(false); - await windowManager.setResizable(false); + // Only the playlist window is resizable; equalizer and presets are + // fixed-size dialogs. + await windowManager.setResizable(_isPl); await windowManager.setAlwaysOnTop(_player.alwaysOnTop); await windowManager.setTitle( - _isEq ? "Winamp Equalizer" : "Winamp Playlist", + _isEq + ? "Winamp Equalizer" + : _isPresets + ? "Winamp EQ Presets" + : "Winamp Playlist", ); await windowManager.setAsFrameless(); - await windowManager.setBackgroundColor(const Color(0xFF000000)); + // The skin windows use a transparent background so skins with + // transparency render correctly; the presets dialog is fully opaque. + await windowManager.setBackgroundColor( + _isPresets ? const Color(0xFF1E1F24) : const Color(0x00000000), + ); await windowManager.setSize(_scaledSize()); + if (_isPl) { + await windowManager.setMinimumSize( + Size( + _mainSize.width * _player.uiScale, + _plMainSize.height * _player.uiScale, + ), + ); + } if (_windowBounds != null) { await windowManager.setPosition(_windowBounds!.topLeft); } else { @@ -170,7 +270,7 @@ class _SubWindowRootState extends State { unawaited( subWindowChannel.invokeMethod( "subReady", - {"type": _isEq ? "eq" : "pl"}, + {"type": widget.type.name}, ), ); } catch (_) { @@ -182,11 +282,149 @@ class _SubWindowRootState extends State { } } - Size _scaledSize() { - final base = _wasShade ? _shadeSize : _mainSize; - return Size(base.width * _player.uiScale, base.height * _player.uiScale); + // --- Resize handling --------------------------------------------------- + + bool _resizing = false; + Rect? _resizeStartBounds; + Offset? _resizeStartPointer; + _ResizeHandle? _resizeHandle; + // Throttle native position/size updates to avoid jitter on rapid pointer + // movements. Coalesce multiple updates into a single native setPosition/setSize + // call scheduled shortly after the most recent pointer event. + Rect? _pendingApplyRect; + bool _applyScheduled = false; + + Future _onResizeStart(_ResizeHandle handle, Offset pointer) async { + _resizing = true; + _resizeHandle = handle; + _resizeStartPointer = pointer; + _resizeStartBounds = await windowManager.getBounds(); } + void _onResizeUpdate(Offset pointer) { + if (!_resizing || _resizeStartBounds == null || _resizeStartPointer == null) + return; + final start = _resizeStartBounds!; + final delta = pointer - _resizeStartPointer!; + var left = start.left; + var top = start.top; + var width = start.width; + var height = start.height; + // Enforce min width as the default playlist width (not narrower). + final minW = (_mainSize.width) * _player.uiScale; + // Minimum height is the main window's height when available from the + // pushed dock context; fall back to this window's base height otherwise. + final mainRect = _dockContext?.rects[DockWindow.main]; + final minH = mainRect?.height ?? (_mainSize.height * _player.uiScale); + switch (_resizeHandle!) { + case _ResizeHandle.right: + width = (start.width + delta.dx).clamp(minW, double.infinity); + case _ResizeHandle.bottom: + height = (start.height + delta.dy).clamp(minH, double.infinity); + case _ResizeHandle.left: + left = start.left + delta.dx; + width = (start.width - delta.dx).clamp(minW, double.infinity); + case _ResizeHandle.top: + top = start.top + delta.dy; + height = (start.height - delta.dy).clamp(minH, double.infinity); + case _ResizeHandle.bottomRight: + width = (start.width + delta.dx).clamp(minW, double.infinity); + height = (start.height + delta.dy).clamp(minH, double.infinity); + } + final candidate = Rect.fromLTWH(left, top, width, height); + setState(() { + _windowBounds = candidate; + if (!_isEq && !(_isEq ? _player.eqShade : _player.plShade)) { + _lastNormalSize = Size( + width / _player.uiScale, + height / _player.uiScale, + ); + } + }); + // Coalesce native updates to reduce jitter: only schedule one outstanding + // setPosition/setSize operation and always apply the most recent candidate. + _pendingApplyRect = candidate; + if (!_applyScheduled) { + _applyScheduled = true; + Timer(const Duration(milliseconds: 16), () async { + final rect = _pendingApplyRect; + if (rect != null) { + try { + await windowManager.setPosition(rect.topLeft); + await windowManager.setSize(Size(rect.width, rect.height)); + _reportSubBounds(rect); + } catch (e) { + // Ignore transient native failures during rapid moves. + } + } + _applyScheduled = false; + }); + } + } + + Future _onResizeEnd() async { + if (!_resizing) return; + _resizing = false; + _resizeHandle = null; + _resizeStartBounds = null; + _resizeStartPointer = null; + // Persist the final size now that main will receive the final subBounds + final bounds = _windowBounds; + if (bounds != null) { + unawaited( + PlayerState.saveWindowSize( + _isEq ? "eq" : "pl", + bounds.width, + bounds.height, + ), + ); + } + // If an external setBounds arrived while resizing, apply it now so the + // window restores to the expected position/size. + _applyPendingExternalBounds(); + } + + /// Applies a `setBounds` push that arrived while the user was interacting + /// (drag or resize), restoring the window to the pushed position/size and + /// reporting it back to the main window. + void _applyPendingExternalBounds() { + final nb = _pendingExternalBounds; + if (nb == null) return; + _pendingExternalBounds = null; + if (mounted) { + setState(() { + _windowBounds = nb; + }); + } else { + _windowBounds = nb; + } + unawaited(windowManager.setSize(nb.size)); + unawaited(windowManager.setPosition(nb.topLeft)); + _reportSubBounds(nb); + } + + Size _windowSizeForState({bool? shade, double? scale}) { + final useShade = shade ?? (_isEq ? _player.eqShade : _player.plShade); + final useScale = scale ?? _player.uiScale; + final current = _windowBounds?.size; + if (current != null) { + final width = current.width / _wasScale * useScale; + final height = current.height / _wasScale * useScale; + final minWidth = _mainSize.width * useScale; + final desiredWidth = useShade + ? width + : (width < minWidth ? minWidth : width); + final desiredHeight = useShade + ? _shadeSize.height * useScale + : (_lastNormalSize?.height ?? height) * useScale; + return Size(desiredWidth, desiredHeight); + } + final base = useShade ? _shadeSize : _mainSize; + return Size(base.width * useScale, base.height * useScale); + } + + Size _scaledSize() => _windowSizeForState(); + void _onPlayerChanged() { if (!_windowReady) return; final shade = _isEq ? _player.eqShade : _player.plShade; @@ -201,8 +439,8 @@ class _SubWindowRootState extends State { ); } if (shadeChanged || scaleChanged) { + final newSize = _windowSizeForState(shade: shade, scale: _player.uiScale); _wasScale = _player.uiScale; - final newSize = _scaledSize(); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(windowManager.setSize(newSize)); }); @@ -237,8 +475,17 @@ class _SubWindowRootState extends State { @override Widget build(BuildContext context) { + if (_isPresets) { + return ColoredBox( + color: Colors.transparent, + child: WindowDragSurface( + dragController: _drag, + child: PresetsWindow(mode: _presetsMode), + ), + ); + } return ColoredBox( - color: const Color(0xFF000000), + color: Colors.transparent, child: AnimatedBuilder( animation: _player, builder: (context, _) { @@ -246,26 +493,138 @@ class _SubWindowRootState extends State { final base = (_isEq ? _player.eqShade : _player.plShade) ? _shadeSize : _mainSize; - return SizedBox( - width: base.width * scale, - height: base.height * scale, + // Compute the actual outer size in logical pixels. When the window + // was resized we prefer the runtime bounds so the UI fills the + // window instead of remaining a fixed-size black box. + final actualOuterWidth = _windowBounds?.width ?? base.width * scale; + final actualOuterHeight = + _windowBounds?.height ?? base.height * scale; + // The inner child expects coordinates in unscaled (base) pixels, + // so divide by the UI scale to produce the child's layout size. + final innerWidth = actualOuterWidth / scale; + final innerHeight = actualOuterHeight / scale; + final content = SizedBox( + width: actualOuterWidth, + height: actualOuterHeight, child: Transform.scale( scale: scale, alignment: Alignment.topLeft, filterQuality: FilterQuality.none, - child: _isEq - ? EqWindow( - player: _player, - skinFuture: _skinFuture, - dragController: _drag, - ) - : PlWindow( - player: _player, - skinFuture: _skinFuture, - dragController: _drag, - ), + child: SizedBox.fromSize( + size: Size(innerWidth, innerHeight), + child: _isEq + ? EqWindow( + player: _player, + skinFuture: _skinFuture, + dragController: _drag, + ) + : PlWindow( + player: _player, + skinFuture: _skinFuture, + dragController: _drag, + ), + ), ), ); + + // If resizable (playlist), overlay thin resize handles on the + // edges and a bottom-right corner grip. + if (_isPl) { + return Stack( + children: [ + content, + // Right edge + Positioned( + right: 0, + top: 0, + bottom: 12, + width: 6, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (e) => + _onResizeStart(_ResizeHandle.right, e.globalPosition), + onPanUpdate: (e) => _onResizeUpdate(e.globalPosition), + onPanEnd: (_) => _onResizeEnd(), + child: const MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + ), + ), + ), + // Bottom edge + Positioned( + left: 0, + right: 12, + bottom: 0, + height: 6, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (e) => + _onResizeStart(_ResizeHandle.bottom, e.globalPosition), + onPanUpdate: (e) => _onResizeUpdate(e.globalPosition), + onPanEnd: (_) => _onResizeEnd(), + child: const MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + ), + ), + ), + // Left edge + Positioned( + left: 0, + top: 0, + bottom: 12, + width: 6, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (e) => + _onResizeStart(_ResizeHandle.left, e.globalPosition), + onPanUpdate: (e) => _onResizeUpdate(e.globalPosition), + onPanEnd: (_) => _onResizeEnd(), + child: const MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + ), + ), + ), + // Top edge + Positioned( + left: 0, + right: 12, + top: 0, + height: 6, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (e) => + _onResizeStart(_ResizeHandle.top, e.globalPosition), + onPanUpdate: (e) => _onResizeUpdate(e.globalPosition), + onPanEnd: (_) => _onResizeEnd(), + child: const MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + ), + ), + ), + // Bottom-right corner + Positioned( + right: 0, + bottom: 0, + width: 12, + height: 12, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (e) => _onResizeStart( + _ResizeHandle.bottomRight, + e.globalPosition, + ), + onPanUpdate: (e) => _onResizeUpdate(e.globalPosition), + onPanEnd: (_) => _onResizeEnd(), + child: const MouseRegion( + cursor: SystemMouseCursors.resizeUpLeftDownRight, + ), + ), + ), + ], + ); + } + + return content; }, ), ); @@ -326,10 +685,12 @@ class _SubDockContext { /// `setPosition`, snaps locally against the pushed dock context, and reports /// the final bounds to the main window on release. /// -/// The drag is relative: Flutter's pointer positions are window-local (they -/// shift as the window follows the cursor), so the new position is the current -/// bounds plus the pointer's delta since the grab. This needs no native cursor -/// call (`screen_retriever.getCursorScreenPoint` is unimplemented on macOS). +/// Flutter's pointer positions are window-local (they shift as the window +/// follows the cursor), so the new position is the *applied* (native) bounds +/// plus the pointer's delta since the grab. Tracking against the last position +/// actually set on the native window keeps the math absolute — the native +/// bounds cancel out — so a lagging window never accumulates error and the +/// drag cannot run away to the screen corners. class _SubWindowDragController implements WindowDragController { _SubWindowDragController(this._state); @@ -338,10 +699,20 @@ class _SubWindowDragController implements WindowDragController { bool _active = false; Offset? _grab; + /// The position last set on the native window. Pointer deltas are measured + /// against the real window position, so this (not the ahead-of-native + /// `_windowBounds`) is the correct base for the drag math. + Rect? _lastApplied; + + // Throttle drag native updates similarly to resize. + bool _dragApplyScheduled = false; + Offset? _dragPendingTopLeft; + @override void start(ui.Offset globalPosition) { _active = true; _grab = globalPosition; + _lastApplied = _state._windowBounds; _apply(globalPosition); } @@ -351,18 +722,21 @@ class _SubWindowDragController implements WindowDragController { } void _apply(ui.Offset localPoint) { - final bounds = _state._windowBounds; - if (bounds == null) return; + // If a resize is currently active, don't also try to move the window; + // concurrent move + resize causes alternating native updates and jitter. + if (_state._resizing) return; + final applied = _lastApplied ?? _state._windowBounds; + if (applied == null) return; final grab = _grab ?? localPoint; _grab = grab; - var candidate = Rect.fromLTWH( - bounds.left + localPoint.dx - grab.dx, - bounds.top + localPoint.dy - grab.dy, - bounds.width, - bounds.height, - ); + var candidate = DockLogic.dragCandidate(applied, localPoint, grab); final context = _state._dockContext; - if (context != null && context.snap) { + if (_state._isPresets) { + // The presets dialog is not dockable: just keep it on screen. + if (context != null && context.screen != Rect.zero) { + candidate = DockLogic.clampVisible(candidate, context.screen); + } + } else if (context != null && context.snap) { candidate = DockLogic.snapToScreen( candidate, context.screen, @@ -379,28 +753,60 @@ class _SubWindowDragController implements WindowDragController { ); } } - candidate = DockLogic.clampVisible( - candidate, - context?.screen ?? Rect.zero, - ); - final moved = candidate.topLeft != bounds.topLeft; + if (!_state._isPresets) { + candidate = DockLogic.clampVisible( + candidate, + context?.screen ?? Rect.zero, + ); + } + final moved = candidate.topLeft != applied.topLeft; _state._windowBounds = candidate; if (moved) { - unawaited(windowManager.setPosition(candidate.topLeft)); - // Report the dragged position so the main window updates its rect map - // (this window moves alone; docking/undocking happens here, locally). - _state._reportSubBounds(candidate); + // Coalesce frequent drag updates to avoid overwhelming native. Capture + // the desired top-left now and apply the most recent bounds when the + // scheduled task runs. Avoid referencing `candidate` inside the async + // closure to prevent stale-capture races. + _dragPendingTopLeft = candidate.topLeft; + if (!_dragApplyScheduled) { + _dragApplyScheduled = true; + Timer(const Duration(milliseconds: 16), () async { + final topLeft = _dragPendingTopLeft; + final applyBounds = _state._windowBounds; + if (topLeft != null && applyBounds != null) { + try { + await windowManager.setPosition(topLeft); + // Only the native position is the drag base going forward; + // mark it applied so the next pointer event is measured from + // where the window really is. + _lastApplied = applyBounds; + _state._reportSubBounds(applyBounds); + } catch (_) {} + } + _dragApplyScheduled = false; + }); + } } } @override - void end() { + Future end() async { _active = false; - // The last [apply] already moved and reported the window; report once more - // so the main window's rect map settles on the final bounds. + // Apply any position the coalescing timer has not sent yet, then drop the + // pending move so a late timer cannot fight the pending external bounds. + final pending = _dragPendingTopLeft; + _dragPendingTopLeft = null; + // Report once more so the main window's rect map settles on the final + // bounds. final bounds = _state._windowBounds; - if (bounds != null) { - _state._reportSubBounds(bounds); + if (bounds == null) return; + if (pending != null) { + try { + await windowManager.setPosition(pending); + _lastApplied = bounds; + } catch (_) {} } + _state._reportSubBounds(bounds); + // If an external setBounds arrived while dragging, apply it now. + _state._applyPendingExternalBounds(); } } diff --git a/lib/src/windows/window_args.dart b/lib/src/windows/window_args.dart index 87a3b18..eb3cca2 100644 --- a/lib/src/windows/window_args.dart +++ b/lib/src/windows/window_args.dart @@ -5,9 +5,9 @@ import "dart:convert"; /// Every OS window spawned by `desktop_multi_window` re-runs the app's /// top-level `main`; the plugin passes each window a launch-arguments string so /// `main` can build the matching app. The main window has empty arguments. -enum SubWindowType { eq, pl } +enum SubWindowType { eq, pl, presets } -/// Launch arguments for an Equalizer or Playlist sub-window. +/// Launch arguments for an Equalizer, Playlist, or presets sub-window. class SubWindowArgs { const SubWindowArgs({ required this.type, @@ -15,6 +15,7 @@ class SubWindowArgs { required this.alwaysOnTop, this.uiScale = 1.0, this.skinName = "Base", + this.mode = "load", }); final SubWindowType type; @@ -28,6 +29,10 @@ class SubWindowArgs { /// The active skin at spawn time, so the sub-window starts skinned. final String skinName; + /// The EQ preset dialog mode ("load", "save", or "delete"); only used by the + /// presets window. + final String mode; + /// Encodes the args into the JSON string the plugin passes to the new /// window's engine. String encode() { @@ -37,6 +42,7 @@ class SubWindowArgs { "alwaysOnTop": alwaysOnTop, "uiScale": uiScale, "skinName": skinName, + "mode": mode, }); } @@ -62,6 +68,7 @@ class SubWindowArgs { alwaysOnTop: map["alwaysOnTop"] == true, uiScale: (map["uiScale"] as num?)?.toDouble().clamp(1.0, 3.0) ?? 1.0, skinName: (map["skinName"] as String?) ?? "Base", + mode: (map["mode"] as String?) ?? "load", ); } } diff --git a/macos/Podfile b/macos/Podfile index ff5ddb3..167132a 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ae58106..a0036ce 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,8 +15,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486 - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 +PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 6e9262d..b681d83 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -567,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -649,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -699,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 502ccf6..e9c41a1 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -22,45 +22,6 @@ class AppDelegate: FlutterAppDelegate { // FlutterViewController is already attached here, but poll briefly as a // fallback for other Flutter versions where it may not be yet. registerWinampChannelWhenReady() - installMouseDiagnostics() - } - - /// Temporary diagnostics: logs every mouse event macOS delivers to one of - /// our Flutter windows (frame, location, and whether the window moved the - /// mouse over its content). Used to trace why the drag loop gets no events - /// at launch while it works after the first resize. - private var mouseDiag: Any? - private func installMouseDiagnostics() { - guard mouseDiag == nil else { return } - mouseDiag = NSEvent.addLocalMonitorForEvents( - matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .mouseMoved] - ) { event in - guard let window = event.window, - let vc = window.contentViewController as? FlutterViewController - else { return event } - let view = vc.view - let local = view.convert(event.locationInWindow, from: nil) - let inView = view.bounds.contains(local) - NSLog( - "mdiag %@ local=(%.1f,%.1f) inView=%@ frame=%@ wf=%@", - Self.name(for: event.type), - local.x, local.y, - inView ? "y" : "n", - NSStringFromRect(view.bounds), - NSStringFromRect(window.frame) - ) - return event - } - } - - private static func name(for type: NSEvent.EventType) -> String { - switch type { - case .leftMouseDown: return "down" - case .leftMouseDragged: return "drag" - case .leftMouseUp: return "up" - case .mouseMoved: return "move" - default: return "other" - } } /// Quits this instance if another copy of the app is already running, and diff --git a/macos/Runner/Assets.xcassets/icon.icon/icon.json b/macos/Runner/Assets.xcassets/icon.icon/icon.json new file mode 100644 index 0000000..398577d --- /dev/null +++ b/macos/Runner/Assets.xcassets/icon.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "WinampIcon.png", + "name" : "WinampIcon", + "position" : { + "scale" : 3.5, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/packages/dartamp_skin/analysis_options.yaml b/packages/dartamp_skin/analysis_options.yaml index e764191..1620c43 100644 --- a/packages/dartamp_skin/analysis_options.yaml +++ b/packages/dartamp_skin/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:arcane_analysis/analysis_options.yaml diff --git a/packages/dartamp_skin/pubspec.yaml b/packages/dartamp_skin/pubspec.yaml index a64c3e2..43dc052 100644 --- a/packages/dartamp_skin/pubspec.yaml +++ b/packages/dartamp_skin/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: archive: ^4.0.9 flutter: sdk: flutter + material_ui: ^1.0.0 path: ^1.9.0 dev_dependencies: diff --git a/pubspec.yaml b/pubspec.yaml index 334abf6..c1eee83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: file_picker: ^11.0.3 flutter: sdk: flutter + material_ui: ^1.0.0 path_provider: ^2.1.5 screen_retriever: ^0.2.2 shared_preferences: ^2.5.5 diff --git a/test/dock_logic_test.dart b/test/dock_logic_test.dart index ff199fd..5444041 100644 --- a/test/dock_logic_test.dart +++ b/test/dock_logic_test.dart @@ -167,6 +167,38 @@ void main() { }); }); + group("dragCandidate", () { + test("moves the window by the pointer's window-local delta", () { + const grab = Offset(50, 30); + const applied = Rect.fromLTWH(100, 100, 275, 116); + final out = DockLogic.dragCandidate( + applied, + const Offset(150, 130), + grab, + ); + expect(out.topLeft, const Offset(200, 200)); + expect(out.width, applied.width); + expect(out.height, applied.height); + }); + + test("tracks the cursor absolutely regardless of the applied base", () { + const grab = Offset(50, 30); + // Pointer at screen (450, 330): window-local (50, 30) when the window + // really is at (400, 300). The base must cancel so a stale (lagging) + // applied position never accumulates error. + const lagging = Rect.fromLTWH(400, 300, 275, 116); + final kept = DockLogic.dragCandidate(lagging, const Offset(50, 30), grab); + expect(kept.topLeft, const Offset(400, 300)); + // Cursor moves to screen (500, 400) -> window-local (100, 100). + final moved = DockLogic.dragCandidate( + lagging, + const Offset(100, 100), + grab, + ); + expect(moved.topLeft, const Offset(450, 370)); + }); + }); + group("fixOverlaps", () { test("pushes r2 right when the horizontal gap is smaller", () { const r1 = Rect.fromLTWH(0, 0, 50, 200); diff --git a/test/eq_presets_test.dart b/test/eq_presets_test.dart new file mode 100644 index 0000000..8f17dc9 --- /dev/null +++ b/test/eq_presets_test.dart @@ -0,0 +1,80 @@ +import "package:dartamp/src/player/eq_presets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test("first load seeds the 18 built-in presets", () async { + final presets = await EqPresets.load(); + expect(presets.length, 18); + expect(presets["Classical"], isNotNull); + expect(presets["Flat"]!.bands, everyElement(31)); + }); + + test("save adds a new preset", () async { + await EqPresets.save( + const EqPreset( + name: "My mix", + preamp: 40, + bands: [35, 36, 37, 38, 39, 40, 41, 42, 43, 44], + ), + ); + final presets = await EqPresets.load(); + expect(presets.length, 19); + expect(presets["My mix"]!.preamp, 40); + expect(presets["My mix"]!.bands, [35, 36, 37, 38, 39, 40, 41, 42, 43, 44]); + }); + + test("save overwrites an existing preset case-insensitively", () async { + await EqPresets.save( + const EqPreset( + name: "rock", + preamp: 50, + bands: [50, 50, 50, 50, 50, 50, 50, 50, 50, 50], + ), + ); + final presets = await EqPresets.load(); + expect(presets.length, 18); + expect(presets["Rock"]!.preamp, 50); + }); + + test("remove deletes by name case-insensitively", () async { + await EqPresets.remove("flat"); + final presets = await EqPresets.load(); + expect(presets.containsKey("Flat"), isFalse); + expect(presets.length, 17); + }); + + test( + "byName matches case-insensitively and misses missing presets", + () async { + final found = await EqPresets.byName("POP"); + expect(found, isNotNull); + expect(found!.name, "Pop"); + expect(await EqPresets.byName("Nope"), isNull); + }, + ); + + test("last preset round-trips", () async { + expect(await EqPresets.lastPreset(), isNull); + await EqPresets.setLastPreset("Classical"); + expect(await EqPresets.lastPreset(), "Classical"); + }); + + test("malformed stored data falls back to the built-ins", () async { + SharedPreferences.setMockInitialValues({"winamp.eq_presets": "{oops"}); + final presets = await EqPresets.load(); + expect(presets.length, 18); + }); + + test("defaultPreset is the neutral reset", () { + expect(EqPresets.defaultPreset.preamp, 31); + expect(EqPresets.defaultPreset.bands, everyElement(31)); + expect(EqPresets.defaultPreset.name, "Default"); + }); +} diff --git a/test/presets_window_test.dart b/test/presets_window_test.dart new file mode 100644 index 0000000..b967dfe --- /dev/null +++ b/test/presets_window_test.dart @@ -0,0 +1,63 @@ +import "package:dartamp/src/main_window/presets_window.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets("shows the preset list in load mode with no name field", ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)), + ); + await tester.pump(); + + expect(find.text("Winamp EQ Presets"), findsOneWidget); + expect(find.text("Classical"), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets("save mode adds the name field", (tester) async { + await tester.pumpWidget( + const MaterialApp(home: PresetsWindow(mode: PresetsMode.save)), + ); + await tester.pump(); + + expect(find.text("Preset name"), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets("selecting a preset enables the Load action", (tester) async { + await tester.pumpWidget( + const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)), + ); + await tester.pump(); + + FilledButton button() => tester.widget( + find.widgetWithText(FilledButton, "Load"), + ); + expect(button().onPressed, isNull); + + await tester.tap(find.text("Classical")); + // The list rows have an onDoubleTap in load mode, so the single-tap + // callback only fires after the double-tap timeout elapses. + await tester.pump(const Duration(milliseconds: 350)); + expect(button().onPressed, isNotNull); + }); + + testWidgets("mode switcher toggles between load and delete", (tester) async { + await tester.pumpWidget( + const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)), + ); + await tester.pump(); + + await tester.tap(find.text("Delete")); + await tester.pump(); + expect(find.widgetWithText(FilledButton, "Delete"), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); +} diff --git a/test_driver/app.dart b/test_driver/app.dart index 7cc5c5a..ca66442 100644 --- a/test_driver/app.dart +++ b/test_driver/app.dart @@ -1,7 +1,7 @@ import "package:dartamp/main.dart"; import "package:dartamp/src/skin/skin_manager.dart"; -import "package:flutter/material.dart"; import "package:flutter_driver/driver_extension.dart"; +import "package:material_ui/material_ui.dart"; Future main() async { enableFlutterDriverExtension();