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,
);
}
}
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
@@ -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';
+2
View File
@@ -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;
+2
View File
@@ -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';
+2 -5
View File
@@ -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}),
+2
View File
@@ -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';
-33
View File
@@ -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: ''));
});
}
@@ -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';
+2 -10
View File
@@ -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 {
+2
View File
@@ -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';
@@ -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';
+20 -5
View File
@@ -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<String>)> _origin(String file) async {
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> 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);
@@ -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';
@@ -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';
@@ -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';
+4 -24
View File
@@ -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);
});
}
@@ -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';
+53 -4
View File
@@ -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.
+52 -26
View File
@@ -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/<file>`.
- 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/<file>`: 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.
+98 -7
View File
@@ -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":"<memberId>","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/<sid>/<token>/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":"<server time>","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
+5
View File
@@ -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';
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
@@ -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';
+3
View File
@@ -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';