From fd4f00041a81bfe0f206f0f50426ad7a44a2b2e0 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 18 Aug 2026 15:24:00 +0200 Subject: [PATCH] Main cleanup Signed-off-by: Hans Kokx --- lib/main.dart | 508 ++++++++++++++++++-------------------------------- 1 file changed, 183 insertions(+), 325 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index a54bbec..3726e41 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,15 +23,10 @@ import "src/windows/window_args.dart"; const Size _mainSize = Size(275, 116); const Size _shadeSize = Size(275, 14); -// Toggle to enable extra drag diagnostics (kept off by default). -const bool _dragDiagnostics = false; Future main(List 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(); @@ -56,6 +51,7 @@ Future main(List args) async { final plOpen = await PlayerState.loadPlOpen(); final uiScale = await PlayerState.loadUiScale(); final skinManager = await SkinManager.load(); + await windowManager.ensureInitialized(); runApp( DartampApp( @@ -74,9 +70,8 @@ Future _resolveSubWindowArgs() async { 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; } - return null; } class DartampApp extends StatelessWidget { @@ -140,6 +135,7 @@ class _WinampRootState extends State { late final SkinManager _skins; late final DockController _dock; late final _MainWindowDragController _mainDrag; + bool _windowReady = false; bool _wasShade = false; double _wasScale = 1.0; @@ -147,35 +143,41 @@ class _WinampRootState extends State { bool _wasEqOpen = false; bool _wasPlOpen = false; 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; + Timer? _presetsTimer; - /// How often pending window moves are flushed to the native side. static const Duration _moveFlushInterval = Duration(milliseconds: 16); - /// Saved top-left positions of the sub-windows, loaded at startup. + final Map _pendingSubBounds = {}; + Rect? _pendingPresetsBounds; + final Map _pendingMoves = {}; final Map _savedSubPositions = {}; - - /// Saved sizes (width, height) of sub-windows, loaded at startup. final Map _savedSubSizes = {}; @override void initState() { super.initState(); + _initializeState(); + + winampChannel.setMethodCallHandler(_handleMethodCall); + unawaited(subWindowChannel.setMethodCallHandler(_onSubWindowCall)); + + _windowsSubscription = onWindowsChanged.listen((_) { + unawaited(_reconcileSubWindows()); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _readyWindow(); + }); + } + + void _initializeState() { _player = PlayerState( windowShade: widget.initialWindowShade, alwaysOnTop: widget.initialAlwaysOnTop, @@ -189,37 +191,22 @@ class _WinampRootState extends State { 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() { _moveFlushTimer?.cancel(); + _presetsTimer?.cancel(); _windowsSubscription?.cancel(); _player.removeListener(_onPlayerChanged); _skins.removeListener(_onSkinChanged); @@ -229,19 +216,23 @@ class _WinampRootState extends State { Future _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(); - // Try to restore a saved size first (user-resized main window), falling - // back to the default scaled size when none saved. + + if (!mounted) return; + 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( @@ -255,9 +246,6 @@ class _WinampRootState extends State { ); 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) { @@ -266,7 +254,10 @@ class _WinampRootState extends State { await windowManager.setPosition(Offset(x, y)); } } + final mainBounds = await windowManager.getBounds(); + if (!mounted) return; + _dock.setRect( DockWindow.main, Rect.fromLTWH( @@ -276,52 +267,48 @@ class _WinampRootState extends State { 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}", - ); + + setState(() => _windowReady = true); } 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()); + await _loadSubWindowStates(); + } + + Future _loadSubWindowStates() async { 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) { + if (savedEqSize != null) _savedSubSizes[SubWindowType.eq] = (savedEqSize.$1, savedEqSize.$2); - } - if (savedPlSize != null) { + if (savedPlSize != null) _savedSubSizes[SubWindowType.pl] = (savedPlSize.$1, savedPlSize.$2); - } + 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(), @@ -331,34 +318,25 @@ class _WinampRootState extends State { } Future _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>( "getScreenBounds", ); if (result != null) { - final rect = Rect.fromLTWH( + _dock.screenRect = 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 + // Retry logic expected } await Future.delayed(const Duration(milliseconds: 100)); } - debugPrint("getScreenBounds: all attempts failed"); } Size _scaledSize() { @@ -369,15 +347,19 @@ class _WinampRootState extends State { void _onPlayerChanged() { if (!_windowReady) return; + final shadeChanged = _player.windowShade != _wasShade; final scaleChanged = _player.uiScale != _wasScale; + if (shadeChanged || scaleChanged) { _wasShade = _player.windowShade; _wasScale = _player.uiScale; final newSize = _scaledSize(); + WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(windowManager.setSize(newSize)); }); + final mainRect = _dock.rectOf(DockWindow.main); if (mainRect != null) { _dock.repinResized( @@ -385,57 +367,53 @@ class _WinampRootState extends State { Rect.fromLTWH( mainRect.left, mainRect.top, - _scaledSize().width, - _scaledSize().height, + newSize.width, + newSize.height, ), ); } } + if (scaleChanged) { _invokeSub(DockWindow.eq, "setUiScale", _player.uiScale); _invokeSub(DockWindow.pl, "setUiScale", _player.uiScale); unawaited(_pushDockContext()); } + unawaited(windowManager.setAlwaysOnTop(_player.alwaysOnTop)); + if (_player.alwaysOnTop != _wasAlwaysOnTop) { _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; - if (_player.eqOpen) { - unawaited(_openSubWindow(SubWindowType.eq)); - } else { - unawaited(_eqController?.hide()); - } + _player.eqOpen + ? unawaited(_openSubWindow(SubWindowType.eq)) + : unawaited(_eqController?.hide()); } + if (_player.plOpen != _wasPlOpen) { _wasPlOpen = _player.plOpen; - if (_player.plOpen) { - unawaited(_openSubWindow(SubWindowType.pl)); - } else { - unawaited(_plController?.hide()); - } + _player.plOpen + ? unawaited(_openSubWindow(SubWindowType.pl)) + : unawaited(_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); } @@ -448,29 +426,30 @@ class _WinampRootState extends State { ); 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 + // Ignored: active skin remains unchanged } } Future _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( @@ -485,15 +464,10 @@ class _WinampRootState extends State { ), ); } catch (_) { - if (isEq) { - _player.setEqOpen(false); - } else { - _player.setPlOpen(false); - } + isEq ? _player.setEqOpen(false) : _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; @@ -502,60 +476,48 @@ class _WinampRootState extends State { } } - /// 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; 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) { rect = Rect.fromLTWH(saved.$1, saved.$2, size.width, size.height); - // If the saved position is near the main window's bottom edge within - // the snap tolerance, snap it exactly so the window restores docked - // rather than merely appearing visually aligned but detached. rect = DockLogic.snapWindowBelow(rect, mainRect, snapLen: _dock.snapLen); } 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, - ) { + Rect _defaultSubRect(SubWindowType type, Rect mainRect, Size size) { final window = type == SubWindowType.eq ? DockWindow.eq : DockWindow.pl; final siblings = Map.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 _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, @@ -571,8 +533,6 @@ 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) { @@ -580,10 +540,7 @@ class _WinampRootState extends State { 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; @@ -592,8 +549,10 @@ class _WinampRootState extends State { } } } + final mainRect = _dock.rectOf(DockWindow.main); final eqRect = _dock.rectOf(DockWindow.eq); + try { final controller = await WindowController.create( WindowConfiguration( @@ -612,18 +571,17 @@ class _WinampRootState extends State { _setInitialPresetsRect(eqRect ?? mainRect); unawaited(_pushPresetsBounds()); } catch (_) { - // ignore: failed to open the presets window + // Ignore open failure } } - /// 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( @@ -634,17 +592,17 @@ class _WinampRootState extends State { ); 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, @@ -657,7 +615,8 @@ class _WinampRootState extends State { _handlePresetsWindowLost(); } catch (_) { if (mounted && _presetsController != null) { - Timer(const Duration(milliseconds: 120), () { + _presetsTimer?.cancel(); + _presetsTimer = Timer(const Duration(milliseconds: 120), () { if (mounted && _presetsController != null) { unawaited(_pushPresetsBounds()); } @@ -666,25 +625,17 @@ class _WinampRootState extends State { } } - /// 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; - }), - ); + _presetsController?.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; } @@ -705,20 +656,15 @@ class _WinampRootState extends State { 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; + if (_eqController == null) return null; try { - return await controller.invokeMethod("getEqSettings"); - } on WindowChannelException { - return null; - } on MissingPluginException { + return await _eqController!.invokeMethod("getEqSettings"); + } catch (_) { return null; } case "subReady": @@ -750,19 +696,14 @@ class _WinampRootState extends State { 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 _pushDockContext() async { final rectsJson = _dock.rects.map( - (window, rect) => MapEntry( - window.name, - { - "x": rect.left, - "y": rect.top, - "w": rect.width, - "h": rect.height, - }, - ), + (window, rect) => MapEntry(window.name, { + "x": rect.left, + "y": rect.top, + "w": rect.width, + "h": rect.height, + }), ); final args = { "screen": { @@ -775,25 +716,21 @@ class _WinampRootState extends State { "snapLen": _dock.snapLen, "rects": rectsJson, }; + _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 - /// 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 { + } else if (e is! MissingPluginException) { debugPrint("sub invoke $method failed for $window: $e"); } return null; @@ -801,34 +738,22 @@ class _WinampRootState extends State { ); } - /// 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 { + _plController = null; _player.setPlOpen(false); } + + _dock.removeWindow(window); unawaited(_pushDockContext()); } - /// Applies a window rect natively: the main window positions itself; - /// sub-windows are moved over their control channel. - /// - /// Moves are coalesced: each call only records the latest position and a - /// single flush applies them up to [_moveFlushInterval] later. A fast drag - /// emits hundreds of positions per second; sending them all would saturate - /// the platform thread (a sub-window move crosses three channel hops) and - /// the windows would lag the cursor and catch up in a cascade of jumps. void _moveWindow(DockWindow window, Rect rect) { _pendingMoves[window] = rect.topLeft; _moveFlushTimer ??= Timer(_moveFlushInterval, _flushMoves); @@ -838,17 +763,14 @@ class _WinampRootState extends State { _moveFlushTimer = null; final moves = Map.of(_pendingMoves); _pendingMoves.clear(); + for (final entry in moves.entries) { final window = entry.key; final topLeft = entry.value; if (window == DockWindow.main) { windowManager.setPosition(topLeft); } else { - _invokeSub( - window, - "setBounds", - {"x": topLeft.dx, "y": topLeft.dy}, - ); + _invokeSub(window, "setBounds", {"x": topLeft.dx, "y": topLeft.dy}); } } } @@ -867,19 +789,13 @@ class _WinampRootState extends State { PlayerState.saveWindowPos(name, entry.value.left, entry.value.top), ); unawaited( - PlayerState.saveWindowSize( - name, - entry.value.width, - entry.value.height, - ), + PlayerState.saveWindowSize(name, entry.value.width, entry.value.height), ); } } Future _reconcileSubWindows() async { - WindowController? eq; - WindowController? pl; - WindowController? presets; + WindowController? eq, pl, presets; try { for (final window in await WindowController.getAll()) { final args = SubWindowArgs.tryDecode(window.arguments); @@ -891,9 +807,11 @@ class _WinampRootState extends State { } catch (_) { return; } + _eqController = eq; _plController = pl; _presetsController = presets; + if (eq == null && _player.eqOpen) { _dock.removeWindow(DockWindow.eq); _player.setEqOpen(false); @@ -902,17 +820,15 @@ class _WinampRootState extends State { _dock.removeWindow(DockWindow.pl); _player.setPlOpen(false); } - if (eq == null || pl == null) { - unawaited(_pushDockContext()); - } + if (eq == null || pl == null) unawaited(_pushDockContext()); } Future _handleMethodCall(MethodCall call) async { switch (call.method) { case "toggleEq": - _toggleEqWindow(); + _player.setEqOpen(!_player.eqOpen); case "togglePlaylist": - _togglePlaylistWindow(); + _player.setPlOpen(!_player.plOpen); case "toggleAlwaysOnTop": _player.setAlwaysOnTop(!_player.alwaysOnTop); case "toggleWindowShade": @@ -925,8 +841,6 @@ class _WinampRootState extends State { } } - /// 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 _syncMenuToNative() async { try { await winampChannel.invokeMethod("setAlwaysOnTop", { @@ -940,68 +854,56 @@ class _WinampRootState extends State { await winampChannel.invokeMethod("setUiScale", { "scale": _player.uiScale, }); - } on PlatformException { - // ignore: menu sync is best-effort - } on MissingPluginException { - // ignore: menu sync is best-effort + } catch (_) { + // 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( + + if (skin == null) return const ColoredBox(color: Colors.black); + if (_skins.lastError != null) { + return 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 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 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); @@ -1021,12 +923,7 @@ class _MainWindowDragController implements WindowDragController { _begun = false; _last = null; _relGrab = globalPosition; - _useNative = null; // undecided until first fetch - debugPrint( - "main drag start (window-local: " - "${globalPosition.dx.toStringAsFixed(1)}," - "${globalPosition.dy.toStringAsFixed(1)})", - ); + _useNative = null; _fetch(globalPosition); } @@ -1037,84 +934,55 @@ class _MainWindowDragController implements WindowDragController { Future _fetch(ui.Offset localPoint) async { final seq = ++_seq; - // Decide once whether to use native absolute coordinates or the local - // relative fallback for the whole drag. This avoids mid-drag source - // switching that causes visible jumps when the native channel is - // intermittently available. - if (_useNative == null) { + + if (_useNative == null || _useNative == true) { try { final result = await winampChannel.invokeMethod>( "getMouseLocation", ); if (!_active || seq != _seq || result == null) return; + _useNative = true; final point = Offset( (result["x"] as num).toDouble(), (result["y"] as num).toDouble(), ); - if (!_begun) { - _begun = true; - _state._dock.beginMainDrag(point); - if (_dragDiagnostics) - debugPrint("main drag begun at $point (native)"); - } - if (point != _last) { - _last = point; - _state._dock.mainDragTo(point); - if (_dragDiagnostics) debugPrint("main drag to $point (native)"); - } + _applyNativeDrag(point); return; } catch (_) { + if (_useNative == true) return; // Prevent mid-drag switching _useNative = false; - if (_dragDiagnostics) - debugPrint("main drag: native unavailable, using relative mode"); - // fall through to relative path below } } - if (_useNative == true) { - // Native already chosen; attempt to fetch latest but ignore errors. - try { - final result = await winampChannel.invokeMethod>( - "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); - if (_dragDiagnostics) - debugPrint("main drag begun at $point (native)"); - } - if (point == _last) return; - _last = point; - _state._dock.mainDragTo(point); - if (_dragDiagnostics) debugPrint("main drag to $point (native)"); - } catch (_) { - // Ignore transient failures once native was chosen; avoid switching - // to relative mid-drag to prevent jumps. - } - return; + if (_useNative == false) { + _applyRelativeDrag(localPoint, seq); } + } - // Relative fallback path (chosen because native was unavailable at drag - // start). Use the window-local grab offset consistently for the whole - // drag so coordinates don't oscillate. + void _applyNativeDrag(Offset point) { + if (!_begun) { + _begun = true; + _state._dock.beginMainDrag(point); + } + if (point != _last) { + _last = point; + _state._dock.mainDragTo(point); + } + } + + void _applyRelativeDrag(ui.Offset localPoint, int seq) { if (!_active || seq != _seq) return; + final rect = _state._dock.rectOf(DockWindow.main); final relGrab = _relGrab; if (rect == null || relGrab == null) return; + if (!_begun) { _begun = true; _state._dock.beginMainDrag(rect.topLeft + relGrab); - if (_dragDiagnostics) - debugPrint( - "main drag begun (relative) at ${rect.topLeft.dx + relGrab.dx},${rect.topLeft.dy + relGrab.dy}", - ); } + _state._dock.moveAttached( DockWindow.main, DockLogic.moveBy( @@ -1123,10 +991,6 @@ class _MainWindowDragController implements WindowDragController { localPoint.dy - relGrab.dy, ), ); - if (_dragDiagnostics) - debugPrint( - "main drag to (relative) ${localPoint.dx - relGrab.dx},${localPoint.dy - relGrab.dy}", - ); } @override @@ -1135,14 +999,8 @@ class _MainWindowDragController implements WindowDragController { 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 _finalize() async { try { - // Only query native if we chose native mode for this drag. If we - // started in relative mode, skip the native fetch to avoid switching - // sources at the last moment. if (_useNative != false) { final result = await winampChannel.invokeMethod>( "getMouseLocation", @@ -1159,7 +1017,7 @@ class _MainWindowDragController implements WindowDragController { } } } catch (_) { - // ignore: end the drag with the position already applied + // Drag ends with the last position already applied } _state._dock.endMainDrag(); }