diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 32e4bc5..90f24e7 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart'; import 'app.dart'; import 'src/playback/yt_dlp_app.dart'; import 'src/platform/desktop_entry.dart'; +import 'src/platform/window_memory.dart'; import 'src/screens/home/ui.dart'; import 'src/state/session_state.dart'; import 'src/state/settings.dart'; @@ -40,23 +41,25 @@ Future main(List args) async { ), ); WidgetsFlutterBinding.ensureInitialized(); + await Hive.initFlutter(); + await AppSettings.instance.ensureLoaded(); await windowManager.ensureInitialized(); await windowManager.setPreventClose(true); - windowManager.addListener(_CloseGuard()); - const windowOptions = WindowOptions( - size: Size(1280, 720), - minimumSize: Size(160, 90), - center: true, - backgroundColor: Color(0xFF000000), + final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); + windowManager.addListener(_CloseGuard(memory)); + final windowOptions = WindowOptions( + size: memory.size, + minimumSize: const Size(160, 90), + center: !memory.hasPosition, + backgroundColor: const Color(0xFF000000), titleBarStyle: TitleBarStyle.hidden, title: appName, ); windowManager.waitUntilReadyToShow(windowOptions, () async { + await memory.restore(); await windowManager.show(); await windowManager.focus(); }); - await Hive.initFlutter(); - await AppSettings.instance.ensureLoaded(); try { await SessionState.instance.loadForm(); } catch (e, st) { @@ -74,12 +77,15 @@ Future main(List args) async { /// hosted server shuts down), bounded so a hung socket can't keep the /// window open. class _CloseGuard with WindowListener { + final WindowMemory memory; bool _closing = false; + _CloseGuard(this.memory); @override Future onWindowClose() async { if (_closing) return; _closing = true; + await memory.save(); try { await SessionState.instance.leave().timeout(const Duration(seconds: 2)); } catch (e) { diff --git a/apps/desktop/lib/src/platform/window_memory.dart b/apps/desktop/lib/src/platform/window_memory.dart new file mode 100644 index 0000000..a34c405 --- /dev/null +++ b/apps/desktop/lib/src/platform/window_memory.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:window_manager/window_manager.dart'; + +/// The window's size, position and maximized state across restarts (and +/// hot restarts), in the settings box. The size and position kept are +/// the normal (unmaximized) ones; fullscreen is never saved or restored. +/// Wayland lets no app place its own window, so there only the size and +/// maximized state come back (the compositor decides the position). +class WindowMemory with WindowListener { + static const _key = 'windowBounds'; + static const defaultSize = Size(1280, 720); + + // Null when the settings box failed to open: defaults, nothing saved. + final Box? _box; + Timer? _save; + WindowMemory(this._box); + + static bool get _canPlace => !(Platform.isLinux && Platform.environment['WAYLAND_DISPLAY'] != null); + + Map? get _saved => _box?.get(_key) is Map ? _box!.get(_key) as Map : null; + + /// The size to open at: the last normal size, else [defaultSize]. + Size get size { + final s = _saved; + final w = s?['w'], h = s?['h']; + return w is num && h is num && w >= 160 && h >= 90 ? Size(w.toDouble(), h.toDouble()) : defaultSize; + } + + /// Whether a saved position exists to place the window at (else center). + bool get hasPosition => _canPlace && _saved?['x'] is num && _saved?['y'] is num; + + /// After the window shows: back to the saved position when it still + /// lands on a connected display, maximized when it was; then listens. + Future restore() async { + final s = _saved; + try { + if (s != null && hasPosition) { + final rect = Offset((s['x'] as num).toDouble(), (s['y'] as num).toDouble()) & size; + if (await _onSomeDisplay(rect)) { + await windowManager.setPosition(rect.topLeft); + } else { + await windowManager.center(); + } + } + if (s?['max'] == true) await windowManager.maximize(); + } catch (e) { + debugPrint('window restore failed: $e'); + } + windowManager.addListener(this); + } + + // At least a 100×50 corner of the title area on a display's visible area. + static Future _onSomeDisplay(Rect r) async { + final grip = Rect.fromLTWH(r.left, r.top, r.width.clamp(0, 100).toDouble(), 50); + for (final d in await screenRetriever.getAllDisplays()) { + final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size); + if (area.overlaps(grip)) return true; + } + return false; + } + + void _later() { + _save?.cancel(); + _save = Timer(const Duration(milliseconds: 400), () => unawaited(save())); + } + + /// Writes the current state now (also on close). + Future save() async { + _save?.cancel(); + final box = _box; + if (box == null) return; + try { + if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return; + final max = await windowManager.isMaximized(); + final s = Map.of((_saved ?? const {}).cast()); + s['max'] = max; + if (!max) { + final b = await windowManager.getBounds(); + s['w'] = b.width; + s['h'] = b.height; + if (_canPlace) { + s['x'] = b.left; + s['y'] = b.top; + } + } + await box.put(_key, s); + } catch (e) { + debugPrint('window save failed: $e'); + } + } + + @override + void onWindowResized() => _later(); + @override + void onWindowMoved() => _later(); + @override + void onWindowMaximize() => _later(); + @override + void onWindowUnmaximize() => _later(); +} diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index 2b90c66..93e2122 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -670,7 +670,7 @@ packages: source: hosted version: "2.0.6" screen_retriever: - dependency: transitive + dependency: "direct main" description: name: screen_retriever sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index abd927c..547f9ac 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: hive_ce_flutter: ^2.3.4 image: ^4.9.2 window_manager: ^0.5.2 + screen_retriever: ^0.2.2 crypto: ^3.0.6 ffi: ^2.1.0 # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e78a485..688d5e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -923,7 +923,12 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in ## Desktop UI One window, `AppShell` (`lib/src/shell/`), whatever the connection state -(online = `SessionState.route` is `session`). Motion is shared +(online = `SessionState.route` is `session`). The window comes back as it was +(`platform/window_memory.dart`, settings key `windowBounds`, saved on +resize/move/maximize and close): its normal size and maximized state +everywhere, its position where an app may place itself (Windows, X11; +not Wayland, where the compositor decides) and only if it still lands +on a connected display, else centred. Fullscreen is not restored. Motion is shared (`motion.dart`): one curve (`fastEaseInToSlowEaseOut`, 320 ms), a fade through for views that replace each other (the old one gone after 35%, the new one fading and scaling in from 0.985), `showPopover` (cards that fade