Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s

- Tests: the four known failures fixed (test-side causes), one duplicate
  file and four detail-pinning cases removed, every test that pins
  behaviour the restructure changes tagged with its step.
- Every toast, sound and activity-log line goes through Notifier.notify
  (AppEvent), mapped one-to-one to today's behaviour.
- Platform ports (shell, URLs, folders, link resolver, system
  notifications) with desktop implementations; window_manager, Process
  and file_picker now live only in platform/desktop/.
- ROADMAP: protocol 16 planned; app id dev.foxyz.starsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 19:24:11 +03:00
1 parent 7e98ebd1db
commit a1b270fd4e
63 files changed
+1231 -434

No files matched your search

+17 -31
View File
@@ -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<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args);
@@ -46,24 +44,11 @@ Future<void> main(List<String> 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<void> main(List<String> 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());
}
+11 -12
View File
@@ -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 = <String>{};
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<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance].
final FolderStore? store;
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
void _shown() => _select(_lastOpen);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
void _select(String? dir) {
setState(() {
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
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<FilesPage> {
}
static Future<String?> _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<FilesPage> {
),
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 _) {});
}
+1 -2
View File
@@ -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<String> 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'}');
@@ -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<void> shutDownApp({Future<void> 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<void> shutDownApp({Future<void> 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).
@@ -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<String?> pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle);
@override
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions}) async {
final files = await FilePicker.pickFiles(
dialogTitle: dialogTitle,
type: FileType.custom,
allowedExtensions: extensions,
);
return [for (final f in files) ?f.path];
}
@override
Future<FolderScan> scan(String dir) => scanFolder(dir);
@override
bool folderExists(String dir) => Directory(dir).existsSync();
@override
Future<FileStat> stat(String path) => File(path).stat();
// The file manager opens a folder path the way a browser opens a link.
@override
Future<bool> reveal(String dir) => urls.open(Uri.directory(dir).toString());
}
@@ -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(),
);
}
@@ -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<void> start({required String title, required Future<void> 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<void> setFullScreen(bool on) => windowManager.setFullScreen(on);
@override
Future<void> minimize() => windowManager.minimize();
@override
Future<bool> isMaximized() => windowManager.isMaximized();
@override
Future<void> maximize() => windowManager.maximize();
@override
Future<void> 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<void> startDragging() => windowManager.startDragging();
@override
Future<void> requestClose() => windowManager.close();
@override
Future<void> saveWindow() async => _memory?.save();
@override
Future<void> hide() => windowManager.hide();
@override
Future<void> quit() async {
try {
await windowManager.setPreventClose(false);
await windowManager.close();
} catch (e) {
debugPrint('window close failed: $e');
await windowManager.destroy();
}
}
@override
Future<void> requestAttention() => requestWindowAttention();
@override
Future<Paste> readPaste() async {
var files = const <String>[];
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<String> 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<void> 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);
}
@@ -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<bool> 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;
}
}
}
@@ -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<String?> pickFolder({required String dialogTitle});
/// The system file dialog limited to [extensions]; the picked paths
/// (empty when cancelled).
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions});
/// The videos under [dir] (see [FolderScan]); never throws.
Future<FolderScan> scan(String dir);
/// Whether the folder [dir] exists.
bool folderExists(String dir);
/// Size and dates of [path]; throws when it is gone.
Future<FileStat> stat(String path);
/// Shows the folder [dir] in the file manager; false when nothing
/// opened it.
Future<bool> reveal(String dir);
}
@@ -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<YtDlp> get ytDlp;
/// Gets it ready in the background (download on first run, updates).
void warmUp();
/// mpv's `ytdl-raw-options` (sub languages, cookies).
Future<String> mpvRawOptions();
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
/// [url] no longer needs keeping longer than usual.
void release(String url);
}
@@ -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,
});
}
@@ -0,0 +1,54 @@
import 'package:flutter/widgets.dart';
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> 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<void> start({required String title, required Future<void> Function() onCloseRequest});
Future<void> setFullScreen(bool on);
Future<void> minimize();
Future<bool> isMaximized();
Future<void> maximize();
Future<void> 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<void> startDragging();
/// Asks to close the window: the same path as the close button.
Future<void> requestClose();
/// The way out: remembers the window, hides it ([hide]), and later
/// closes it for good ([quit]).
Future<void> saveWindow();
Future<void> hide();
Future<void> quit();
/// Points the desktop at the window when it is not focused (taskbar
/// flash, urgency hint); nothing when it is.
Future<void> requestAttention();
/// The clipboard's files, else its text (Ctrl+V outside a text field).
Future<Paste> 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<String> paths, Offset at) onDrop,
required Widget child,
});
}
@@ -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<void> show({required String title, required String body, String tag = ''});
}
/// Shows nothing (desktop today).
class NoSystemNotifications implements SystemNotifications {
const NoSystemNotifications();
@override
Future<void> show({required String title, required String body, String tag = ''}) async {}
}
@@ -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<bool> open(String url);
}
@@ -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>? _ytDlp;
@override
Future<YtDlp> 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<String> 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<LinkProbe> 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;
@@ -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.
+6 -21
View File
@@ -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<QueuePanel> {
return KeyEventResult.ignored;
}
Future<({List<String> files, String? text})> _readClipboard() async {
var files = const <String>[];
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<void> _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<QueuePanel> {
@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(
@@ -119,8 +119,7 @@ class AboutPanel extends StatelessWidget {
static Future<void> _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");
}
}
@@ -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<void> _addFiles() async {
final List<PlatformFile> files;
final List<String> 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]);
}
@@ -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);
}
+140
View File
@@ -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<void> Function() attention;
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
: attention = attention ?? _noAttention;
static Future<void> _noAttention() async {}
/// Toasts on screen (the shell shows them; [expire] drops old ones).
final toasts = signal<List<ToastItem>>([]);
/// The activity log: the last 100 feed lines.
final feedLog = signal<List<FeedMessage>>([]);
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 = [];
}
}
@@ -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<StateBroadcast?>(null);
final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]);
/// The activity log ([Notifier.feedLog]).
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
/// Toasts on screen ([Notifier.toasts]).
Signal<List<ToastItem>> get toasts => notifier.toasts;
final reconnecting = signal(false);
final starving = signal<String?>(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<bool> selfOut = computed(() => selfMember.value?.out ?? false);
/// Asks the desktop to point at the window (a nudge); tests swap it.
Future<void> 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<String, String> _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<void> _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() {
+19 -23
View File
@@ -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<AppShell> with SingleTickerProviderStateMixin
Future<void> _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<AppShell> 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<void> _paste() async {
var files = const <String>[];
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<void> _handOff(Handoff item, Offset? at) async {
@@ -489,7 +484,7 @@ class AppShellState extends State<AppShell> 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<AppShell> 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<AppShell> 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<AppShell> 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<AppShell> 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,
+3 -3
View File
@@ -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<LinkProbe> Function(String url);
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
+2 -1
View File
@@ -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<void> 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
+1 -2
View File
@@ -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),
],
+4 -6
View File
@@ -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<CropPicker> {
}
Future<void> _pick() async {
final List<PlatformFile> files;
final List<String> 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());
+2 -10
View File
@@ -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<SessionPlayer> {
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<SessionPlayer> {
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<SessionPlayer> {
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
+11 -15
View File
@@ -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>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@@ -389,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
@override
Widget build(BuildContext context) {
@@ -472,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
};
Future<void> _pickFile() async {
final List<PlatformFile> files;
final List<String> 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<YtDlpCookieSettings> {
@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(
+10 -4
View File
@@ -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<String> _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;
+7 -26
View File
@@ -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<bool> 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<void> 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<void> 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");
}
@@ -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<WindowButtons> createState() => _WindowButtonsState();
}
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
class _WindowButtonsState extends State<WindowButtons> {
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<void> _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<WindowButtons> 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<WindowButtons> 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,
);
}
}