Files
dartamp/lib/main.dart
T

1001 lines
33 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);
// Toggle to enable extra drag diagnostics (kept off by default).
const bool _dragDiagnostics = false;
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 = {};
/// The latest desired top-left per window, coalesced by [_flushMoves].
final Map<DockWindow, Offset> _pendingMoves = {};
Timer? _moveFlushTimer;
/// 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<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() {
_moveFlushTimer?.cancel();
_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;
final newSize = _scaledSize();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(windowManager.setSize(newSize));
});
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());
}
unawaited(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 {
unawaited(_eqController?.hide());
}
}
if (_player.plOpen != _wasPlOpen) {
_wasPlOpen = _player.plOpen;
if (_player.plOpen) {
unawaited(_openSubWindow(SubWindowType.pl));
} else {
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);
}
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);
// 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,
) {
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.
///
/// 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);
}
void _flushMoves() {
_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},
);
}
}
}
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;
bool? _useNative;
@override
void start(ui.Offset globalPosition) {
_active = true;
_seq = 0;
_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)})",
);
_fetch(globalPosition);
}
@override
void update(ui.Offset globalPosition) {
if (_active) _fetch(globalPosition);
}
Future<void> _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) {
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"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)");
}
return;
} catch (_) {
_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<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);
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;
}
// 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.
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(
rect,
localPoint.dx - relGrab.dx,
localPoint.dy - relGrab.dy,
),
);
if (_dragDiagnostics)
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 {
// 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<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();
}
}