9a8a8a02ad
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
997 lines
32 KiB
Dart
997 lines
32 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);
|
|
} 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();
|
|
}
|
|
}
|