diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 770bb50..1b8db0b 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -5,18 +5,16 @@ import 'package:flutter/material.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:media_kit/media_kit.dart'; import 'package:sync_relay_core/core.dart'; -import 'package:window_manager/window_manager.dart'; import 'app.dart'; -import 'src/playback/yt_dlp_app.dart'; import 'src/platform/app_log.dart'; import 'src/platform/app_shutdown.dart'; import 'src/platform/desktop_entry.dart'; -import 'src/platform/window_memory.dart'; import 'src/screens/home/ui.dart'; +import 'src/session/app_events.dart' show UpdateAvailable; import 'src/state/session_state.dart'; import 'src/state/settings.dart'; -import 'src/update/update_controller.dart' show launchArgs; +import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs; Future main(List args) async { launchArgs = List.unmodifiable(args); @@ -46,24 +44,11 @@ Future main(List args) async { await startAppLogging(); await Hive.initFlutter(); await AppSettings.instance.ensureLoaded(); - await windowManager.ensureInitialized(); - await windowManager.setPreventClose(true); - final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); - shutdownWindowMemory = memory; - windowManager.addListener(_CloseGuard()); - 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(); - }); + // Close button / Alt+F4: the orderly close ([shutDownApp]), so others + // see "left", a hosted server shuts down and no player outlives the + // engine. + final ports = SessionState.instance.ports; + await ports.shell.start(title: appName, onCloseRequest: shutDownApp); try { await SessionState.instance.loadForm(); } catch (e, st) { @@ -71,18 +56,19 @@ Future main(List args) async { } MediaKit.ensureInitialized(); // yt-dlp for web page links: downloaded on first run, kept current. - AppYtDlp.instance.warmUp(); + ports.links.warmUp(); // Linux: the launcher entry and icon Wayland needs (release installs). unawaited(installDesktopEntry()); + // A found update is an event too (the band shows it). + final updates = UpdateController.instance; + updates.status.addListener(() { + final s = updates.status.value; + final version = s.manifest?.version; + if (s.phase == UpdatePhase.available && version != null) { + SessionState.instance.notifier.notify(UpdateAvailable(version)); + } + }); runApp(const WatchTogetherApp()); // The sound device opens once the window is up, not on the first sound. Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp); } - -/// Close button / Alt+F4: the orderly close ([shutDownApp]), so others -/// see "left", a hosted server shuts down and no player outlives the -/// engine. -class _CloseGuard with WindowListener { - @override - void onWindowClose() => unawaited(shutDownApp()); -} diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index 6d8098e..7d2bfba 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; import '../../shell/motion.dart'; +import '../../platform/ports/folder_access.dart'; +import '../../state/session_state.dart'; import '../../widgets/app_menu.dart'; import '../folder_store.dart'; import '../name_parser.dart'; @@ -32,6 +33,8 @@ String? _lastOpen; final _expanded = {}; final _showRequests = ValueNotifier(0); +FolderAccess get _folders => SessionState.instance.ports.folders; + /// Opens [dir] (null: Recent) in the Files page: now if one is showing, /// else when it next opens. void showFilesFolder(String? dir) { @@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget { final String? nowPlayingPath; /// Defaults to [thumbnailFor]. final ThumbnailLoader? thumbnails; - /// Defaults to [scanFolder]. + /// Defaults to [FolderAccess.scan]. final Future Function(String dir)? scan; /// Defaults to [FolderStore.instance]. final FolderStore? store; @@ -142,7 +145,7 @@ class _FilesPageState extends State { void _shown() => _select(_lastOpen); - Future _runScan(String dir) => (widget.scan ?? scanFolder)(dir); + Future _runScan(String dir) => (widget.scan ?? _folders.scan)(dir); void _select(String? dir) { setState(() { @@ -176,7 +179,7 @@ class _FilesPageState extends State { String? dir; try { dir = await (widget.pickFolder ?? - () => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))(); + () => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))(); } catch (e) { _showError('Folder dialog unavailable (${e.runtimeType})'); } @@ -196,12 +199,8 @@ class _FilesPageState extends State { } static Future _pickVideo() async { - final files = await FilePicker.pickFiles( - dialogTitle: 'Pick a video', - type: FileType.custom, - allowedExtensions: videoExtensions.toList(), - ); - return files.isEmpty ? null : files.first.path; + final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList()); + return files.firstOrNull; } void _removeFolder(String dir) { @@ -589,7 +588,7 @@ class _FilesPageState extends State { ), if (scan.videos.isEmpty) SliverToBoxAdapter( - child: Directory(dir).existsSync() + child: _folders.folderExists(dir) ? const FilesEmpty( Icons.search_off, 'No videos here', @@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> { @override void initState() { super.initState(); - File(widget.path).stat().then((s) { + _folders.stat(widget.path).then((s) { if (mounted) setState(() => _stat = s); }, onError: (Object _) {}); } diff --git a/apps/desktop/lib/src/platform/app_log.dart b/apps/desktop/lib/src/platform/app_log.dart index e9fbace..7657dad 100644 --- a/apps/desktop/lib/src/platform/app_log.dart +++ b/apps/desktop/lib/src/platform/app_log.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sync_relay_core/core.dart'; -import '../playback/yt_dlp_app.dart'; import '../screens/home/ui.dart' show appName, displayVersion; import '../state/session_state.dart'; @@ -59,7 +58,7 @@ Future diagnosticsText({Duration since = const Duration(hours: 6), bool b.writeln('mpv: unknown'); } try { - final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2)); + final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2)); final cookies = y.cookies.read(count: false); b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, ' 'cookies: ${cookies == null ? 'none' : 'set'}'); diff --git a/apps/desktop/lib/src/platform/app_shutdown.dart b/apps/desktop/lib/src/platform/app_shutdown.dart index c7ccd46..e7883e7 100644 --- a/apps/desktop/lib/src/platform/app_shutdown.dart +++ b/apps/desktop/lib/src/platform/app_shutdown.dart @@ -2,11 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:window_manager/window_manager.dart'; import '../files/thumbnails.dart'; import '../state/session_state.dart'; -import 'window_memory.dart'; // The one way out of the app (close button, Alt+F4, Update and restart). // Order matters: every mpv player goes before the engine does (a live one @@ -15,9 +13,6 @@ import 'window_memory.dart'; // inside the message loop. The runners end the process right after // (`linux/runner/main.cc`, `windows/runner/main.cpp`). -/// Saved on the way out; set by main once known. -WindowMemory? shutdownWindowMemory; - bool _shuttingDown = false; /// True once [shutDownApp] started. @@ -30,9 +25,10 @@ Future shutDownApp({Future Function()? beforeQuit}) async { if (_shuttingDown) return; _shuttingDown = true; final session = SessionState.instance; + final shell = session.ports.shell; final total = Stopwatch()..start(); - await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500)); - await _step('hide', windowManager.hide(), const Duration(milliseconds: 500)); + await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500)); + await _step('hide', shell.hide(), const Duration(milliseconds: 500)); await Future.wait([ _step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)), _step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)), @@ -48,13 +44,7 @@ Future shutDownApp({Future Function()? beforeQuit}) async { await _step('server stop', session.stopServer(), const Duration(milliseconds: 500)); if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3)); debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms'); - try { - await windowManager.setPreventClose(false); - await windowManager.close(); - } catch (e) { - debugPrint('window close failed: $e'); - await windowManager.destroy(); - } + await shell.quit(); // The engine stops with the window, so this only fires when the close // did not go through. A kill, not exit(): no exit handlers (NVIDIA's // EGL one segfaults, Windows DLL detach can hang). diff --git a/apps/desktop/lib/src/platform/attention.dart b/apps/desktop/lib/src/platform/desktop/attention.dart similarity index 100% rename from apps/desktop/lib/src/platform/attention.dart rename to apps/desktop/lib/src/platform/desktop/attention.dart diff --git a/apps/desktop/lib/src/platform/desktop/desktop_folders.dart b/apps/desktop/lib/src/platform/desktop/desktop_folders.dart new file mode 100644 index 0000000..f609514 --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/desktop_folders.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; + +import '../../files/scanner.dart'; +import '../ports/folder_access.dart'; +import '../ports/url_opener.dart'; + +/// [FolderAccess] on a desktop file system: file_picker's dialogs, the +/// [scanFolder] walk, the file manager through [UrlOpener]. +class DesktopFolders implements FolderAccess { + final UrlOpener urls; + const DesktopFolders(this.urls); + + @override + Future pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle); + + @override + Future> pickFiles({required String dialogTitle, required List extensions}) async { + final files = await FilePicker.pickFiles( + dialogTitle: dialogTitle, + type: FileType.custom, + allowedExtensions: extensions, + ); + return [for (final f in files) ?f.path]; + } + + @override + Future scan(String dir) => scanFolder(dir); + + @override + bool folderExists(String dir) => Directory(dir).existsSync(); + + @override + Future stat(String path) => File(path).stat(); + + // The file manager opens a folder path the way a browser opens a link. + @override + Future reveal(String dir) => urls.open(Uri.directory(dir).toString()); +} diff --git a/apps/desktop/lib/src/platform/desktop/desktop_ports.dart b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart new file mode 100644 index 0000000..94d1141 --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart @@ -0,0 +1,17 @@ +import '../../playback/yt_dlp_app.dart'; +import '../ports/ports.dart'; +import 'desktop_folders.dart'; +import 'desktop_shell.dart'; +import 'desktop_urls.dart'; + +/// The desktop's [Ports]. +Ports desktopPorts() { + const urls = DesktopUrls(); + return Ports( + shell: DesktopShell(), + urls: urls, + folders: const DesktopFolders(urls), + links: AppYtDlp.instance, + notifications: const NoSystemNotifications(), + ); +} diff --git a/apps/desktop/lib/src/platform/desktop/desktop_shell.dart b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart new file mode 100644 index 0000000..b58e63d --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/desktop_shell.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../state/settings.dart' show settingsBoxName; +import 'attention.dart'; +import '../ports/shell_port.dart'; +import 'window_memory.dart'; + +/// [ShellPort] on Linux and Windows: window_manager for the window, +/// [WindowMemory] for its size and place, the runner's channel for +/// attention, fox_utils for drops and the clipboard. +class DesktopShell implements ShellPort { + WindowMemory? _memory; + + @override + Future start({required String title, required Future Function() onCloseRequest}) async { + await windowManager.ensureInitialized(); + await windowManager.setPreventClose(true); + final memory = _memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); + windowManager.addListener(_CloseGuard(onCloseRequest)); + final windowOptions = WindowOptions( + size: memory.size, + minimumSize: const Size(160, 90), + center: !memory.hasPosition, + backgroundColor: const Color(0xFF000000), + titleBarStyle: TitleBarStyle.hidden, + title: title, + ); + unawaited(windowManager.waitUntilReadyToShow(windowOptions, () async { + await memory.restore(); + await windowManager.show(); + await windowManager.focus(); + })); + } + + @override + Future setFullScreen(bool on) => windowManager.setFullScreen(on); + @override + Future minimize() => windowManager.minimize(); + @override + Future isMaximized() => windowManager.isMaximized(); + @override + Future maximize() => windowManager.maximize(); + @override + Future unmaximize() => windowManager.unmaximize(); + + @override + void Function() watchMaximized(void Function(bool maximized) onChange) { + final l = _MaximizeWatch(onChange); + windowManager.addListener(l); + return () => windowManager.removeListener(l); + } + + @override + Future startDragging() => windowManager.startDragging(); + + @override + Future requestClose() => windowManager.close(); + + @override + Future saveWindow() async => _memory?.save(); + + @override + Future hide() => windowManager.hide(); + + @override + Future quit() async { + try { + await windowManager.setPreventClose(false); + await windowManager.close(); + } catch (e) { + debugPrint('window close failed: $e'); + await windowManager.destroy(); + } + } + + @override + Future requestAttention() => requestWindowAttention(); + + @override + Future readPaste() async { + var files = const []; + String? text; + try { + files = await ClipboardX.readFiles(); + } catch (_) {} + if (files.isEmpty) { + try { + text = await ClipboardX.readText(); + } catch (_) {} + } + return (files: files, text: text); + } + + @override + Widget dropTarget({ + Key? key, + bool enable = true, + VoidCallback? onEnter, + VoidCallback? onExit, + required void Function(List paths, Offset at) onDrop, + required Widget child, + }) => + DropTarget( + key: key, + enable: enable, + onDragEntered: onEnter == null ? null : (_) => onEnter(), + onDragExited: onExit == null ? null : (_) => onExit(), + onDragDone: (d) => onDrop(DesktopDropX.pathsOf(d.files), d.localPosition), + child: child, + ); +} + +/// Close button / Alt+F4 run the orderly close instead. +class _CloseGuard with WindowListener { + final Future Function() onClose; + _CloseGuard(this.onClose); + @override + void onWindowClose() => unawaited(onClose()); +} + +class _MaximizeWatch with WindowListener { + final void Function(bool) onChange; + _MaximizeWatch(this.onChange); + @override + void onWindowMaximize() => onChange(true); + @override + void onWindowUnmaximize() => onChange(false); +} diff --git a/apps/desktop/lib/src/platform/desktop/desktop_urls.dart b/apps/desktop/lib/src/platform/desktop/desktop_urls.dart new file mode 100644 index 0000000..ba9989f --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/desktop_urls.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import '../ports/url_opener.dart'; + +/// [UrlOpener] without url_launcher: `xdg-open`, `open` or Windows' +/// `url.dll` handler. +class DesktopUrls implements UrlOpener { + const DesktopUrls(); + + @override + Future open(String url) async { + try { + final ProcessResult r; + if (Platform.isWindows) { + r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]); + } else if (Platform.isMacOS) { + r = await Process.run('open', [url]); + } else { + r = await Process.run('xdg-open', [url]); + } + return r.exitCode == 0; + } catch (_) { + return false; + } + } +} diff --git a/apps/desktop/lib/src/platform/window_memory.dart b/apps/desktop/lib/src/platform/desktop/window_memory.dart similarity index 100% rename from apps/desktop/lib/src/platform/window_memory.dart rename to apps/desktop/lib/src/platform/desktop/window_memory.dart diff --git a/apps/desktop/lib/src/platform/ports/folder_access.dart b/apps/desktop/lib/src/platform/ports/folder_access.dart new file mode 100644 index 0000000..c97176f --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/folder_access.dart @@ -0,0 +1,28 @@ +import 'dart:io' show FileStat; + +import '../../files/scanner.dart' show FolderScan; + +/// The user's folders and files: picking them, listing videos, looking a +/// path up. Paths for now; a platform without them (Android's tree URIs) +/// brings its own implementation. +abstract interface class FolderAccess { + /// The system folder dialog; null when cancelled. + Future pickFolder({required String dialogTitle}); + + /// The system file dialog limited to [extensions]; the picked paths + /// (empty when cancelled). + Future> pickFiles({required String dialogTitle, required List extensions}); + + /// The videos under [dir] (see [FolderScan]); never throws. + Future scan(String dir); + + /// Whether the folder [dir] exists. + bool folderExists(String dir); + + /// Size and dates of [path]; throws when it is gone. + Future stat(String path); + + /// Shows the folder [dir] in the file manager; false when nothing + /// opened it. + Future reveal(String dir); +} diff --git a/apps/desktop/lib/src/platform/ports/link_resolver.dart b/apps/desktop/lib/src/platform/ports/link_resolver.dart new file mode 100644 index 0000000..595cb56 --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/link_resolver.dart @@ -0,0 +1,21 @@ +import 'package:sync_relay_core/core.dart' show LinkProbe, YtDlp; + +/// Web page links: what they are and how they play, through the app's +/// yt-dlp (a process on desktop). +abstract interface class LinkResolver { + /// The yt-dlp behind it (relaying a link, cookies, the hosted server's + /// shared folder). + Future get ytDlp; + + /// Gets it ready in the background (download on first run, updates). + void warmUp(); + + /// mpv's `ytdl-raw-options` (sub languages, cookies). + Future mpvRawOptions(); + + /// What [url] is, remembered for a while ([keep]); [fresh] asks again. + Future probe(String url, {Duration keep, bool fresh}); + + /// [url] no longer needs keeping longer than usual. + void release(String url); +} diff --git a/apps/desktop/lib/src/platform/ports/ports.dart b/apps/desktop/lib/src/platform/ports/ports.dart new file mode 100644 index 0000000..7e9a19a --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/ports.dart @@ -0,0 +1,28 @@ +import 'folder_access.dart'; +import 'link_resolver.dart'; +import 'shell_port.dart'; +import 'system_notifications.dart'; +import 'url_opener.dart'; + +export 'folder_access.dart'; +export 'link_resolver.dart'; +export 'shell_port.dart'; +export 'system_notifications.dart'; +export 'url_opener.dart'; + +/// The platform as the app sees it: one set, made in the composition +/// root ([SessionState]) and handed on from there. +class Ports { + final ShellPort shell; + final UrlOpener urls; + final FolderAccess folders; + final LinkResolver links; + final SystemNotifications notifications; + const Ports({ + required this.shell, + required this.urls, + required this.folders, + required this.links, + required this.notifications, + }); +} diff --git a/apps/desktop/lib/src/platform/ports/shell_port.dart b/apps/desktop/lib/src/platform/ports/shell_port.dart new file mode 100644 index 0000000..b287e75 --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/shell_port.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; + +/// What the clipboard holds for a paste: its files, else its text. +typedef Paste = ({List files, String? text}); + +/// The app window and what the desktop hands it (drops, pastes). The +/// desktop implementation wraps window_manager and fox_utils +/// ([DesktopShell]); another platform brings its own. +abstract interface class ShellPort { + /// Before the first frame: the window shown at its remembered size and + /// place; a close request (close button, Alt+F4) runs [onCloseRequest] + /// instead of closing. + Future start({required String title, required Future Function() onCloseRequest}); + + Future setFullScreen(bool on); + Future minimize(); + Future isMaximized(); + Future maximize(); + Future unmaximize(); + + /// Calls [onChange] whenever the window is maximized or restored; + /// returns the call that stops it. + void Function() watchMaximized(void Function(bool maximized) onChange); + + /// Moves the window with the pointer (a press on the title area). + Future startDragging(); + + /// Asks to close the window: the same path as the close button. + Future requestClose(); + + /// The way out: remembers the window, hides it ([hide]), and later + /// closes it for good ([quit]). + Future saveWindow(); + Future hide(); + Future quit(); + + /// Points the desktop at the window when it is not focused (taskbar + /// flash, urgency hint); nothing when it is. + Future requestAttention(); + + /// The clipboard's files, else its text (Ctrl+V outside a text field). + Future readPaste(); + + /// [child] taking dropped files ([onDrop]: their paths and where they + /// landed); [enable] off lets drops through. + Widget dropTarget({ + Key? key, + bool enable = true, + VoidCallback? onEnter, + VoidCallback? onExit, + required void Function(List paths, Offset at) onDrop, + required Widget child, + }); +} diff --git a/apps/desktop/lib/src/platform/ports/system_notifications.dart b/apps/desktop/lib/src/platform/ports/system_notifications.dart new file mode 100644 index 0000000..155cf2b --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/system_notifications.dart @@ -0,0 +1,14 @@ +/// Notifications outside the window (the desktop's notification center, +/// Android's shade). The policy that uses them comes with the Notifier. +abstract interface class SystemNotifications { + /// Shows [title] / [body]; replaces the one with the same [tag]. + Future show({required String title, required String body, String tag = ''}); +} + +/// Shows nothing (desktop today). +class NoSystemNotifications implements SystemNotifications { + const NoSystemNotifications(); + + @override + Future show({required String title, required String body, String tag = ''}) async {} +} diff --git a/apps/desktop/lib/src/platform/ports/url_opener.dart b/apps/desktop/lib/src/platform/ports/url_opener.dart new file mode 100644 index 0000000..29f9773 --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/url_opener.dart @@ -0,0 +1,6 @@ +/// Opens links outside the app. +abstract interface class UrlOpener { + /// Opens [url] with the system's handler (a browser for web links, the + /// file manager for a `file:` folder); false when nothing opened it. + Future open(String url); +} diff --git a/apps/desktop/lib/src/playback/yt_dlp_app.dart b/apps/desktop/lib/src/playback/yt_dlp_app.dart index 837edf1..fecdc84 100644 --- a/apps/desktop/lib/src/playback/yt_dlp_app.dart +++ b/apps/desktop/lib/src/playback/yt_dlp_app.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:sync_relay_core/core.dart'; +import '../platform/ports/link_resolver.dart'; + /// mpv's `ytdl-format` for links every player opens itself. const ytdlFormat = 'bv*[height<=1080]+ba/b'; @@ -16,13 +18,14 @@ const ytdlSubLangs = 'all,-danmaku'; /// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with /// the hosted server. [probe] remembers what pasted links are for a while, /// so the Link preview and the swap ask once. -class AppYtDlp { +class AppYtDlp implements LinkResolver { static final instance = AppYtDlp(); /// [ytDlp]: a given one (tests); default in the app-support folder. AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp); Future? _ytDlp; + @override Future get ytDlp => _ytDlp ??= () async { final base = await getApplicationSupportDirectory(); return YtDlp(dir(base.path)); @@ -32,10 +35,12 @@ class AppYtDlp { /// Gets yt-dlp ready in the background (download on first run, a daily /// update check afterwards). + @override void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); /// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the /// yt-dlp folder (see [YtDlpCookies]). + @override Future mpvRawOptions() async { const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs'; try { @@ -54,6 +59,7 @@ class AppYtDlp { /// is probed again before that runs out). [fresh] probes again: the /// remembered result stays until the new one is in, and stays when the /// new one fails. + @override Future probe(String url, {Duration keep = _probeTtl, bool fresh = false}) { final now = DateTime.now(); _probes.removeWhere((_, v) => now.isAfter(v.until)); @@ -80,6 +86,7 @@ class AppYtDlp { } /// [url] no longer needs keeping longer: back to the usual 10 minutes. + @override void release(String url) { final hit = _probes[url]; if (hit == null) return; diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart index a8a7dde..5db2c32 100644 --- a/apps/desktop/lib/src/queue/queue_controller.dart +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/format.dart' show pickFor; import '../jellyfin/session_media.dart'; import '../screens/home/picked_media.dart'; +import '../session/app_events.dart' show AddedToUpNext; import '../session/media_art.dart'; import '../session/session_coordinator.dart'; import '../state/settings.dart'; @@ -82,7 +83,6 @@ class QueueController { if (_attached) return; _attached = true; coord.sync.states.listen(_onState); - coord.sync.feeds.listen(_onFeed); effect(() { final next = coord.nextEpisode.value; final plan = coord.jellyfinPlan.value; @@ -122,7 +122,7 @@ class QueueController { if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now()); final thumb = built.thumb; coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb))); - coord.showToast('Added ${item.title} to Up next'); + coord.notify(AddedToUpNext(item.title)); return null; } @@ -369,12 +369,6 @@ class QueueController { _warm(r); } - void _onFeed(FeedMessage f) { - if (f.action == 'queue-skip') { - coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}'); - } - } - /// Forgets what no longer matters: performing marks after a minute (a /// swap that never landed may be tried again), local picks no longer /// queued. diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index e422fd8..70f3cce 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget; import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; @@ -640,23 +639,9 @@ class _QueuePanelState extends State { return KeyEventResult.ignored; } - Future<({List files, String? text})> _readClipboard() async { - var files = const []; - String? text; - try { - files = await ClipboardX.readFiles(); - } catch (_) {} - if (files.isEmpty) { - try { - text = await ClipboardX.readText(); - } catch (_) {} - } - return (files: files, text: text); - } - /// Ctrl+V: the clipboard's video files, else the links in its text. Future _paste() async { - final clip = await (widget.readClipboard ?? _readClipboard)(); + final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)(); final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]); if (!mounted) return; if (picks.isEmpty) { @@ -829,12 +814,12 @@ class _QueuePanelState extends State { @override Widget build(BuildContext context) { final height = MediaQuery.sizeOf(context).height; - final drop = DropTarget( - onDragEntered: (_) => setState(() => _dragging = true), - onDragExited: (_) => setState(() => _dragging = false), - onDragDone: (d) { + final drop = SessionState.instance.ports.shell.dropTarget( + onEnter: () => setState(() => _dragging = true), + onExit: () => setState(() => _dragging = false), + onDrop: (paths, _) { setState(() => _dragging = false); - _dropped(DesktopDropX.pathsOf(d.files)); + _dropped(paths); }, // Esc: the popover's own (it fades out). child: Builder( diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index fdd1edc..78b9d41 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -119,8 +119,7 @@ class AboutPanel extends StatelessWidget { static Future _openLogs() async { final dir = appLogDir; if (dir == null) return; - // The file manager opens a folder path the way a browser opens a link. - if (!await openInBrowser(Uri.directory(dir).toString())) { + if (!await SessionState.instance.ports.folders.reveal(dir)) { await copyToClipboard(dir, done: "Couldn't open it — folder path copied"); } } diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index cb2af46..30fd668 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -1,7 +1,7 @@ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import '../../state/session_state.dart'; import '../../state/settings.dart'; import '../../update/update_controller.dart'; import 'ui.dart'; @@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget { const CustomShaderFiles({super.key}); Future _addFiles() async { - final List files; + final List files; try { - files = await FilePicker.pickFiles( + files = await SessionState.instance.ports.folders.pickFiles( dialogTitle: 'Pick mpv GLSL shaders', - type: FileType.custom, - allowedExtensions: const ['glsl', 'hook'], + extensions: const ['glsl', 'hook'], ); } catch (_) { return; @@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget { final s = AppSettings.instance; final current = s.customShaders.peek(); final added = [ - for (final f in files) - if (f.path case final p? when !current.contains(p)) p, + for (final p in files) + if (!current.contains(p)) p, ]; if (added.isNotEmpty) s.setCustomShaders([...current, ...added]); } diff --git a/apps/desktop/lib/src/session/app_events.dart b/apps/desktop/lib/src/session/app_events.dart new file mode 100644 index 0000000..d3c4955 --- /dev/null +++ b/apps/desktop/lib/src/session/app_events.dart @@ -0,0 +1,252 @@ +import 'package:sync_relay_core/core.dart' show FeedMessage; + +// Everything that may become a toast, a sound, an attention request or an +// activity-log line, as one sealed type. Code that has news emits an +// event ([Notifier.notify]); only the notifier decides what the user sees +// and hears. Pure Dart (no Flutter): it can move to core with the policy. + +sealed class AppEvent { + const AppEvent(); +} + +// ---------------------------------------------------------------- feed + +/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands +/// in the activity log. +sealed class FeedEvent extends AppEvent { + final FeedMessage feed; + const FeedEvent(this.feed); + + String get actor => feed.actor; + + /// [feed] as its typed event. [selfName]: this member's name (the feed + /// names members by name); [readyCount]: members ready in the latest + /// state (the pitch of a `ready` sound). + factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) { + 'join' => MemberJoined(f), + 'leave' => MemberLeft(f), + 'stale' => MemberStale(f), + 'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName), + 'back' => MemberBack(f), + 'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount), + 'unready' => ReadyChanged(f, ready: false), + 'force-ready' => ForcedReady(f, ready: true), + 'force-unready' => ForcedReady(f, ready: false), + 'force-play' => ForcedTransport(f, play: true), + 'force-hold' => ForcedTransport(f, play: false), + 'swap' => MediaSwapped(f), + 'seek' => Seeked(f), + 'pause' => Paused(f), + 'resume' => Resumed(f), + 'finished' => Finished(f), + 'chat' => ChatPosted(f, own: f.actor == selfName), + 'rename' => MemberRenamed(f), + 'avatar' => AvatarChanged(f), + 'queue-add' => QueueAdded(f), + 'queue-remove' => QueueRemoved(f), + 'queue-skip' => QueueSkipped(f), + 'fetch-fail' => FetchFailed(f), + _ => OtherFeed(f), + }; +} + +class MemberJoined extends FeedEvent { + const MemberJoined(super.feed); +} + +class MemberLeft extends FeedEvent { + const MemberLeft(super.feed); +} + +class MemberStale extends FeedEvent { + const MemberStale(super.feed); +} + +/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else +/// sat this member out. +class MemberSatOut extends FeedEvent { + final bool atYou; + const MemberSatOut(super.feed, {this.atYou = false}); +} + +class MemberBack extends FeedEvent { + const MemberBack(super.feed); +} + +class ReadyChanged extends FeedEvent { + final bool ready; + /// Members ready when it came (pitch of the ready sound). + final int readyIndex; + const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0}); +} + +class ForcedReady extends FeedEvent { + final bool ready; + const ForcedReady(super.feed, {required this.ready}); +} + +/// Force play / hold. +class ForcedTransport extends FeedEvent { + final bool play; + const ForcedTransport(super.feed, {required this.play}); +} + +class MediaSwapped extends FeedEvent { + const MediaSwapped(super.feed); +} + +class Seeked extends FeedEvent { + const Seeked(super.feed); +} + +class Paused extends FeedEvent { + const Paused(super.feed); +} + +class Resumed extends FeedEvent { + const Resumed(super.feed); +} + +class Finished extends FeedEvent { + const Finished(super.feed); +} + +/// A chat message (`reason` carries the text); [own]: this member sent it. +class ChatPosted extends FeedEvent { + final bool own; + const ChatPosted(super.feed, {this.own = false}); +} + +class MemberRenamed extends FeedEvent { + const MemberRenamed(super.feed); +} + +class AvatarChanged extends FeedEvent { + const AvatarChanged(super.feed); +} + +class QueueAdded extends FeedEvent { + const QueueAdded(super.feed); +} + +class QueueRemoved extends FeedEvent { + const QueueRemoved(super.feed); +} + +/// Up next skipped an item (`target` its title, `reason` why). +class QueueSkipped extends FeedEvent { + const QueueSkipped(super.feed); +} + +/// The server could not fetch the current media. +class FetchFailed extends FeedEvent { + const FetchFailed(super.feed); +} + +/// An action this app does not know (a newer server). +class OtherFeed extends FeedEvent { + const OtherFeed(super.feed); +} + +// -------------------------------------------------------------- session + +/// Someone poked this member. +class Nudged extends AppEvent { + /// Their name; '' when unknown. + final String name; + const Nudged(this.name); +} + +/// Another member's player cannot open the current media. +class MemberCantPlay extends AppEvent { + final String name; + final String error; + const MemberCantPlay(this.name, this.error); +} + +/// This member passes the video on slower than it plays. +class SlowSource extends AppEvent { + final String text; + const SlowSource(this.text); +} + +/// This member started passing the video on through the server. +class RelayStarted extends AppEvent { + const RelayStarted(); +} + +class RelayFailed extends AppEvent { + final String error; + const RelayFailed(this.error); +} + +/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes. +class IdleWarning extends AppEvent { + final bool sitsOut; + const IdleWarning({required this.sitsOut}); +} + +/// The connection to the server dropped (once per drop). +class ConnectionLost extends AppEvent { + const ConnectionLost(); +} + +/// It came back after [ConnectionLost]. +class ConnectionBack extends AppEvent { + const ConnectionBack(); +} + +/// Jellyfin progress reports fail (once per media). +class JellyfinProgressFailed extends AppEvent { + final bool signInExpired; + const JellyfinProgressFailed({required this.signInExpired}); +} + +// ------------------------------------------------------------------ app + +/// The player's shader message (a preset change, shaders turned off). +class ShaderMessage extends AppEvent { + final String text; + const ShaderMessage(this.text); +} + +/// This app added an item to Up next. +class AddedToUpNext extends AppEvent { + final String title; + const AddedToUpNext(this.title); +} + +/// Putting something up or adding it to Up next failed ([text]: why). +class ActionFailed extends AppEvent { + final String text; + const ActionFailed(this.text); +} + +/// The Schedule tab's request results and errors. +class ScheduleResult extends AppEvent { + final String text; + const ScheduleResult(this.text); +} + +/// "Mark a problem" done; [inSession]: in the server's session log. +class ProblemMarked extends AppEvent { + final bool inSession; + const ProblemMarked({required this.inSession}); +} + +/// Something went on the clipboard ([text]: the confirmation, e.g. +/// "Link copied"). +class Copied extends AppEvent { + final String text; + const Copied(this.text); +} + +class CopyFailed extends AppEvent { + const CopyFailed(); +} + +/// A newer release is out (the update band shows it). +class UpdateAvailable extends AppEvent { + final String version; + const UpdateAvailable(this.version); +} diff --git a/apps/desktop/lib/src/session/notifier.dart b/apps/desktop/lib/src/session/notifier.dart new file mode 100644 index 0000000..9bc8a6b --- /dev/null +++ b/apps/desktop/lib/src/session/notifier.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:signals/signals_core.dart'; +import 'package:sync_relay_core/core.dart' show FeedMessage; + +import '../sfx/sfx.dart'; +import 'app_events.dart'; + +export 'app_events.dart'; + +class ToastItem { + final int id; + final String text; + final DateTime at; + /// The member a feed or chat toast is about (their name, as the feed + /// carries it; [text] starts with it); null for the app's own toasts. + final String? actor; + const ToastItem({required this.id, required this.text, required this.at, this.actor}); +} + +/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound, +/// the window asking for attention, an activity-log line. +class Notice { + final String? toast; + final String? actor; + final SfxCue? sound; + final bool attention; + /// The feed line it goes into the activity log as. + final FeedMessage? log; + const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log}); + + static const none = Notice(); +} + +/// The policy: what each event does. +Notice noticeFor(AppEvent e) => switch (e) { + // The feed: everything is logged; joins, leaves, swaps and chat + // pop up; the social actions have a sound. + MemberJoined(:final feed) => Notice( + log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)), + MemberLeft(:final feed) => Notice( + log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)), + MediaSwapped(:final feed) => Notice( + log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)), + ChatPosted(:final feed, :final own) => Notice( + log: feed, + toast: '${feed.actor}: ${feed.reason}', + actor: feed.actor, + // Your own message needs no sound. + sound: own ? null : const SfxCue(SfxKind.chat)), + MemberSatOut(:final feed, atYou: true) => Notice( + log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)), + ReadyChanged(:final feed, ready: true, :final readyIndex) => + Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)), + ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)), + ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)), + QueueSkipped(:final feed) => Notice( + log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'), + FeedEvent(:final feed) => Notice(log: feed), + // The session. + Nudged(:final name) => Notice( + toast: '${name.isEmpty ? 'Someone' : name} nudged you', + actor: name.isEmpty ? null : name, + sound: const SfxCue(SfxKind.nudge), + attention: true), + MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name), + SlowSource(:final text) => Notice(toast: text), + RelayStarted() => + const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'), + RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'), + IdleWarning(:final sitsOut) => Notice( + toast: sitsOut + ? 'idle — sitting out in 2 min unless you press Space' + : 'idle — auto-unready in 2 min unless you press Space', + sound: const SfxCue(SfxKind.warn)), + ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)), + ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)), + JellyfinProgressFailed(:final signInExpired) => + Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'), + // The app. + ShaderMessage(:final text) => Notice(toast: text), + AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'), + ActionFailed(:final text) => Notice(toast: text), + ScheduleResult(:final text) => Notice(toast: text), + ProblemMarked(:final inSession) => + Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"), + Copied(:final text) => Notice(toast: text), + CopyFailed() => const Notice(toast: "Couldn't copy"), + // The update band shows it. + UpdateAvailable() => Notice.none, + }; + +/// The one way news reaches the user: [notify] turns an [AppEvent] into +/// toasts ([toasts]), sounds, an attention request and the activity log +/// ([feedLog]) per [noticeFor]. +class Notifier { + final SfxService sfx; + final Future Function() attention; + Notifier({this.sfx = const NoopSfxService(), Future Function()? attention}) + : attention = attention ?? _noAttention; + + static Future _noAttention() async {} + + /// Toasts on screen (the shell shows them; [expire] drops old ones). + final toasts = signal>([]); + /// The activity log: the last 100 feed lines. + final feedLog = signal>([]); + int _toastId = 0; + + void notify(AppEvent e) { + final n = noticeFor(e); + final log = n.log; + if (log != null) { + final all = [...feedLog.peek(), log]; + feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all; + } + final text = n.toast; + if (text != null) { + _toastId++; + toasts.value = [ + ...toasts.peek(), + ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor), + ]; + } + final sound = n.sound; + if (sound != null) unawaited(sfx.play(sound)); + if (n.attention) unawaited(attention()); + } + + /// Drops toasts 4 s old or older. + void expire(DateTime now) { + toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); + } + + /// A new session: no toasts, an empty log. + void clear() { + feedLog.value = []; + toasts.value = []; + } +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index a194c95..ac2a515 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -14,12 +14,12 @@ import '../jellyfin/session_media.dart'; import '../jellyfin/track_prefs.dart'; import '../playback/media_marks.dart'; import '../playback/playback_controller.dart'; -import '../platform/attention.dart'; -import '../playback/yt_dlp_app.dart'; +import '../platform/ports/link_resolver.dart'; +import '../playback/yt_dlp_app.dart' show AppYtDlp; import '../schedule/anilist.dart'; import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/picked_media.dart'; -import '../sfx/sfx.dart'; +import '../sfx/sfx.dart' show NoopSfxService, SfxService; import '../state/settings.dart'; import '../sync/avatar_cache.dart'; import '../sync/state_diff.dart'; @@ -30,10 +30,13 @@ import 'delivery.dart'; import 'health.dart'; import 'idle.dart'; import 'media_art.dart'; +import 'notifier.dart'; import 'now_playing.dart'; import 'now_playing_info.dart'; import 'switch_trace.dart'; +export 'notifier.dart' show ToastItem; + final _log = createLogger('session'); /// Thrown when a connect is superseded by cancel/leave before it finished. @@ -100,18 +103,6 @@ class AutoAdvance { /// Seconds of the end-of-episode countdown. const autoAdvanceSecs = 10; -class ToastItem { - final int id; - final String text; - final DateTime at; - /// The member a feed or chat toast is about (their name, as the feed - /// carries it; [text] starts with it); null for the app's own toasts. - final String? actor; - const ToastItem({required this.id, required this.text, required this.at, this.actor}); -} - -const _toastActions = {'join', 'leave', 'swap', 'chat'}; - /// "Unready when the video changes": a state with a newer [mediaId] than /// the last one this session saw ([lastMediaId], -1 before the first) while /// self is ready (or a reconnect is restoring it). A swap keeps the @@ -164,7 +155,9 @@ String? stateMediaUrl(StateBroadcast state) { } class SessionCoordinator { - final SfxService sfx; + /// Toasts, sounds and the activity log: every event goes through + /// [notify]. + final Notifier notifier; /// Linked Jellyfin servers: how this viewer plays Jellyfin refs. final JellyfinAccounts jellyfin; /// Audio/subtitle carry-over between Jellyfin items. @@ -173,7 +166,7 @@ class SessionCoordinator { /// null when this app is not the host. Future<({String name, int size})?> Function(String path)? hostFile; /// The app's yt-dlp and link probes (tests pass their own). - final AppYtDlp ytDlp; + final LinkResolver ytDlp; final SyncClient sync = SyncClient(); /// This app's log lines to the server's session log (Settings: "Share /// diagnostics with the server"), and "Mark a problem". @@ -217,8 +210,10 @@ class SessionCoordinator { final health = signal(const HealthStatus(HealthSeverity.info, 'idle')); final serverState = signal(null); - final feedLog = signal>([]); - final toasts = signal>([]); + /// The activity log ([Notifier.feedLog]). + Signal> get feedLog => notifier.feedLog; + /// Toasts on screen ([Notifier.toasts]). + Signal> get toasts => notifier.toasts; final reconnecting = signal(false); final starving = signal(null); /// Why this player cannot open the current media, in plain words @@ -279,9 +274,6 @@ class SessionCoordinator { /// Self sits out: the player has nothing open until [joinBack]. late final Computed selfOut = computed(() => selfMember.value?.out ?? false); - /// Asks the desktop to point at the window (a nudge); tests swap it. - Future Function() requestAttention = requestWindowAttention; - Timer? _ticker; StreamSubscription? _stateSub; StreamSubscription? _feedSub; @@ -362,7 +354,6 @@ class SessionCoordinator { String _resumeToken = ''; DateTime? _lastStateAt; final IdleTracker _idle = IdleTracker(DateTime.now().toUtc()); - int _toastId = 0; ConnectParams? _params; bool _everConnected = false; /// The connection-lost sound played; the reconnected one answers it once. @@ -388,9 +379,15 @@ class SessionCoordinator { // Members' last `error`, by id, so a new one toasts once. Map _memberErrors = const {}; + /// [notifier] defaults to one playing [sfx] (tests). SessionCoordinator( - {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp}) - : ytDlp = ytDlp ?? AppYtDlp.instance, + {SfxService sfx = const NoopSfxService(), + Notifier? notifier, + JellyfinAccounts? jellyfin, + TrackPrefs? trackPrefs, + LinkResolver? ytDlp}) + : notifier = notifier ?? Notifier(sfx: sfx), + ytDlp = ytDlp ?? AppYtDlp.instance, jellyfin = jellyfin ?? JellyfinAccounts.instance, trackPrefs = trackPrefs ?? TrackPrefs(); @@ -438,7 +435,7 @@ class SessionCoordinator { for (final m in r.members) { final e = now[m.id]; if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) { - _pushToast("${m.name} can't play this: $e", actor: m.name); + notify(MemberCantPlay(m.name, e)); } } _memberErrors = now; @@ -452,7 +449,7 @@ class SessionCoordinator { final warning = slowSourceWarning(r, sync.memberId); if (warning == null) return; _slowWarnedFor = r.mediaId; - _pushToast(warning); + notify(SlowSource(warning)); } bool _reloadingVideo = false; @@ -528,8 +525,7 @@ class SessionCoordinator { diag ..share = AppSettings.instance.shareDiagnostics.peek() ..start(); - feedLog.value = []; - toasts.value = []; + notifier.clear(); await initPlayer(); if (epoch != _epoch) throw const ConnectCancelled(); health.value = @@ -807,9 +803,7 @@ class SessionCoordinator { _relaySource = warm.src; _relaySourceMediaId = mediaId; relaying.value = true; - if (announce) { - _pushToast('Passing the video on through the server. Keep the app open while it plays.'); - } + if (announce) notify(const RelayStarted()); unawaited(warm.src.done.then((_) { if (identical(_relaySource, warm.src)) { _relaySource = null; @@ -823,9 +817,7 @@ class SessionCoordinator { _relaySource = src; _relaySourceMediaId = mediaId; relaying.value = true; - if (announce) { - _pushToast('Passing the video on through the server. Keep the app open while it plays.'); - } + if (announce) notify(const RelayStarted()); unawaited(() async { try { await src.start(); @@ -834,7 +826,7 @@ class SessionCoordinator { if (identical(_relaySource, src)) { _relaySource = null; relaying.value = false; - _pushToast('Cannot pass the video on: $e'); + notify(RelayFailed('$e')); } return; } @@ -1138,62 +1130,19 @@ class SessionCoordinator { } void _onFeed(FeedMessage f) { - feedLog.value = [...feedLog.peek(), f].length > 100 - ? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99) - : [...feedLog.peek(), f]; - if (_toastActions.contains(f.action)) { - final text = f.action == 'chat' - ? '${f.actor}: ${f.reason}' - : _feedText(f); - _toastId++; - toasts.value = [ - ...toasts.peek(), - ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) - ]; - } + final event = FeedEvent.of(f, + selfName: selfMember.peek()?.name, + readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0); + notify(event); // The server gave up fetching what everyone waits for. - if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) { + if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) { health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video"); } - final selfName = selfMember.peek()?.name; - // Sat out by someone else: say who (the feed names members by name). - if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) { - _pushToast('${f.actor} sat you out', actor: f.actor); - unawaited(sfx.play(const SfxCue(SfxKind.kick))); - } - final kind = sfxForFeedAction(f.action); - // Your own message needs no sound. - final own = f.action == 'chat' && f.actor == selfName; - if (kind != null && !own) { - var readyIndex = 0; - if (f.action == 'ready') { - readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0; - } - unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex))); - } - } - - String _feedText(FeedMessage f) { - switch (f.action) { - case 'join': - return '${f.actor} joined'; - case 'leave': - return '${f.actor} left'; - case 'swap': - return '${f.actor} changed media'; - default: - return '${f.actor} ${f.action}'; - } } /// Someone poked this member: sound (even when its switch is off; the /// volume still applies), a toast, and the window asks for attention. - void _onNudge(NudgeNotice n) { - final name = n.name.isEmpty ? 'Someone' : n.name; - _pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name); - unawaited(sfx.play(const SfxCue(SfxKind.nudge))); - unawaited(requestAttention()); - } + void _onNudge(NudgeNotice n) => notify(Nudged(n.name)); void _startTicker() { _ticker?.cancel(); @@ -1227,8 +1176,7 @@ class SessionCoordinator { Future _tick() async { final now = DateTime.now().toUtc(); - toasts.value = - toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); + notifier.expire(now); final r = serverState.peek(); if (sync.token.isNotEmpty) sync.sendPing(); diag @@ -1271,17 +1219,7 @@ class SessionCoordinator { selfReady: selfMember.peek()?.ready ?? false); switch (event) { case IdleEvent.warn: - _toastId++; - toasts.value = [ - ...toasts.peek(), - ToastItem( - id: _toastId, - text: sitOutWhenIdle - ? 'idle — sitting out in 2 min unless you press Space' - : 'idle — auto-unready in 2 min unless you press Space', - at: now) - ]; - unawaited(sfx.play(const SfxCue(SfxKind.warn))); + notify(IdleWarning(sitsOut: sitOutWhenIdle)); case IdleEvent.unready: _lastReady = false; if (sitOutWhenIdle) { @@ -1738,17 +1676,9 @@ class SessionCoordinator { // ------------------------------------------------------------- jellyfin - /// A toast from outside the session (e.g. the Schedule tab's request - /// results), shown like the session's own. - void showToast(String text) => _pushToast(text); - - void _pushToast(String text, {String? actor}) { - _toastId++; - toasts.value = [ - ...toasts.peek(), - ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor) - ]; - } + /// News for the user, from the session or anywhere in the app (see + /// [Notifier]). + void notify(AppEvent event) => notifier.notify(event); void _resetJellyfin() { _jfGen++; @@ -1928,7 +1858,7 @@ class SessionCoordinator { onError: (e) { if (_jfToastShown) return; _jfToastShown = true; - _pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}'); + notify(JellyfinProgressFailed(signInExpired: e is JellyfinException && e.isUnauthorized)); }, ); final snap = await _snapshot(); @@ -2153,7 +2083,7 @@ class SessionCoordinator { _everConnected = true; if (_lostSounded) { _lostSounded = false; - unawaited(sfx.play(const SfxCue(SfxKind.back))); + notify(const ConnectionBack()); } _resumeToken = sync.token; _lastStateAt = DateTime.now().toUtc(); @@ -2195,7 +2125,7 @@ class SessionCoordinator { void _soundLost() { if (!_everConnected || _lostSounded) return; _lostSounded = true; - unawaited(sfx.play(const SfxCue(SfxKind.lost))); + notify(const ConnectionLost()); } void _invalidateAttempts() { diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 25bdac2..ae38eef 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -3,9 +3,7 @@ import 'dart:io' show File; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget; import 'package:signals/signals_flutter.dart'; -import 'package:window_manager/window_manager.dart'; import '../controls/frame.dart' show DoubleClickListener; import '../files/folder_store.dart'; @@ -14,6 +12,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/session_media.dart'; import '../screens/home/connect_flow.dart'; +import '../session/app_events.dart' show ActionFailed, ScheduleResult; import '../session/session_coordinator.dart' show ConnectParams; import '../screens/home/health_probe.dart'; import '../screens/home/media_picker.dart'; @@ -25,6 +24,7 @@ import '../update/update_controller.dart'; import 'chat_panel.dart'; import 'connect_cards.dart'; import 'handoff.dart'; +import 'library_link.dart' show LinkProber, defaultLinkProber; import 'library_tab.dart'; import 'motion.dart'; import 'party_strip.dart'; @@ -58,6 +58,9 @@ class AppShell extends StatefulWidget { final JellyfinAccounts? jellyfin; /// Stand-in for media_kit's `Video` (tests). final VideoViewBuilder? videoBuilder; + /// What the Library's Link page asks about a link (tests: a fake, so no + /// network or yt-dlp runs). + final LinkProber linkProbe; const AppShell({ super.key, this.updates, @@ -66,6 +69,7 @@ class AppShell extends StatefulWidget { this.healthGet = defaultHealthGet, this.jellyfin, this.videoBuilder, + this.linkProbe = defaultLinkProber, }); @override @@ -329,7 +333,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin Future _setWindowFullscreen(bool on) async { if (!widget.windowControls) return; try { - await windowManager.setFullScreen(on); + await SessionState.instance.ports.shell.setFullScreen(on); } catch (_) { // No window manager. } @@ -457,17 +461,8 @@ class AppShellState extends State with SingleTickerProviderStateMixin /// Clipboard files first, then text (a link or absolute paths). A pasted /// image has no taker yet, so the clipboard's image data is not read. Future _paste() async { - var files = const []; - String? text; - try { - files = await ClipboardX.readFiles(); - } catch (_) {} - if (files.isEmpty) { - try { - text = await ClipboardX.readText(); - } catch (_) {} - } - await _handOff(classifyHandoff(paths: files, text: text), _pointer); + final clip = await SessionState.instance.ports.shell.readPaste(); + await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer); } Future _handOff(Handoff item, Offset? at) async { @@ -489,7 +484,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin case (HandoffVideos(:final paths), HandoffAction.putUp): final error = await _putUp(FileMedia(paths.first)); if (error != null) { - SessionState.instance.coordinator.showToast(error); + SessionState.instance.coordinator.notify(ActionFailed(error)); } else { await FolderStore.instance.ensureLoaded(); FolderStore.instance.addRecent(paths.first); @@ -500,7 +495,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin for (final p in next ? paths.reversed : paths) { final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next); if (error != null) { - SessionState.instance.coordinator.showToast(error); + SessionState.instance.coordinator.notify(ActionFailed(error)); break; } } @@ -509,7 +504,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin final coord = SessionState.instance.coordinator; final error = await coord.queue .add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext); - if (error != null) coord.showToast(error); + if (error != null) coord.notify(ActionFailed(error)); case (HandoffVideos(:final paths), HandoffAction.addFolder): await _addFolder(File(paths.first).parent.path); case (HandoffFolder(:final path), _): @@ -540,13 +535,13 @@ class AppShellState extends State with SingleTickerProviderStateMixin } // Off on Settings: its own drop targets (cookies) take the files there. - Widget _dropTarget(Widget child) => DropTarget( + Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget( enable: _tab != ShellTab.settings, - onDragEntered: (_) => setState(() => _dragging = _onTop), - onDragExited: (_) => setState(() => _dragging = false), - onDragDone: (d) { + onEnter: () => setState(() => _dragging = _onTop), + onExit: () => setState(() => _dragging = false), + onDrop: (paths, at) { setState(() => _dragging = false); - _handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition); + _handOff(classifyHandoff(paths: paths), at); }, child: Stack( fit: StackFit.expand, @@ -718,12 +713,13 @@ class AppShellState extends State with SingleTickerProviderStateMixin // Up next needs a server (a solo session has one). onAddToQueue: online ? coord.queue.add : null, onOpenIntegrations: () => _openSettings(SettingsSection.integrations), + probe: widget.linkProbe, ), ShellTab.schedule => SchedulePage( solo: alone, onPutUp: _putUp, onAddToQueue: online ? coord.queue.add : null, - onToast: coord.showToast, + onToast: (text) => coord.notify(ScheduleResult(text)), ), ShellTab.settings => SettingsTab( section: _section, diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index ced6f6d..71c7660 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -6,11 +6,11 @@ import 'package:flutter/services.dart'; import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText; -import '../playback/yt_dlp_app.dart'; import '../screens/home/media_picker.dart'; import '../widgets/app_menu.dart'; import '../widgets/copy_open.dart'; import '../state/connect_prefs.dart'; +import '../state/session_state.dart'; import 'motion.dart'; // The Library's Link source: a big paste field, a preview of what the link @@ -236,10 +236,10 @@ class LinkPreviewStore { } } -/// What a link is (see [AppYtDlp.probe]). +/// What a link is (see [LinkResolver.probe]). typedef LinkProber = Future Function(String url); -Future defaultLinkProber(String url) => AppYtDlp.instance.probe(url); +Future defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url); String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; diff --git a/apps/desktop/lib/src/shell/mark_problem.dart b/apps/desktop/lib/src/shell/mark_problem.dart index 3bef1c7..a5c2960 100644 --- a/apps/desktop/lib/src/shell/mark_problem.dart +++ b/apps/desktop/lib/src/shell/mark_problem.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sync_relay_core/core.dart' show RelayConstants; +import '../session/app_events.dart' show ProblemMarked; import '../state/session_state.dart'; import '../widgets/app_menu.dart' show AppGlass; import 'motion.dart'; @@ -19,7 +20,7 @@ Future showMarkProblem(BuildContext context) async { if (note == null) return; final coord = SessionState.instance.coordinator; final inSession = await coord.markProblem(note); - coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log"); + coord.notify(ProblemMarked(inSession: inSession)); } /// The small card: a one-line note and Mark. Pops with the note (maybe diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index 1e42fa8..60f6264 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; -import 'package:window_manager/window_manager.dart'; import '../screens/home/ui.dart' show appName, displayVersion, shortVersion; import '../session/delivery.dart'; @@ -263,7 +262,7 @@ class PartyStrip extends StatelessWidget { // their own clicks. if (windowControls) const Positioned.fill( - child: DragToMoveArea(child: SizedBox.expand()), + child: WindowDragArea(child: SizedBox.expand()), ), Positioned.fill(child: row), ], diff --git a/apps/desktop/lib/src/shell/profile_card.dart b/apps/desktop/lib/src/shell/profile_card.dart index ecb5130..fea04fa 100644 --- a/apps/desktop/lib/src/shell/profile_card.dart +++ b/apps/desktop/lib/src/shell/profile_card.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; @@ -329,18 +328,17 @@ class _CropPickerState extends State { } Future _pick() async { - final List files; + final List files; try { - files = await FilePicker.pickFiles( + files = await SessionState.instance.ports.folders.pickFiles( dialogTitle: 'Pick a picture', - type: FileType.custom, - allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], + extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], ); } catch (e) { if (mounted) setState(() => _error = 'file dialog unavailable — try again'); return; } - final path = files.isEmpty ? null : files.first.path; + final path = files.firstOrNull; if (path == null) return; try { final codec = await ui.instantiateImageCodec(await File(path).readAsBytes()); diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 5fb4f7a..6ebc2e9 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -15,6 +15,7 @@ import '../queue/queue_ui.dart'; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/player_controls.dart'; +import '../session/app_events.dart' show ShaderMessage; import '../session/session_coordinator.dart'; import '../shaders/shader_presets.dart'; import '../state/session_state.dart'; @@ -92,8 +93,6 @@ class _SessionPlayerState extends State { final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); DateTime? _lastKeyAt; late final void Function() _stopShaderWatch; - // Own toast ids, negative: the coordinator counts up from 1. - int _toastSeq = 0; int _shaderNoticeSeen = 0; bool _miniHover = false; // Whether the controls are up (their frame reports it). @@ -134,7 +133,7 @@ class _SessionPlayerState extends State { final n = _coord.playback.shaderNotice.value; if (n == null || n.id == _shaderNoticeSeen) return; _shaderNoticeSeen = n.id; - _toast(n.text); + _coord.notify(ShaderMessage(n.text)); }); _stopPopoverWatch = effect(() { _hold.set(#upNextPopover, _coord.queue.popoverOpen.value); @@ -162,13 +161,6 @@ class _SessionPlayerState extends State { super.dispose(); } - void _toast(String text) { - _coord.toasts.value = [ - ..._coord.toasts.peek(), - ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()), - ]; - } - void _toggleChat() => _model.toggleChat(); /// The player keys, while the player fills the body and nothing (a diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 9189af0..5939261 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart' show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies; @@ -12,7 +10,6 @@ import 'package:sync_relay_core/core.dart' import '../controls/player_settings.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/accounts_panel.dart'; -import '../playback/yt_dlp_app.dart'; import '../schedule/animeflow.dart'; import '../screens/home/about_page.dart'; import '../screens/home/settings_page.dart'; @@ -380,7 +377,7 @@ class RecentServersPanel extends StatelessWidget { /// The app's yt-dlp (web page links): its version, "Update now", and the /// last error. class YtDlpSettings extends StatefulWidget { - /// Defaults to [AppYtDlp.instance]'s. + /// Defaults to the app's ([Ports.links]). final Future? ytDlp; const YtDlpSettings({super.key, this.ytDlp}); @@ -389,7 +386,7 @@ class YtDlpSettings extends StatefulWidget { } class _YtDlpSettingsState extends State { - late final Future _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp; + late final Future _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp; @override Widget build(BuildContext context) { @@ -472,18 +469,17 @@ class _YtDlpCookieSettingsState extends State { }; Future _pickFile() async { - final List files; + final List files; try { - files = await FilePicker.pickFiles( + files = await SessionState.instance.ports.folders.pickFiles( dialogTitle: 'Pick a cookies.txt or a JSON cookie export', - type: FileType.custom, - allowedExtensions: const ['txt', 'json'], + extensions: const ['txt', 'json'], ); } catch (_) { _reload('file dialog unavailable — try again'); return; } - final path = files.firstOrNull?.path; + final path = files.firstOrNull; if (path != null) await _importFile(path); } @@ -535,12 +531,12 @@ class _YtDlpCookieSettingsState extends State { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return DropTarget( - onDragEntered: (_) => setState(() => _dragging = true), - onDragExited: (_) => setState(() => _dragging = false), - onDragDone: (d) { + return SessionState.instance.ports.shell.dropTarget( + onEnter: () => setState(() => _dragging = true), + onExit: () => setState(() => _dragging = false), + onDrop: (paths, _) { setState(() => _dragging = false); - final path = DesktopDropX.pathsOf(d.files).firstOrNull; + final path = paths.firstOrNull; if (path != null) _importFile(path); }, child: AnimatedContainer( diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index 3834ae0..b831539 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -11,11 +11,13 @@ import '../jellyfin/session_media.dart'; import '../screens/home/health_probe.dart'; import '../screens/home/picked_media.dart'; import '../platform/app_log.dart' show appLogDir; -import '../playback/yt_dlp_app.dart'; +import '../platform/desktop/desktop_ports.dart'; +import '../platform/ports/ports.dart'; import '../sfx/sfx.dart'; import '../sfx/sound_bank.dart'; import '../sfx/synth_sfx.dart'; import '../session/health.dart'; +import '../session/notifier.dart'; import '../session/session_coordinator.dart'; import '../widgets/avatar_pipeline.dart'; import 'connect_prefs.dart'; @@ -167,7 +169,11 @@ class SessionState { enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), presetFor: soundPresetFor, ); - late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); + /// The platform (window, links, folders, yt-dlp, notifications). + final Ports ports = desktopPorts(); + /// Every toast, sound and activity-log line goes through it. + late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention); + late final SessionCoordinator coordinator = SessionCoordinator(notifier: notifier, ytDlp: ports.links); final lastForm = ConnectForm(); JellyfinAccounts get jellyfin => JellyfinAccounts.instance; /// Set when the hosted server died mid-session; Home shows it on the @@ -188,7 +194,7 @@ class SessionState { /// default) when the app-support folder is not available. static Future _sharedYtDlpDir() async { try { - return (await AppYtDlp.instance.ytDlp).dir; + return (await instance.ports.links.ytDlp).dir; } catch (_) { return ''; } @@ -285,7 +291,7 @@ class SessionState { try { if (carry != null) await _resume(carry); } catch (e) { - coordinator.showToast("couldn't put it up again: $e"); + coordinator.notify(ActionFailed("couldn't put it up again: $e")); } } finally { switching.value = false; diff --git a/apps/desktop/lib/src/widgets/copy_open.dart b/apps/desktop/lib/src/widgets/copy_open.dart index 2915718..745aba4 100644 --- a/apps/desktop/lib/src/widgets/copy_open.dart +++ b/apps/desktop/lib/src/widgets/copy_open.dart @@ -1,8 +1,7 @@ -import 'dart:io'; - import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor; import '../state/session_state.dart'; // Reusing what the app shows elsewhere: copying a link or a title, opening @@ -22,28 +21,10 @@ abstract final class ReuseText { @visibleForTesting void Function(String text)? debugReuseToast; -void _toast(String text) { +void _notify(AppEvent event) { final debug = debugReuseToast; - if (debug != null) return debug(text); - SessionState.instance.coordinator.showToast(text); -} - -/// Opens [url] in the default browser (no url_launcher dependency); false -/// when no browser could be started. -Future openInBrowser(String url) async { - try { - final ProcessResult r; - if (Platform.isWindows) { - r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]); - } else if (Platform.isMacOS) { - r = await Process.run('open', [url]); - } else { - r = await Process.run('xdg-open', [url]); - } - return r.exitCode == 0; - } catch (_) { - return false; - } + if (debug != null) return debug(noticeFor(event).toast ?? ''); + SessionState.instance.coordinator.notify(event); } /// Puts [text] on the clipboard and confirms it with a toast ([done]: @@ -52,15 +33,15 @@ Future copyToClipboard(String text, {String done = 'Copied'}) async { try { await Clipboard.setData(ClipboardData(text: text)); } catch (_) { - _toast("Couldn't copy"); + _notify(const CopyFailed()); return; } - _toast(done); + _notify(Copied(done)); } /// Opens [url] in the browser; when none opens, copies it instead and /// says so. Future openLink(String url) async { - if (await openInBrowser(url)) return; + if (await SessionState.instance.ports.urls.open(url)) return; await copyToClipboard(url, done: "Couldn't open a browser — link copied"); } diff --git a/apps/desktop/lib/src/widgets/window_buttons.dart b/apps/desktop/lib/src/widgets/window_buttons.dart index 7903f49..2952cf2 100644 --- a/apps/desktop/lib/src/widgets/window_buttons.dart +++ b/apps/desktop/lib/src/widgets/window_buttons.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; import '../state/session_state.dart'; @@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget { State createState() => _WindowButtonsState(); } -class _WindowButtonsState extends State with WindowListener { +class _WindowButtonsState extends State { + final _shell = SessionState.instance.ports.shell; bool _maximized = false; + late final void Function() _stopWatch; @override void initState() { super.initState(); - windowManager.addListener(this); - windowManager.isMaximized().then((v) { + _stopWatch = _shell.watchMaximized((v) { + if (mounted) setState(() => _maximized = v); + }); + _shell.isMaximized().then((v) { if (mounted) setState(() => _maximized = v); }); } @override void dispose() { - windowManager.removeListener(this); + _stopWatch(); super.dispose(); } - @override - void onWindowMaximize() { - if (mounted) setState(() => _maximized = true); - } - - @override - void onWindowUnmaximize() { - if (mounted) setState(() => _maximized = false); - } - Future _toggleMaximize() async { if (widget.onToggleFullscreen != null) { widget.onToggleFullscreen!(); return; } - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); + if (await _shell.isMaximized()) { + await _shell.unmaximize(); } else { - await windowManager.maximize(); + await _shell.maximize(); } } @@ -63,7 +56,7 @@ class _WindowButtonsState extends State with WindowListener { await SessionState.instance.coordinator.leave(); await SessionState.instance.stopServer(); } catch (_) {} - await windowManager.close(); + await _shell.requestClose(); } @override @@ -77,7 +70,7 @@ class _WindowButtonsState extends State with WindowListener { icon: Icons.remove, tooltip: 'Minimize', focusNode: focus, - onPressed: () => windowManager.minimize(), + onPressed: () => _shell.minimize(), ), _WinBtn( icon: restored ? Icons.filter_none : Icons.crop_square, @@ -131,3 +124,27 @@ class _WinBtn extends StatelessWidget { ); } } + +/// The title area: drag moves the window, a double click maximizes or +/// restores it (window_manager's DragToMoveArea, through [ShellPort]). +class WindowDragArea extends StatelessWidget { + final Widget child; + const WindowDragArea({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final shell = SessionState.instance.ports.shell; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (_) => shell.startDragging(), + onDoubleTap: () async { + if (await shell.isMaximized()) { + await shell.unmaximize(); + } else { + await shell.maximize(); + } + }, + child: child, + ); + } +} diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index df0ed56..72e0414 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect, +// resume and ready restore; "feed and chat toasts" in step 2 (Notifier). import 'dart:async'; import 'dart:io'; diff --git a/apps/desktop/test/delivery_test.dart b/apps/desktop/test/delivery_test.dart index 75aff2e..db6e6f1 100644 --- a/apps/desktop/test/delivery_test.dart +++ b/apps/desktop/test/delivery_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 2 (Status + Notifier): delivery lines become +// part of the one computed status (and the source-gone half of P2). import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; diff --git a/apps/desktop/test/files_page_test.dart b/apps/desktop/test/files_page_test.dart index 05e93bf..dd5d951 100644 --- a/apps/desktop/test/files_page_test.dart +++ b/apps/desktop/test/files_page_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 4 (Library + Media identity): series +// grouping on the page follows L1/L2. import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; diff --git a/apps/desktop/test/files_scanner_test.dart b/apps/desktop/test/files_scanner_test.dart index af57a1b..7713e64 100644 --- a/apps/desktop/test/files_scanner_test.dart +++ b/apps/desktop/test/files_scanner_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 4 (Library + Media identity): groupBySeries +// becomes L1 local grouping; the scan and classify tests stay. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index d0aee30..ac79110 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 3 (Your server): hosted recents and the host +// card; the rest stays. import 'dart:convert'; import 'dart:io' show Platform; diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index 50592a8..1bbd81d 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 5 (Watch tracking): the progress-reporting +// checks; the guest-through-the-relay flow stays. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index 16eb0b8..1afc0a4 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 5 (What's next + Watch tracking): the +// "progress" and "episodes" groups (reporting, auto-advance swapper). import 'dart:convert'; import 'package:flutter/gestures.dart'; @@ -364,11 +366,6 @@ void main() { expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); }); - test('the floating prompt starts two minutes before the end', () { - expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22)); - expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero); - }); - test('one auto-advance swapper: the lowest id among members with their own account', () { final members = [ member('m10', 'Ann', jf: {fixtureServerId: true}), diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index 9bf4c44..4b86db2 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 3 (Your server): the "solo" and "hosting" +// tests (D9: no solo mode); the rest stays. import 'dart:convert'; import 'package:flutter/material.dart'; diff --git a/apps/desktop/test/media_input_test.dart b/apps/desktop/test/media_input_test.dart deleted file mode 100644 index 7c8dc58..0000000 --- a/apps/desktop/test/media_input_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:watch_together/src/widgets/media_input.dart'; - -void main() { - test('http url routes to linkUrl', () { - expect(parseMediaInput('http://example.com/v.mp4'), - (mediaPath: '', linkUrl: 'http://example.com/v.mp4')); - }); - - test('https url routes to linkUrl', () { - expect(parseMediaInput('https://example.com/v.mp4'), - (mediaPath: '', linkUrl: 'https://example.com/v.mp4')); - }); - - test('scheme match is case-insensitive and trimmed', () { - expect(parseMediaInput(' HTTPS://example.com/v.mp4 '), - (mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4')); - }); - - test('file path routes to mediaPath', () { - expect(parseMediaInput('/home/u/Videos/chiyo.mp4'), - (mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: '')); - }); - - test('empty input stays empty', () { - expect(parseMediaInput(' '), (mediaPath: '', linkUrl: '')); - }); - - test('non-http scheme stays a path', () { - expect(parseMediaInput('ftp://example.com/v.mp4'), - (mediaPath: 'ftp://example.com/v.mp4', linkUrl: '')); - }); -} diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index 462316a..0420e5c 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 3 (Your server): invite addresses (P24) and +// the hosting invite; the settings cards stay. import 'dart:io'; import 'package:flutter/material.dart'; diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart index db021e5..74c93d9 100644 --- a/apps/desktop/test/player_status_test.dart +++ b/apps/desktop/test/player_status_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 2 (Status + Notifier): the "status" group +// (one computed level); the title block and overflow checks stay. import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -165,16 +167,6 @@ void main() { expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing); expect(tester.takeException(), isNull); }); - - testWidgets('a new media fades through', (tester) async { - await tester.pumpWidget(host(const NowPlayingBlock(now: now))); - await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune')))); - await tester.pump(const Duration(milliseconds: 60)); - expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out'); - await tester.pumpAndSettle(); - expect(find.text('Frieren'), findsNothing); - expect(find.text('Dune'), findsOneWidget); - }); }); testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async { diff --git a/apps/desktop/test/queue_logic_test.dart b/apps/desktop/test/queue_logic_test.dart index 6b3a955..2312645 100644 --- a/apps/desktop/test/queue_logic_test.dart +++ b/apps/desktop/test/queue_logic_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 5 (What's next): who performs an item and +// the janitor become server state. import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/queue/queue_logic.dart'; diff --git a/apps/desktop/test/queue_switch_e2e_test.dart b/apps/desktop/test/queue_switch_e2e_test.dart index 3460fec..488cde6 100644 --- a/apps/desktop/test/queue_switch_e2e_test.dart +++ b/apps/desktop/test/queue_switch_e2e_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 6 (Media pipeline): preload and the warm +// relay become one pipeline; keep the timing per source. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/apps/desktop/test/relay_e2e_test.dart b/apps/desktop/test/relay_e2e_test.dart index 1962fdd..38728a7 100644 --- a/apps/desktop/test/relay_e2e_test.dart +++ b/apps/desktop/test/relay_e2e_test.dart @@ -1,3 +1,6 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): "the +// source drops uncleanly and resumes" (resume token, D6 source resume); the +// rest stays. import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -65,6 +68,7 @@ Future<(HttpServer, List)> _origin(String file) async { void main() { late SyncServer server; late List all; + late List serverErrors; Future member(String name) async { final acc = JellyfinAccounts(store: MemoryJellyfinStore()); @@ -102,7 +106,15 @@ void main() { MediaKit.ensureInitialized(); all = []; server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps')); - await server.start(); + // Relic reports an error writing a response (a relay read cut short + // when its relay is dropped at a swap or at the stop in tearDown) to + // the zone the server started in, as it logs it outside tests; + // anything else fails the test. + serverErrors = []; + await runZonedGuarded(server.start, (e, _) { + if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return; + serverErrors.add(e); + }); }); tearDown(() async { @@ -110,6 +122,7 @@ void main() { await x.dispose(); } await server.stop(); + expect(serverErrors, isEmpty, reason: 'server errors'); }); test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)), @@ -127,8 +140,9 @@ void main() { expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay'); expect(relay.sourceToken, bob.sync.token); - // The next swap retires bob's source and the buffer. - alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv')); + // The next swap retires bob's source and the buffer. Nothing answers + // at that link, so its probe refuses it unless put up anyway. + expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull); await _poll(() => !bob.relaying.peek(), 'source stopped'); expect(server.hub.relay, isNull); }); @@ -155,7 +169,8 @@ void main() { await playTogether([alice, bob]); expect(hits.length, greaterThan(before)); - // A page yt-dlp cannot read is refused up front. + // A page is refused up front when this computer cannot read it (here + // yt-dlp is off, and the refusal says so). final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); page.listen((r) { r.response.headers.contentType = ContentType.html; @@ -163,7 +178,7 @@ void main() { r.response.close(); }); expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), - contains('Not a video')); + 'yt-dlp is turned off'); await page.close(force: true); } finally { await origin.close(force: true); diff --git a/apps/desktop/test/schedule_page_test.dart b/apps/desktop/test/schedule_page_test.dart index 33eec97..5f6e3f1 100644 --- a/apps/desktop/test/schedule_page_test.dart +++ b/apps/desktop/test/schedule_page_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 3 (Your server): "solo: the button says +// Play"; the rest stays. import 'dart:convert'; import 'dart:io'; diff --git a/apps/desktop/test/server_isolate_test.dart b/apps/desktop/test/server_isolate_test.dart index 3b6b72f..5ed6751 100644 --- a/apps/desktop/test/server_isolate_test.dart +++ b/apps/desktop/test/server_isolate_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 3 (Your server): one hub with listeners +// attached, nothing restarts, no solo mode. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/apps/desktop/test/session_logic_test.dart b/apps/desktop/test/session_logic_test.dart index 8e1b4e6..2766755 100644 --- a/apps/desktop/test/session_logic_test.dart +++ b/apps/desktop/test/session_logic_test.dart @@ -1,3 +1,6 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): +// IdleTracker and unreadyForNewMedia give way to auto sit-out; +// linkDisplayName stays. import 'package:flutter_test/flutter_test.dart'; import 'package:watch_together/src/session/idle.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 748b37c..82aa209 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 2 (Status + Notifier): toasts and the status +// pill; in step 3 (Your server): the solo strip and Invite friends. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -79,6 +81,8 @@ void main() { autoUpdateCheck: false, windowControls: false, videoBuilder: (context, controls) => const FakeVideo(), + // A real probe (network, yt-dlp) never answers on the test clock. + linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000), ), )); await tester.pumpAndSettle(); @@ -192,18 +196,6 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('tabs: the underline moves to the selected one', (tester) async { - await pumpShell(tester, const Size(1280, 800)); - double underline(String tab) => tester - .getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer))) - .width; - expect(underline('library'), 22); - expect(underline('settings'), 0); - await tester.tap(find.byKey(const ValueKey('tab-settings'))); - await tester.pumpAndSettle(); - expect(underline('library'), 0); - expect(underline('settings'), 22); - }); }); group('popovers', () { @@ -418,16 +410,4 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Shaders off'), findsNothing); }); - - testWidgets('status avatar fills its box at any size', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: Center( - child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88), - ), - )); - final circle = tester.getSize(find.byType(CircleAvatar)); - // Ring and gap (2 x 4 px each side at this size) inside the 88 px box. - expect(circle.width, 88 - 4 * 4); - expect(find.text('Z'), findsOneWidget); - }); } diff --git a/apps/desktop/test/video_reload_e2e_test.dart b/apps/desktop/test/video_reload_e2e_test.dart index 3b2bd30..0b22bf6 100644 --- a/apps/desktop/test/video_reload_e2e_test.dart +++ b/apps/desktop/test/video_reload_e2e_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 6 (Media pipeline + player host): the +// fresh player per video. import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e2d34cd..d6e9b2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1081,11 +1081,60 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in in order, a link in the Link page's mode). All go through `QueueController.add`. None of it offline. +## App events and platform ports + +**Events.** Everything that may become a toast, a sound, a window +attention request or an activity-log line is an `AppEvent` +(`session/app_events.dart`, a sealed type, pure Dart): the server feed as +typed `FeedEvent`s (`FeedEvent.of`: joins, leaves, stale, sat out, back, +ready/unready, force ready/play/hold, swap, seek, pause, resume, +finished, chat, rename, avatar, queue add/remove/skip, fetch failed), +the session's own (nudged, a member can't play, slow source, relay +started/failed, idle warning, connection lost/back, Jellyfin progress +not saved) and the app's (shader message, added to Up next, a put-up or +queue add that failed, Schedule results, problem marked, copied / copy +failed, update available). Code with news calls +`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`); +nothing else writes toasts or plays sounds (Settings' sound preview +aside). `noticeFor` is the whole policy, one `Notice` per event: the +toast text and its actor, the `SfxCue`, attention, the feed line for the +activity log. Today: every feed line is logged (`Notifier.feedLog`, the +last 100); joins, leaves, swaps, chat, being sat out by someone else and +Up next skips toast; the sounds are listed under "Sounds"; a nudge also +asks for attention; an available update does nothing (the update band +shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came +(`expire`, the session's 1 s tick), cleared on connect. The Library +tab's card errors are still its own snack bars. + +**Ports.** UI and session code reach the platform through interfaces +(`platform/ports/`), made once in the composition root +(`SessionState.ports`, `desktopPorts()` in `platform/desktop/`) and +handed on from there (the coordinator gets its `Notifier` and +`LinkResolver` as constructor arguments): +- `ShellPort` (`DesktopShell`): window start (remembered bounds, the + close request routed to `shutDownApp`), fullscreen, minimize/maximize + and their changes, dragging (`WindowDragArea`), close, the way out + (save, hide, quit), attention, the clipboard paste (files, else text) + and drop targets (fox_utils). +- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`. +- `FolderAccess` (`DesktopFolders`): folder and file dialogs + (file_picker), the Files scan, folder exists, file stat, showing a + folder in the file manager. Paths for now. +- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw + options, warm-up. +- `SystemNotifications`: an interface only; desktop shows none + (`NoSystemNotifications`). +Still direct: the player (`PlaybackController`, media_kit, and its +`AppYtDlp.instance.mpvRawOptions`), the updater's restart +(`Process.start`), the session's own file reads for what it plays or +relays (`QueueController`, thumbnails, cookies import), app-private +caches. + ## Desktop UI One window, `AppShell` (`lib/src/shell/`), whatever the connection state (online = `SessionState.route` is `session`). The window comes back as it was -(`platform/window_memory.dart`, settings key `windowBounds`, saved on +(`platform/desktop/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 @@ -1269,7 +1318,7 @@ panel) and chat messages (Copy message, and a link in it). Library → Link (with others the relay mode is picked there). In a server, videos and links also get Add to queue / Play next. Images are ignored. Nothing happens while a dialog or popover is over the shell. -- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, +- **Sounds** (`sfx/`): the `Notifier` plays an `SfxCue` for ready, unready, force-ready, join, leave, swap and others' chat (feed events), being sat out by someone (the `kick` sound), a nudge (always, whatever its switch), the idle warning, and connection lost / back (once per drop). @@ -1390,7 +1439,7 @@ panel) and chat messages (Copy message, and a link in it). the controls down and the video not playing a glass chip (`PlayerGlass`) top right shows it (while it plays nothing sits on the picture). - **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with + **Toasts** (`toasts.dart`): the `Notifier`'s toasts bottom left, with the avatar of their `ToastItem.actor` (feed and chat toasts; the app's own have none); each slides in, stays 5 s, then slides out and collapses its gap. A click on @@ -1551,7 +1600,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins. `StartBanner` under them); media_kit's own controls are not used, so its keys, double-click route and seek bar are out of the picture. -Window chrome is frameless (`window_manager`): the party strip is the drag +Window chrome is frameless (`window_manager`, behind `ShellPort`): the party strip is the drag area and holds the min/max/close buttons (`widgets/window_buttons.dart`); fullscreen has no drag area and shows the buttons in the player's top bar. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e998cd3..96a0b7a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -56,31 +56,53 @@ and `e2e_check` (simulated viewers against a live server). ## Tests -- `cd packages/core && dart test`, `cd packages/server && dart test`. -- `cd apps/desktop && flutter test test/`. -- Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`, - `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a - local fake Jellyfin over HTTP; tests never talk to a real Jellyfin), - `queue_switch_e2e` (times Up next switches against a delayed, rate-capped - fake Jellyfin and link host: an episode, a direct link, a link passed - on and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path, - `WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and - end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with - ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't - open video-only files, so test media needs an audio track. Tests always log - media_kit "S/W rendering" because they have no display; that says nothing - about the app. Run only tests related to - what you changed. -- `sound_device_e2e` opens the real audio device (flutter_soloud) and plays - every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1); - `sound_engine_test` covers the engine with a fake backend. -- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus - `dart compile exe` of the dedicated server (fails if Flutter creeps into - `packages/server`). Desktop tests are not in CI. -- `packages/server/test/serve_isolate_test.dart` runs `serve` in a real - isolate; `apps/desktop/test/server_isolate_test.dart` covers the app's - host path (isolate server, raw WebSocket join, crash, rehost). -- Desktop tests that write files use `WT_TEST_TMP` (default: system temp). +Three suites, run from each package's folder: + +- `cd packages/core && dart test` (a few seconds): protocol round-trips, + the sync engine (clock, gate, correction, end of file), queue model, + relay pieces, updater, logging redaction, watch rule, yt-dlp and + Jellyfin client parsing against captured fixtures. +- `cd packages/server && dart test` (well under a minute): live servers + on loopback: auth, media and relay byte serving, Jellyfin relay against + a fake, queue, session log, isolate `serve`. +- `cd apps/desktop && flutter test test/`: widgets (shell, library, + controls, settings, schedule), pure app logic (`StateDiff`, name parser, + shader list, sound engine with a fake backend) and the real-mpv `*_e2e` + tests plus `coord_presence`. The whole suite takes a couple of minutes; + run it only before a restructure step lands, otherwise only the files + related to what you changed. + +Opt-in, skipped unless set: `JELLYFIN_TEST_*` (core `jellyfin_live` and +server `jellyfin_relay_live` against a real Jellyfin; read their headers), +`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything +else is offline: Jellyfin and link hosts are local fakes. + +Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`, +`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`, +`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its +own: a guest's file, links passed on or fetched by the server, a late +source, a source that drops and resumes), `jellyfin_e2e`, +`queue_switch_e2e` (times Up next switches against a delayed, rate-capped +fake Jellyfin and link host: an episode, a direct link, a link passed on +and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path, +`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync, +reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others +generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all +desktop tests that write files use it). Headless mpv (no video output +attached) won't open video-only files, so test media needs an audio +track. Tests always log media_kit "S/W rendering" because they have no +display; that says nothing about the app. `sound_device_e2e` opens the +real audio device (flutter_soloud) and plays every sound of every preset +quietly (`WT_SFX_VOLUME`, default 0.1). + +A test file whose first line is `// restructure: rewrite in step N` pins +behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes; +the comment names the tests. Rewrite or delete them in that step, not +before. + +CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus +`dart compile exe` of the dedicated server (fails if Flutter creeps into +`packages/server`). Desktop tests are not in CI. Traps: - Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill @@ -91,7 +113,11 @@ Traps: response (a relay response cut short when its relay is dropped at a swap or stop) to the zone the server started in, which fails the test; outside tests it is only logged. Start it in `runZonedGuarded` - and fail on anything else (`queue_switch_e2e`). + and fail on anything else (`queue_switch_e2e`, `relay_e2e`): a viewer's + read is still in flight at the `stop` in tearDown. +- The Link page probes a filled-in link (network, yt-dlp), which never + answers on the widget-test clock: pass `AppShell(linkProbe:)` / + `LibraryTab(probe:)` a fake, or `pumpAndSettle` never returns. - Never depend on a server-side WebSocket `onDone`; assert presence with deadline polling. - `sub.asFuture()` can resolve before `onDone`; poll with a deadline. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 709963c..b352235 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,8 +59,6 @@ Problems are numbered; delete a line when its fix ships. - P23 Solo mode you enter and leave; going hosted loses the queue (D9). - P24 The invite address list is unhelpful (shows the proxy's 198.18.x). - P25 ssh to Gitea blocked through the proxy (pushes go over https). -- P27 Failing tests: shell "Ctrl+V outside a field", relay e2e "a guest - puts up a file", "links: through me". - P28 Log noise: "Failed to create file cache"; an idle relay channel for Jellyfin items everyone has an account for. - P29 Relay start delay after a seek into an unbuffered part. @@ -87,10 +85,9 @@ the automatic continuation and countdown, relay state), the device (client id, library and folders, match corrections, watch history, settings, notification prefs, what this app is sourcing) and the integrations (Jellyfin played/resume, AniList list). -0. Safety net and seams, no visible change: fix P27; an `AppEvent` type; - platform ports wrapping today's calls (window/shell, open URL, - notifications, yt-dlp process, folder access, player host); - Connection extracted from the coordinator; the proto 16 spec written. +0. Safety net and seams, no visible change. Left: Connection extracted + from the coordinator. (Done: tests audited and tagged per step, P27 + fixed, AppEvent + Notifier seam, platform ports, protocol 16 planned.) 1. Connection + Identity + Presence, proto 16 in full (one bump, with a min/max version range and a stable refusal frame so later changes are additive): a lasting client id; a socket close no longer removes the @@ -133,7 +130,8 @@ No Windows dev builds: restructure testing happens on Linux; Windows friends stay on 1.2.x until the 1.3.0 release (Windows-only fixes like P30 reach them then). Rename to Starsync everywhere (repo, app id, data folder, updater URL -migration) is its own step; proposed app id `ru.foxyz.starsync`. Auto +migration) is its own step; app id `dev.foxyz.starsync`, Windows +company name `foxyz` (replacing Flutter's default `com.example`). Auto sit-out: idle or minimised for a while sets you Away, activity sets you In; it replaces the 10-minute auto-unready. @@ -148,6 +146,99 @@ In; it replaces the 10-minute auto-unready. - D10 was dropped on purpose (preload/warm relay/fresh player are folded into step 6, not a separate decision). +### Protocol 16 (planned) + +Lands in step 1, written into PROTOCOL.md then. Tags against proto 15: +**new**, **changed**, **removed**. Rooms are out: one server, no room id. + +Versions and refusal: +- `hello.proto` **changed**: the client's version; the server accepts + `[protoMin, protoMax]` (both 16 at 1.3.0; later additions raise + `protoMax` only, so they stay additive). `welcome.proto` **new**: the + version used on this connection. +- Refusal **changed**, stable from 16 on: `{"t":"err","code":"version", + "min":16,"max":16,"server":"1.3.0","message":"proto mismatch: got 15, + want 16..16"}`, then close (a proto 15 app shows `message`). Every + `err` gets `code` **new**: `version`, `secret`, `rejected`, `bad`, + `replaced`. +- `/health`: `protoMin`, `protoMax`, `tls` **new** (`proto` = `protoMax`). + +Handshake: +- `hello.client` **new**: a random id per install (22 characters + base64url), private: never broadcast. +- `hello.app` (`"1.3.0"`), `hello.platform` (`linux`, `windows`, + `android`), `hello.caps` **new**: what this app can do (`source`: serve + a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own + account); unknown entries ignored. +- `hello.resume` **removed** (the client id replaces it); `hello.media` + **removed** (unused). +- `members[].app` **new**: the member's `hello.app`. + +Lasting member: +- The server maps `client` to one member. A new socket with a known + client id takes that member over (`memberId` and `token` unchanged, so + `/m` `/r` `/j` URLs keep working); the older socket gets `err` + `replaced` and closes. `welcome.resumed` **new**: true when it did. +- A socket close no longer removes the member **changed**: it goes + `gone` and is kept for the grace period (~10 min, `welcome.grace` + **new**, seconds) with its ready flag, presence, queue items and + relay. After the grace it leaves (feed `leave`). +- `{"t":"bye"}` **new**: leave now (Leave button, app close). + +Presence: +- `members[].presence` **new**: `joining` | `in` | `away` | `gone`; + `members[].goneSince` **new** (ISO, only while `gone`). + `members[].out` and `members[].stale` **removed** (`away`; a silent + socket is closed and its member is `gone`). +- `{"t":"presence","target":"","state":"away"|"in"}` **new**, + replaces `out` **removed**: anyone sets someone `away`; only the member + itself goes back `in`. +- Feed **changed**: `away` (was `out`), `in` (was `back`), `gone` (was + `stale`), `return` **new** (back within the grace: no `join`), `leave` + (after the grace or `bye`), `join` (new members only). +- Gate (one `PresencePolicy` in core, used by server and app): only `in` + counts for the gate, the ready counter and watched tracking; `away` and + `gone` never. A joiner is `joining` until its report holds the current + media with `bufferNeed` at the current position, then `in`. While + `playing` a joiner never holds (D2): it loads in the background and + comes in already in sync. While held it is in the set like a fresh + member today, for at most ~15 s, then out of the set until `in`. A + returning member (`gone` → back within grace) is `joining` again with + its ready flag kept, treated like a joiner; an `away` one stays + `away`. + +Relay (D6): +- `media.relay.sourceGone` **new**: true while the source member is + `gone`. The relay and its buffer stay; viewers play what is buffered. +- Source resume **changed**: the source's close no longer ends the relay; + only its leave (grace over, `bye`) or a swap does. Back in the grace, + it dials `/r///source` again; an `info` equal to the first + continues (pulls restart from the first byte or piece not buffered, new + pull ids), a different one ends the relay (close 4002 **new**). + +Queue and what's next: +- `queue[].by` **changed**: lasting (the member id survives reconnects); + a `through`/`file` item of a `gone` adder waits instead of being + skipped, until its adder leaves. +- `queue[].auto` **new**: the continuation (next episode): `add` with + `auto:true`; at most one, after the others, replaced by a newer one, + dropped by a swap to something else. +- `state.advance` **new**: `{"id":"q3","at":"","secs":10}`, + the shared countdown when the media finishes and an item is first; at + `at` + `secs` the server sets `queuePlay` to it. + `{"t":"queue","op":"cancel"}` **new** clears it for everyone (feed + `advance-cancel` **new**). + +Media identity: +- `media.ref` **new**, in `swap.media`, `state.media` and + `queue[].media`: `{"kind":"episode"|"movie"|"extra","ids":{"anilist":1, + "tvdb":2},"show":"...","season":1,"episode":6}` (MediaRef); passed + through as is, at most 2 KiB. + +Transport: paths unchanged. A server with TLS serves the same endpoints +over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered, +`ws://` stays allowed. + ## Backlog 1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index a7b5b7f..1e1f070 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -1,3 +1,8 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): the +// groups "sitting out", "membership + gate", "presence gate", "stale ready +// rejoins" and the member rules of "swap" pin today's presence (out flag, +// stale sweep, leave on drop); presence In/Away/Gone with grace replaces +// them. The clock, correction, end-of-file and wire groups stay. import 'dart:convert'; import 'package:sync_relay_core/core.dart'; diff --git a/packages/core/test/watch_session_test.dart b/packages/core/test/watch_session_test.dart index 0f24855..6093c39 100644 --- a/packages/core/test/watch_session_test.dart +++ b/packages/core/test/watch_session_test.dart @@ -1,3 +1,4 @@ +// restructure: rewrite in step 5 (D3: the watched rule moves from position coverage to time present) import 'package:sync_relay_core/core.dart'; import 'package:test/test.dart'; diff --git a/packages/server/test/hardening_test.dart b/packages/server/test/hardening_test.dart index 0e6a324..a4cbe9a 100644 --- a/packages/server/test/hardening_test.dart +++ b/packages/server/test/hardening_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): "anyone +// sits anyone out" (Away); the rest stays. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/server/test/lifecycle_test.dart b/packages/server/test/lifecycle_test.dart index 93a325b..e0918d4 100644 --- a/packages/server/test/lifecycle_test.dart +++ b/packages/server/test/lifecycle_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): a +// socket close no longer removes the member (explicit `bye`, grace). import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/server/test/queue_test.dart b/packages/server/test/queue_test.dart index 495ccad..80ead90 100644 --- a/packages/server/test/queue_test.dart +++ b/packages/server/test/queue_test.dart @@ -1,3 +1,5 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): queue +// `by` and adder-only keys follow the lasting client id. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/server/test/server_auth_test.dart b/packages/server/test/server_auth_test.dart index 4d81d4f..5da2a42 100644 --- a/packages/server/test/server_auth_test.dart +++ b/packages/server/test/server_auth_test.dart @@ -1,3 +1,6 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): +// "proto mismatch rejected" (min/max range, refusal frame) and "silent +// socket dropped and member removed" (Gone with grace); the rest stays. import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/server/test/sync_wave_test.dart b/packages/server/test/sync_wave_test.dart index fe86594..29149fb 100644 --- a/packages/server/test/sync_wave_test.dart +++ b/packages/server/test/sync_wave_test.dart @@ -1,3 +1,6 @@ +// restructure: rewrite in step 1 (Connection + Identity + Presence): the +// resume-token takeover becomes a lasting client id; ping/pong and the +// seek echo stay. import 'dart:async'; import 'dart:convert'; import 'dart:io';