0f6c3faf6a
- 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>
927 lines
30 KiB
Dart
927 lines
30 KiB
Dart
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();
|
||
}
|
||
}
|