+148
-290
@@ -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<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();
|
||||
@@ -56,6 +51,7 @@ Future<void> main(List<String> 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<SubWindowArgs?> _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;
|
||||
}
|
||||
}
|
||||
|
||||
class DartampApp extends StatelessWidget {
|
||||
@@ -140,6 +135,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
bool _wasEqOpen = false;
|
||||
bool _wasPlOpen = false;
|
||||
String _wasSkinName = baseSkinName;
|
||||
|
||||
WindowController? _eqController;
|
||||
WindowController? _plController;
|
||||
WindowController? _presetsController;
|
||||
|
||||
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 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<DockWindow, Offset> _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<DockWindow, Rect> _pendingSubBounds = {};
|
||||
Rect? _pendingPresetsBounds;
|
||||
final Map<DockWindow, Offset> _pendingMoves = {};
|
||||
final Map<SubWindowType, (double, double)> _savedSubPositions = {};
|
||||
|
||||
/// Saved sizes (width, height) of sub-windows, loaded at startup.
|
||||
final Map<SubWindowType, (double, double)> _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<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
|
||||
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();
|
||||
// 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<WinampRoot> {
|
||||
);
|
||||
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<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
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<void> _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<WinampRoot> {
|
||||
}
|
||||
|
||||
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(
|
||||
_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<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
debugPrint("getScreenBounds: all attempts failed");
|
||||
}
|
||||
|
||||
Size _scaledSize() {
|
||||
@@ -369,15 +347,19 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
|
||||
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<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
);
|
||||
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<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(
|
||||
@@ -485,15 +464,10 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
),
|
||||
);
|
||||
} 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<WinampRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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,
|
||||
@@ -571,8 +533,6 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the EQ presets dialog (spawned by the EQ presets button), switching
|
||||
/// an already-open dialog to [mode].
|
||||
Future<void> _openPresetsWindow(String mode) async {
|
||||
final existing = _presetsController;
|
||||
if (existing != null) {
|
||||
@@ -580,10 +540,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<WinampRoot> {
|
||||
_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<WinampRoot> {
|
||||
);
|
||||
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<void> _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<WinampRoot> {
|
||||
_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<WinampRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
_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<WinampRoot> {
|
||||
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<WinampRoot> {
|
||||
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,
|
||||
{
|
||||
(window, rect) => MapEntry(window.name, {
|
||||
"x": rect.left,
|
||||
"y": rect.top,
|
||||
"w": rect.width,
|
||||
"h": rect.height,
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
final args = <String, dynamic>{
|
||||
"screen": {
|
||||
@@ -775,25 +716,21 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
"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<WinampRoot> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<WinampRoot> {
|
||||
_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<WinampRoot> {
|
||||
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<void> _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<WinampRoot> {
|
||||
} 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<WinampRoot> {
|
||||
_dock.removeWindow(DockWindow.pl);
|
||||
_player.setPlOpen(false);
|
||||
}
|
||||
if (eq == null || pl == null) {
|
||||
unawaited(_pushDockContext());
|
||||
}
|
||||
if (eq == null || pl == null) unawaited(_pushDockContext());
|
||||
}
|
||||
|
||||
Future<dynamic> _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<WinampRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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", {
|
||||
@@ -940,26 +854,24 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
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(
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_player, _skins]),
|
||||
builder: (context, _) {
|
||||
final scale = _player.uiScale;
|
||||
@@ -990,18 +902,8 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
},
|
||||
);
|
||||
}
|
||||
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<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) {
|
||||
|
||||
if (_useNative == null || _useNative == true) {
|
||||
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(),
|
||||
);
|
||||
_applyNativeDrag(point);
|
||||
return;
|
||||
} catch (_) {
|
||||
if (_useNative == true) return; // Prevent mid-drag switching
|
||||
_useNative = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (_useNative == false) {
|
||||
_applyRelativeDrag(localPoint, seq);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyNativeDrag(Offset point) {
|
||||
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.
|
||||
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<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",
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user