Files
dartamp/lib/main.dart
T
hans 0f6c3faf6a Refactor window management and docking logic
- Update the main window close behavior to use `windowManager.destroy()` for proper app termination.
- Enhance cursor management in `SkinCursorsGate` to prevent stale cursor states during rapid enter/exit events.
- Improve sub-window control channel registration to ensure readiness before main window updates.
- Simplify the drag logic for sub-windows, ensuring only the dragged window moves independently.
- Add diagnostics for mouse events in macOS to aid in debugging drag behavior.
- Refactor docking logic to ensure proper vertical-only docking behavior and improve test coverage for window interactions.
- Update tests to reflect changes in docking behavior and ensure accurate movement tracking for main and sub-windows.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-08-11 16:06:04 +02:00

927 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "dart:async";
import "dart:io";
import "dart:ui" as ui;
import "package:dartamp_skin/dartamp_skin.dart";
import "package:desktop_multi_window/desktop_multi_window.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:window_manager/window_manager.dart";
import "src/common_widgets.dart";
import "src/docking/dock_controller.dart";
import "src/docking/dock_logic.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";
import "src/windows/window_args.dart";
const Size _mainSize = Size(275, 116);
const Size _shadeSize = Size(275, 14);
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
// Sub-windows (Equalizer/Playlist) are separate Flutter engines that re-run
// this entrypoint; the plugin passes each one its launch arguments so we can
// build the matching app.
final subWindowArgs = await _resolveSubWindowArgs();
if (subWindowArgs != null) {
await windowManager.ensureInitialized();
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
home: SubWindowRoot(
type: subWindowArgs.type,
initialShade: subWindowArgs.shade,
initialAlwaysOnTop: subWindowArgs.alwaysOnTop,
initialSkinName: subWindowArgs.skinName,
),
),
);
return;
}
final windowShade = await PlayerState.loadWindowShade();
final alwaysOnTop = await PlayerState.loadAlwaysOnTop();
final eqOpen = await PlayerState.loadEqOpen();
final plOpen = await PlayerState.loadPlOpen();
final uiScale = await PlayerState.loadUiScale();
final skinManager = await SkinManager.load();
await windowManager.ensureInitialized();
runApp(
DartampApp(
initialWindowShade: windowShade,
initialAlwaysOnTop: alwaysOnTop,
initialEqOpen: eqOpen,
initialPlOpen: plOpen,
initialUiScale: uiScale,
skinManager: skinManager,
),
);
}
Future<SubWindowArgs?> _resolveSubWindowArgs() async {
try {
final controller = await WindowController.fromCurrentEngine();
return SubWindowArgs.tryDecode(controller.arguments);
} catch (_) {
// ignore: the plugin may not be ready; treat as the main window
}
return null;
}
class DartampApp extends StatelessWidget {
const DartampApp({
required this.initialWindowShade,
required this.initialAlwaysOnTop,
required this.initialEqOpen,
required this.initialPlOpen,
required this.initialUiScale,
required this.skinManager,
super.key,
});
final bool initialWindowShade;
final bool initialAlwaysOnTop;
final bool initialEqOpen;
final bool initialPlOpen;
final double initialUiScale;
final SkinManager skinManager;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: WinampRoot(
initialWindowShade: initialWindowShade,
initialAlwaysOnTop: initialAlwaysOnTop,
initialEqOpen: initialEqOpen,
initialPlOpen: initialPlOpen,
initialUiScale: initialUiScale,
skinManager: skinManager,
),
);
}
}
class WinampRoot extends StatefulWidget {
const WinampRoot({
required this.initialWindowShade,
required this.initialAlwaysOnTop,
required this.initialEqOpen,
required this.initialPlOpen,
required this.initialUiScale,
required this.skinManager,
super.key,
});
final bool initialWindowShade;
final bool initialAlwaysOnTop;
final bool initialEqOpen;
final bool initialPlOpen;
final double initialUiScale;
final SkinManager skinManager;
@override
State<WinampRoot> createState() => _WinampRootState();
}
class _WinampRootState extends State<WinampRoot> {
late final PlayerState _player;
late final SkinManager _skins;
late final DockController _dock;
late final _MainWindowDragController _mainDrag;
bool _windowReady = false;
bool _wasShade = false;
double _wasScale = 1.0;
bool _wasAlwaysOnTop = false;
bool _wasEqOpen = false;
bool _wasPlOpen = false;
String _wasSkinName = baseSkinName;
WindowController? _eqController;
WindowController? _plController;
StreamSubscription<void>? _windowsSubscription;
/// Initial rects queued for a sub-window that has not booted yet; pushed
/// when it reports `subReady`.
final Map<DockWindow, Rect> _pendingSubBounds = {};
/// Saved top-left positions of the sub-windows, loaded at startup.
final Map<SubWindowType, (double, double)> _savedSubPositions = {};
@override
void initState() {
super.initState();
_player = PlayerState(
windowShade: widget.initialWindowShade,
alwaysOnTop: widget.initialAlwaysOnTop,
eqOpen: widget.initialEqOpen,
plOpen: widget.initialPlOpen,
uiScale: widget.initialUiScale,
);
_skins = widget.skinManager;
_dock = DockController(
moveWindow: _moveWindow,
persistRects: _persistRects,
);
_mainDrag = _MainWindowDragController(this);
_wasShade = widget.initialWindowShade;
_wasScale = widget.initialUiScale;
_wasAlwaysOnTop = widget.initialAlwaysOnTop;
_wasEqOpen = widget.initialEqOpen;
_wasPlOpen = widget.initialPlOpen;
_wasSkinName = _skins.active.name;
_player.addListener(_onPlayerChanged);
_skins.addListener(_onSkinChanged);
// Set up method channel for macOS menu actions
winampChannel.setMethodCallHandler(_handleMethodCall);
// Sub-windows report shade toggles and close requests back here.
unawaited(
subWindowChannel.setMethodCallHandler(_onSubWindowCall),
);
// Track native sub-window lifetime so externally-closed windows don't leave
// the Equalizer/Playlist menu checkmarks stuck on.
_windowsSubscription = onWindowsChanged.listen((_) {
unawaited(_reconcileSubWindows());
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_readyWindow();
});
}
@override
void dispose() {
_windowsSubscription?.cancel();
_player.removeListener(_onPlayerChanged);
_skins.removeListener(_onSkinChanged);
_player.dispose();
super.dispose();
}
Future<void> _readyWindow() async {
if (!mounted || _windowReady) return;
try {
await windowManager.setPreventClose(false);
await windowManager.setResizable(false);
await windowManager.setAlwaysOnTop(_player.alwaysOnTop);
await windowManager.setTitle("Winamp");
await _loadScreenBounds();
await windowManager.setSize(_scaledSize());
final savedMain = await PlayerState.loadWindowPos("main");
if (savedMain != null) {
final saved = _clampToScreen(
Rect.fromLTWH(
savedMain.$1,
savedMain.$2,
_scaledSize().width,
_scaledSize().height,
),
_scaledSize(),
);
await windowManager.setPosition(saved.topLeft);
} else {
// No saved position: pick a centered-on-screen default so the window
// always starts fully visible (no off-screen sliver that breaks pointer
// hit-testing against the drag surface).
final screen = _dock.screenRect;
final size = _scaledSize();
if (screen != Rect.zero) {
final x = (screen.width - size.width) / 2;
final y = (screen.height - size.height) / 2;
await windowManager.setPosition(Offset(x, y));
}
}
final mainBounds = await windowManager.getBounds();
_dock.setRect(
DockWindow.main,
Rect.fromLTWH(
mainBounds.left,
mainBounds.top,
mainBounds.width,
mainBounds.height,
),
);
if (mounted) setState(() => _windowReady = true);
debugPrint(
"main window ready: "
"${mainBounds.left},${mainBounds.top} "
"${mainBounds.width}x${mainBounds.height} "
"screen=${_dock.screenRect.left},${_dock.screenRect.top} "
"${_dock.screenRect.width}x${_dock.screenRect.height}",
);
} catch (e, st) {
debugPrint("_readyWindow retry: $e\n$st");
// The native window may not be ready yet on macOS; defer to the next
// frame so later gestures (drag, shade toggle) still work once it is.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _readyWindow();
});
return;
}
unawaited(_syncMenuToNative());
final savedEq = await PlayerState.loadWindowPos("eq");
final savedPl = await PlayerState.loadWindowPos("pl");
if (savedEq != null) _savedSubPositions[SubWindowType.eq] = savedEq;
if (savedPl != null) _savedSubPositions[SubWindowType.pl] = savedPl;
if (_player.eqOpen) unawaited(_openSubWindow(SubWindowType.eq));
if (_player.plOpen) unawaited(_openSubWindow(SubWindowType.pl));
}
/// Clamps [rect] so its top-left stays within the visible screen, keeping at
/// least the window's top-left corner on screen. Returns [rect] unchanged
/// when the screen bounds are not known yet (`screenRect` is zero).
Rect _clampToScreen(Rect rect, Size size) {
final screen = _dock.screenRect;
if (screen == Rect.zero) return rect;
final maxX = screen.width < size.width
? screen.left
: screen.right - size.width;
final maxY = screen.height < size.height
? screen.top
: screen.bottom - size.height;
return Rect.fromLTWH(
rect.left.clamp(screen.left, maxX).toDouble(),
rect.top.clamp(screen.top, maxY).toDouble(),
size.width,
size.height,
);
}
Future<void> _loadScreenBounds() async {
// The native `winamp` channel handler may not be registered on the very
// first frame, so retry briefly before giving up: with screenRect zero, the
// drag loop snaps the main window to the screen origin and it cannot move.
for (var attempt = 0; attempt < 5; attempt++) {
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"getScreenBounds",
);
if (result != null) {
final rect = Rect.fromLTWH(
(result["x"] as num).toDouble(),
(result["y"] as num).toDouble(),
(result["width"] as num).toDouble(),
(result["height"] as num).toDouble(),
);
_dock.screenRect = rect;
debugPrint(
"getScreenBounds: ${rect.left},${rect.top} "
"${rect.width}x${rect.height}",
);
return;
}
} catch (_) {
// ignore: retry; see above
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
debugPrint("getScreenBounds: all attempts failed");
}
Size _scaledSize() {
final base = _player.windowShade ? _shadeSize : _mainSize;
final scale = _player.uiScale;
return Size(base.width * scale, base.height * scale);
}
void _onPlayerChanged() {
if (!_windowReady) return;
final shadeChanged = _player.windowShade != _wasShade;
final scaleChanged = _player.uiScale != _wasScale;
if (shadeChanged || scaleChanged) {
_wasShade = _player.windowShade;
_wasScale = _player.uiScale;
windowManager.setSize(_scaledSize());
final mainRect = _dock.rectOf(DockWindow.main);
if (mainRect != null) {
_dock.repinResized(
DockWindow.main,
Rect.fromLTWH(
mainRect.left,
mainRect.top,
_scaledSize().width,
_scaledSize().height,
),
);
}
}
if (scaleChanged) {
_invokeSub(DockWindow.eq, "setUiScale", _player.uiScale);
_invokeSub(DockWindow.pl, "setUiScale", _player.uiScale);
unawaited(_pushDockContext());
}
windowManager.setAlwaysOnTop(_player.alwaysOnTop);
if (_player.alwaysOnTop != _wasAlwaysOnTop) {
_wasAlwaysOnTop = _player.alwaysOnTop;
_invokeSub(DockWindow.eq, "setAlwaysOnTop", _player.alwaysOnTop);
_invokeSub(DockWindow.pl, "setAlwaysOnTop", _player.alwaysOnTop);
}
if (_player.eqOpen != _wasEqOpen) {
_wasEqOpen = _player.eqOpen;
if (_player.eqOpen) {
unawaited(_openSubWindow(SubWindowType.eq));
} else {
_eqController?.hide();
}
}
if (_player.plOpen != _wasPlOpen) {
_wasPlOpen = _player.plOpen;
if (_player.plOpen) {
unawaited(_openSubWindow(SubWindowType.pl));
} else {
_plController?.hide();
}
}
unawaited(_syncMenuToNative());
}
void _toggleEqWindow() {
_player.setEqOpen(!_player.eqOpen);
}
void _togglePlaylistWindow() {
_player.setPlOpen(!_player.plOpen);
}
void _onSkinChanged() {
final name = _skins.active.name;
if (name == _wasSkinName) return;
_wasSkinName = name;
final skin = _skins.skin;
if (skin != null) unawaited(registerSkinCursors(skin));
_invokeSub(DockWindow.eq, "setSkin", name);
_invokeSub(DockWindow.pl, "setSkin", name);
}
Future<void> _importSkin() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: const ["wsz", "zip"],
dialogTitle: "Load Winamp Skin",
);
final path = result?.files.single.path;
if (path == null) return;
try {
final bytes = await File(path).readAsBytes();
await _skins.importArchive(bytes, fileName: path.split("/").last);
} catch (_) {
// ignore: a failed import leaves the active skin unchanged
}
}
Future<void> _openSubWindow(SubWindowType type) async {
final isEq = type == SubWindowType.eq;
var controller = isEq ? _eqController : _plController;
if (controller != null) {
try {
await controller.show();
} catch (_) {
// The native window is gone (its engine was destroyed); forget it so
// the next open creates a fresh one.
_handleSubWindowLost(isEq ? DockWindow.eq : DockWindow.pl);
return;
}
_setInitialSubRect(type);
return;
}
try {
controller = await WindowController.create(
WindowConfiguration(
arguments: SubWindowArgs(
type: type,
shade: isEq ? _player.eqShade : _player.plShade,
alwaysOnTop: _player.alwaysOnTop,
uiScale: _player.uiScale,
skinName: _skins.active.name,
).encode(),
hiddenAtLaunch: true,
),
);
} catch (_) {
if (isEq) {
_player.setEqOpen(false);
} else {
_player.setPlOpen(false);
}
return;
}
// The sub-window shows itself once configured; queue its initial position
// (restored or default-docked) so it spawns docked, not centered.
_setInitialSubRect(type);
if (isEq) {
_eqController = controller;
} else {
_plController = controller;
}
}
/// Positions a sub-window at its saved position or docked to the main window
/// (EQ to the right, PL below), clamped on screen, and queues the rect for
/// when it boots.
void _setInitialSubRect(SubWindowType type) {
final window = type == SubWindowType.eq ? DockWindow.eq : DockWindow.pl;
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 rect = _defaultSubRect(type, mainRect, size);
final saved = _savedSubPositions[type];
if (saved != null) {
rect = Rect.fromLTWH(saved.$1, saved.$2, size.width, size.height);
} else if (_pendingSubBounds.containsKey(window)) {
rect = _pendingSubBounds[window]!;
}
rect = _clampToScreen(rect, size);
_pendingSubBounds[window] = rect;
_dock.setRect(window, rect);
unawaited(_pushPendingSubBounds(window));
}
Rect _defaultSubRect(
SubWindowType type,
Rect mainRect,
Size size,
) {
final window = type == SubWindowType.eq ? DockWindow.eq : DockWindow.pl;
final siblings = Map<DockWindow, Rect>.of(_dock.rects)..remove(window);
return DockLogic.defaultSubRect(window, mainRect, size, siblings);
}
/// Pushes a queued initial bounds to a sub-window that has booted.
///
/// The bounds stay pending until the invoke succeeds: a sub-window's control
/// channel is only registered once its engine reports `subReady`, and an
/// early `setBounds` otherwise races the registration and fails. A lost
/// channel (the window's engine was destroyed) clears the window from the
/// dock instead.
Future<void> _pushPendingSubBounds(DockWindow window) async {
final rect = _pendingSubBounds[window];
if (rect == null) return;
final controller = window == DockWindow.eq ? _eqController : _plController;
if (controller == null) return;
try {
await controller.invokeMethod("setBounds", {
"x": rect.left,
"y": rect.top,
});
_pendingSubBounds.remove(window);
} on WindowChannelException catch (e) {
_handleSubWindowLost(window, error: e);
} catch (e) {
debugPrint("setBounds pending failed for $window: $e");
}
}
Future<dynamic> _onSubWindowCall(MethodCall call) async {
final args = call.arguments is Map ? call.arguments as Map : const {};
switch (call.method) {
case "toggleEqShade":
_player.toggleEqShade();
case "togglePlShade":
_player.togglePlShade();
case "eqClose":
_dock.removeWindow(DockWindow.eq);
_player.setEqOpen(false);
case "plClose":
_dock.removeWindow(DockWindow.pl);
_player.setPlOpen(false);
case "subReady":
final window = _subWindowFromArgs(args);
if (window == null) return;
unawaited(_pushPendingSubBounds(window));
await _pushDockContext();
case "subBounds":
final window = _subWindowFromArgs(args);
if (window == null) return;
final rect = Rect.fromLTWH(
(args["x"] as num).toDouble(),
(args["y"] as num).toDouble(),
(args["w"] as num).toDouble(),
(args["h"] as num).toDouble(),
);
_dock.subBounds(window, rect);
await _pushDockContext();
}
}
DockWindow? _subWindowFromArgs(Map args) {
final name = args["type"] as String?;
if (name == "eq") return DockWindow.eq;
if (name == "pl") return DockWindow.pl;
return null;
}
/// Pushes the current screen rect, snap settings and all window rects to the
/// open sub-windows so they can snap locally during their own drags.
Future<void> _pushDockContext() async {
final rectsJson = _dock.rects.map(
(window, rect) => MapEntry(
window.name,
{
"x": rect.left,
"y": rect.top,
"w": rect.width,
"h": rect.height,
},
),
);
final args = <String, dynamic>{
"screen": {
"x": _dock.screenRect.left,
"y": _dock.screenRect.top,
"w": _dock.screenRect.width,
"h": _dock.screenRect.height,
},
"snap": _dock.snapEnabled,
"snapLen": _dock.snapLen,
"rects": rectsJson,
};
_invokeSub(DockWindow.eq, "setDockContext", args);
_invokeSub(DockWindow.pl, "setDockContext", args);
}
/// Invokes [method] on a sub-window's control channel without ever leaking
/// an unhandled channel error, and drops the window from the dock when its
/// channel is gone (the native window was destroyed).
void _invokeSub(DockWindow window, String method, [Object? args]) {
final controller = window == DockWindow.eq ? _eqController : _plController;
if (controller == null) return;
unawaited(
controller.invokeMethod(method, args).catchError((Object e) {
if (e is WindowChannelException && e.code == "CHANNEL_UNREGISTERED") {
_handleSubWindowLost(window, error: e);
} else if (e is MissingPluginException) {
// The window's engine is mid-boot; its channel is not registered
// yet. Swallow: the next push reaches it once it is.
} else {
debugPrint("sub invoke $method failed for $window: $e");
}
return null;
}),
);
}
/// Drops a sub-window whose native window/channel is gone (its engine was
/// destroyed), so its rect and menu checkmark do not linger.
void _handleSubWindowLost(DockWindow window, {Object? error}) {
final isEq = window == DockWindow.eq;
if ((isEq ? _eqController : _plController) == null) return;
debugPrint("sub window lost ($window): $error");
if (isEq) {
_eqController = null;
} else {
_plController = null;
}
_dock.removeWindow(window);
if (isEq) {
_player.setEqOpen(false);
} else {
_player.setPlOpen(false);
}
unawaited(_pushDockContext());
}
/// Applies a window rect natively: the main window positions itself;
/// sub-windows are moved over their control channel.
void _moveWindow(DockWindow window, Rect rect) {
if (window == DockWindow.main) {
windowManager.setPosition(rect.topLeft);
return;
}
_invokeSub(
window,
"setBounds",
{
"x": rect.left,
"y": rect.top,
},
);
}
void _persistRects(Map<DockWindow, Rect> rects) {
for (final entry in rects.entries) {
final (name, type) = switch (entry.key) {
DockWindow.main => ("main", null),
DockWindow.eq => ("eq", SubWindowType.eq),
DockWindow.pl => ("pl", SubWindowType.pl),
};
if (type != null) {
_savedSubPositions[type] = (entry.value.left, entry.value.top);
}
unawaited(
PlayerState.saveWindowPos(
name,
entry.value.left,
entry.value.top,
),
);
}
}
Future<void> _reconcileSubWindows() async {
WindowController? eq;
WindowController? pl;
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;
}
} catch (_) {
return;
}
_eqController = eq;
_plController = pl;
if (eq == null && _player.eqOpen) {
_dock.removeWindow(DockWindow.eq);
_player.setEqOpen(false);
}
if (pl == null && _player.plOpen) {
_dock.removeWindow(DockWindow.pl);
_player.setPlOpen(false);
}
if (eq == null || pl == null) {
unawaited(_pushDockContext());
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case "toggleEq":
_toggleEqWindow();
case "togglePlaylist":
_togglePlaylistWindow();
case "toggleAlwaysOnTop":
_player.setAlwaysOnTop(!_player.alwaysOnTop);
case "toggleWindowShade":
_player.toggleWindowShade();
case "setUIScale":
final arguments = call.arguments as Map;
_player.setUiScale((arguments["scale"] as num).toDouble());
case "importSkin":
await _importSkin();
}
}
/// Reports state back to the macOS menu bar so checkmarks stay in sync with
/// what the in-window buttons do. Best-effort; native may not be up yet.
Future<void> _syncMenuToNative() async {
try {
await winampChannel.invokeMethod("setAlwaysOnTop", {
"isAlwaysOnTop": _player.alwaysOnTop,
});
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", {
"scale": _player.uiScale,
});
} on PlatformException {
// ignore: menu sync is best-effort
} on MissingPluginException {
// ignore: menu sync is best-effort
}
}
@override
Widget build(BuildContext context) {
final skin = _skins.skin;
final Widget body;
if (skin == null) {
body = const ColoredBox(color: Colors.black);
} else if (_skins.lastError != null) {
body = ColoredBox(
color: const Color(0xFF00FF00),
child: Center(child: Text("Skin load failed: ${_skins.lastError}")),
);
} else {
body = AnimatedBuilder(
animation: Listenable.merge([_player, _skins]),
builder: (context, _) {
final scale = _player.uiScale;
final height = (_player.windowShade ? _shadeSize : _mainSize).height;
return ColoredBox(
color: const Color(0xFF000000),
child: SkinCursorsGate(
skin: skin,
child: WindowDragSurface(
dragController: _windowReady ? _mainDrag : null,
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,
),
),
),
),
),
);
},
);
}
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,
);
}
}
/// Drives the main window's custom drag loop through the [DockController].
///
/// The cursor's global screen position is fetched from the native winamp
/// channel on every update; absolute positions make the window track the
/// cursor exactly with no feedback loop or accumulated error. If that fetch
/// ever fails (channel down), the drag falls back to a relative mode: the
/// window-local grab offset plus the current window position, which needs no
/// native round-trip at all.
class _MainWindowDragController implements WindowDragController {
_MainWindowDragController(this._state);
final _WinampRootState _state;
bool _active = false;
int _seq = 0;
bool _begun = false;
Offset? _last;
Offset? _relGrab;
@override
void start(ui.Offset globalPosition) {
_active = true;
_seq = 0;
_begun = false;
_last = null;
_relGrab = globalPosition;
debugPrint(
"main drag start (window-local: "
"${globalPosition.dx.toStringAsFixed(1)},"
"${globalPosition.dy.toStringAsFixed(1)})",
);
_fetch(globalPosition);
}
@override
void update(ui.Offset globalPosition) {
if (_active) _fetch(globalPosition);
}
Future<void> _fetch(ui.Offset localPoint) async {
final seq = ++_seq;
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"getMouseLocation",
);
if (!_active || seq != _seq || result == null) return;
final point = Offset(
(result["x"] as num).toDouble(),
(result["y"] as num).toDouble(),
);
if (!_begun) {
_begun = true;
_state._dock.beginMainDrag(point);
debugPrint(
"main drag begun at "
"${point.dx.toStringAsFixed(1)},${point.dy.toStringAsFixed(1)}",
);
}
if (point == _last) return;
_last = point;
_state._dock.mainDragTo(point);
debugPrint(
"main drag to "
"${point.dx.toStringAsFixed(1)},${point.dy.toStringAsFixed(1)}",
);
} catch (_) {
// Relative fallback. The pointer's window-local coordinate shifts as the
// window follows the cursor, so grabbing the delta since the drag began
// and applying it to the current position tracks the cursor exactly.
if (!_active || seq != _seq) return;
final rect = _state._dock.rectOf(DockWindow.main);
final relGrab = _relGrab;
if (rect == null || relGrab == null) return;
if (!_begun) {
// Grab = pointer − window top-left = the window-local grab offset.
_begun = true;
_state._dock.beginMainDrag(rect.topLeft + relGrab);
debugPrint(
"main drag begun (relative) at "
"${rect.topLeft.dx + relGrab.dx},"
"${rect.topLeft.dy + relGrab.dy}",
);
}
_state._dock.moveAttached(
DockWindow.main,
DockLogic.moveBy(
rect,
localPoint.dx - relGrab.dx,
localPoint.dy - relGrab.dy,
),
);
debugPrint(
"main drag to (relative) "
"${localPoint.dx - relGrab.dx},${localPoint.dy - relGrab.dy}",
);
}
}
@override
void end() {
_active = false;
unawaited(_finalize());
}
/// Applies one last cursor position so the window settles exactly where the
/// pointer was released (the in-flight [update] fetches are dropped once
/// [_active] is false), then ends the drag.
Future<void> _finalize() async {
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"getMouseLocation",
);
if (result != null) {
final point = Offset(
(result["x"] as num).toDouble(),
(result["y"] as num).toDouble(),
);
if (point != _last) {
_last = point;
_state._dock.mainDragTo(point);
}
}
} catch (_) {
// ignore: end the drag with the position already applied
}
_state._dock.endMainDrag();
}
}