Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
- 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:
1 parent
7e98ebd1db
commit
a1b270fd4e
63 files changed
+1228
-431
No files matched your search
+17
-31
@@ -5,18 +5,16 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'src/playback/yt_dlp_app.dart';
|
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart';
|
import 'src/platform/app_shutdown.dart';
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.dart';
|
||||||
import 'src/platform/window_memory.dart';
|
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
|
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
import 'src/state/settings.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 {
|
Future<void> main(List<String> args) async {
|
||||||
launchArgs = List.unmodifiable(args);
|
launchArgs = List.unmodifiable(args);
|
||||||
@@ -46,24 +44,11 @@ Future<void> main(List<String> args) async {
|
|||||||
await startAppLogging();
|
await startAppLogging();
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
await windowManager.ensureInitialized();
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
await windowManager.setPreventClose(true);
|
// see "left", a hosted server shuts down and no player outlives the
|
||||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
// engine.
|
||||||
shutdownWindowMemory = memory;
|
final ports = SessionState.instance.ports;
|
||||||
windowManager.addListener(_CloseGuard());
|
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
|
||||||
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();
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await SessionState.instance.loadForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -71,18 +56,19 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
// 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).
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
unawaited(installDesktopEntry());
|
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());
|
runApp(const WatchTogetherApp());
|
||||||
// The sound device opens once the window is up, not on the first sound.
|
// The sound device opens once the window is up, not on the first sound.
|
||||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
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());
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../../shell/motion.dart';
|
import '../../shell/motion.dart';
|
||||||
|
import '../../platform/ports/folder_access.dart';
|
||||||
|
import '../../state/session_state.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../folder_store.dart';
|
import '../folder_store.dart';
|
||||||
import '../name_parser.dart';
|
import '../name_parser.dart';
|
||||||
@@ -32,6 +33,8 @@ String? _lastOpen;
|
|||||||
final _expanded = <String>{};
|
final _expanded = <String>{};
|
||||||
final _showRequests = ValueNotifier(0);
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
|
FolderAccess get _folders => SessionState.instance.ports.folders;
|
||||||
|
|
||||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||||
/// else when it next opens.
|
/// else when it next opens.
|
||||||
void showFilesFolder(String? dir) {
|
void showFilesFolder(String? dir) {
|
||||||
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
|
|||||||
final String? nowPlayingPath;
|
final String? nowPlayingPath;
|
||||||
/// Defaults to [thumbnailFor].
|
/// Defaults to [thumbnailFor].
|
||||||
final ThumbnailLoader? thumbnails;
|
final ThumbnailLoader? thumbnails;
|
||||||
/// Defaults to [scanFolder].
|
/// Defaults to [FolderAccess.scan].
|
||||||
final Future<FolderScan> Function(String dir)? scan;
|
final Future<FolderScan> Function(String dir)? scan;
|
||||||
/// Defaults to [FolderStore.instance].
|
/// Defaults to [FolderStore.instance].
|
||||||
final FolderStore? store;
|
final FolderStore? store;
|
||||||
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
void _shown() => _select(_lastOpen);
|
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) {
|
void _select(String? dir) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
String? dir;
|
String? dir;
|
||||||
try {
|
try {
|
||||||
dir = await (widget.pickFolder ??
|
dir = await (widget.pickFolder ??
|
||||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||||
}
|
}
|
||||||
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _pickVideo() async {
|
static Future<String?> _pickVideo() async {
|
||||||
final files = await FilePicker.pickFiles(
|
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||||
dialogTitle: 'Pick a video',
|
return files.firstOrNull;
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: videoExtensions.toList(),
|
|
||||||
);
|
|
||||||
return files.isEmpty ? null : files.first.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeFolder(String dir) {
|
void _removeFolder(String dir) {
|
||||||
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
if (scan.videos.isEmpty)
|
if (scan.videos.isEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Directory(dir).existsSync()
|
child: _folders.folderExists(dir)
|
||||||
? const FilesEmpty(
|
? const FilesEmpty(
|
||||||
Icons.search_off,
|
Icons.search_off,
|
||||||
'No videos here',
|
'No videos here',
|
||||||
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
File(widget.path).stat().then((s) {
|
_folders.stat(widget.path).then((s) {
|
||||||
if (mounted) setState(() => _stat = s);
|
if (mounted) setState(() => _stat = s);
|
||||||
}, onError: (Object _) {});
|
}, onError: (Object _) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
|||||||
b.writeln('mpv: unknown');
|
b.writeln('mpv: unknown');
|
||||||
}
|
}
|
||||||
try {
|
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);
|
final cookies = y.cookies.read(count: false);
|
||||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../files/thumbnails.dart';
|
import '../files/thumbnails.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import 'window_memory.dart';
|
|
||||||
|
|
||||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
// 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
|
// 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
|
// inside the message loop. The runners end the process right after
|
||||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||||
|
|
||||||
/// Saved on the way out; set by main once known.
|
|
||||||
WindowMemory? shutdownWindowMemory;
|
|
||||||
|
|
||||||
bool _shuttingDown = false;
|
bool _shuttingDown = false;
|
||||||
|
|
||||||
/// True once [shutDownApp] started.
|
/// True once [shutDownApp] started.
|
||||||
@@ -30,9 +25,10 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
|||||||
if (_shuttingDown) return;
|
if (_shuttingDown) return;
|
||||||
_shuttingDown = true;
|
_shuttingDown = true;
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
|
final shell = session.ports.shell;
|
||||||
final total = Stopwatch()..start();
|
final total = Stopwatch()..start();
|
||||||
await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500));
|
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||||
await _step('hide', windowManager.hide(), const Duration(milliseconds: 500));
|
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
_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));
|
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||||
try {
|
await shell.quit();
|
||||||
await windowManager.setPreventClose(false);
|
|
||||||
await windowManager.close();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('window close failed: $e');
|
|
||||||
await windowManager.destroy();
|
|
||||||
}
|
|
||||||
// The engine stops with the window, so this only fires when the close
|
// 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
|
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||||
// EGL one segfaults, Windows DLL detach can hang).
|
// EGL one segfaults, Windows DLL detach can hang).
|
||||||
|
|||||||
File renamed without changes.
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -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:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
|
||||||
/// mpv's `ytdl-format` for links every player opens itself.
|
/// mpv's `ytdl-format` for links every player opens itself.
|
||||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
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 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,
|
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||||
/// so the Link preview and the swap ask once.
|
/// so the Link preview and the swap ask once.
|
||||||
class AppYtDlp {
|
class AppYtDlp implements LinkResolver {
|
||||||
static final instance = AppYtDlp();
|
static final instance = AppYtDlp();
|
||||||
|
|
||||||
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||||
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||||
|
|
||||||
Future<YtDlp>? _ytDlp;
|
Future<YtDlp>? _ytDlp;
|
||||||
|
@override
|
||||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||||
final base = await getApplicationSupportDirectory();
|
final base = await getApplicationSupportDirectory();
|
||||||
return YtDlp(dir(base.path));
|
return YtDlp(dir(base.path));
|
||||||
@@ -32,10 +35,12 @@ class AppYtDlp {
|
|||||||
|
|
||||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
/// update check afterwards).
|
/// update check afterwards).
|
||||||
|
@override
|
||||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||||
/// yt-dlp folder (see [YtDlpCookies]).
|
/// yt-dlp folder (see [YtDlpCookies]).
|
||||||
|
@override
|
||||||
Future<String> mpvRawOptions() async {
|
Future<String> mpvRawOptions() async {
|
||||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +59,7 @@ class AppYtDlp {
|
|||||||
/// is probed again before that runs out). [fresh] probes again: the
|
/// is probed again before that runs out). [fresh] probes again: the
|
||||||
/// remembered result stays until the new one is in, and stays when the
|
/// remembered result stays until the new one is in, and stays when the
|
||||||
/// new one fails.
|
/// new one fails.
|
||||||
|
@override
|
||||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
_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.
|
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||||
|
@override
|
||||||
void release(String url) {
|
void release(String url) {
|
||||||
final hit = _probes[url];
|
final hit = _probes[url];
|
||||||
if (hit == null) return;
|
if (hit == null) return;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
|
|||||||
import '../jellyfin/format.dart' show pickFor;
|
import '../jellyfin/format.dart' show pickFor;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
|
import '../session/app_events.dart' show AddedToUpNext;
|
||||||
import '../session/media_art.dart';
|
import '../session/media_art.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
@@ -82,7 +83,6 @@ class QueueController {
|
|||||||
if (_attached) return;
|
if (_attached) return;
|
||||||
_attached = true;
|
_attached = true;
|
||||||
coord.sync.states.listen(_onState);
|
coord.sync.states.listen(_onState);
|
||||||
coord.sync.feeds.listen(_onFeed);
|
|
||||||
effect(() {
|
effect(() {
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
final plan = coord.jellyfinPlan.value;
|
final plan = coord.jellyfinPlan.value;
|
||||||
@@ -122,7 +122,7 @@ class QueueController {
|
|||||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||||
final thumb = built.thumb;
|
final thumb = built.thumb;
|
||||||
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +369,6 @@ class QueueController {
|
|||||||
_warm(r);
|
_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
|
/// Forgets what no longer matters: performing marks after a minute (a
|
||||||
/// swap that never landed may be tried again), local picks no longer
|
/// swap that never landed may be tried again), local picks no longer
|
||||||
/// queued.
|
/// queued.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -640,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
return KeyEventResult.ignored;
|
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.
|
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||||
Future<void> _paste() async {
|
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+'))]);
|
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (picks.isEmpty) {
|
if (picks.isEmpty) {
|
||||||
@@ -829,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.sizeOf(context).height;
|
final height = MediaQuery.sizeOf(context).height;
|
||||||
final drop = DropTarget(
|
final drop = SessionState.instance.ports.shell.dropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, _) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
_dropped(DesktopDropX.pathsOf(d.files));
|
_dropped(paths);
|
||||||
},
|
},
|
||||||
// Esc: the popover's own (it fades out).
|
// Esc: the popover's own (it fades out).
|
||||||
child: Builder(
|
child: Builder(
|
||||||
|
|||||||
@@ -119,8 +119,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
static Future<void> _openLogs() async {
|
static Future<void> _openLogs() async {
|
||||||
final dir = appLogDir;
|
final dir = appLogDir;
|
||||||
if (dir == null) return;
|
if (dir == null) return;
|
||||||
// The file manager opens a folder path the way a browser opens a link.
|
if (!await SessionState.instance.ports.folders.reveal(dir)) {
|
||||||
if (!await openInBrowser(Uri.directory(dir).toString())) {
|
|
||||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
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:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
const CustomShaderFiles({super.key});
|
const CustomShaderFiles({super.key});
|
||||||
|
|
||||||
Future<void> _addFiles() async {
|
Future<void> _addFiles() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick mpv GLSL shaders',
|
dialogTitle: 'Pick mpv GLSL shaders',
|
||||||
type: FileType.custom,
|
extensions: const ['glsl', 'hook'],
|
||||||
allowedExtensions: const ['glsl', 'hook'],
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
final current = s.customShaders.peek();
|
final current = s.customShaders.peek();
|
||||||
final added = [
|
final added = [
|
||||||
for (final f in files)
|
for (final p in files)
|
||||||
if (f.path case final p? when !current.contains(p)) p,
|
if (!current.contains(p)) p,
|
||||||
];
|
];
|
||||||
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
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);
|
||||||
|
}
|
||||||
@@ -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 '../jellyfin/track_prefs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
import '../platform/attention.dart';
|
import '../platform/ports/link_resolver.dart';
|
||||||
import '../playback/yt_dlp_app.dart';
|
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart' show NoopSfxService, SfxService;
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../sync/avatar_cache.dart';
|
import '../sync/avatar_cache.dart';
|
||||||
import '../sync/state_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
@@ -30,10 +30,13 @@ import 'delivery.dart';
|
|||||||
import 'health.dart';
|
import 'health.dart';
|
||||||
import 'idle.dart';
|
import 'idle.dart';
|
||||||
import 'media_art.dart';
|
import 'media_art.dart';
|
||||||
|
import 'notifier.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
import 'now_playing_info.dart';
|
import 'now_playing_info.dart';
|
||||||
import 'switch_trace.dart';
|
import 'switch_trace.dart';
|
||||||
|
|
||||||
|
export 'notifier.dart' show ToastItem;
|
||||||
|
|
||||||
final _log = createLogger('session');
|
final _log = createLogger('session');
|
||||||
|
|
||||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
/// 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.
|
/// Seconds of the end-of-episode countdown.
|
||||||
const autoAdvanceSecs = 10;
|
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
|
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
/// 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
|
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||||
@@ -164,7 +155,9 @@ String? stateMediaUrl(StateBroadcast state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SessionCoordinator {
|
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.
|
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
||||||
final JellyfinAccounts jellyfin;
|
final JellyfinAccounts jellyfin;
|
||||||
/// Audio/subtitle carry-over between Jellyfin items.
|
/// Audio/subtitle carry-over between Jellyfin items.
|
||||||
@@ -173,7 +166,7 @@ class SessionCoordinator {
|
|||||||
/// null when this app is not the host.
|
/// null when this app is not the host.
|
||||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||||
/// The app's yt-dlp and link probes (tests pass their own).
|
/// The app's yt-dlp and link probes (tests pass their own).
|
||||||
final AppYtDlp ytDlp;
|
final LinkResolver ytDlp;
|
||||||
final SyncClient sync = SyncClient();
|
final SyncClient sync = SyncClient();
|
||||||
/// This app's log lines to the server's session log (Settings: "Share
|
/// This app's log lines to the server's session log (Settings: "Share
|
||||||
/// diagnostics with the server"), and "Mark a problem".
|
/// diagnostics with the server"), and "Mark a problem".
|
||||||
@@ -217,8 +210,10 @@ class SessionCoordinator {
|
|||||||
final health =
|
final health =
|
||||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final serverState = signal<StateBroadcast?>(null);
|
final serverState = signal<StateBroadcast?>(null);
|
||||||
final feedLog = signal<List<FeedMessage>>([]);
|
/// The activity log ([Notifier.feedLog]).
|
||||||
final toasts = signal<List<ToastItem>>([]);
|
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
|
||||||
|
/// Toasts on screen ([Notifier.toasts]).
|
||||||
|
Signal<List<ToastItem>> get toasts => notifier.toasts;
|
||||||
final reconnecting = signal(false);
|
final reconnecting = signal(false);
|
||||||
final starving = signal<String?>(null);
|
final starving = signal<String?>(null);
|
||||||
/// Why this player cannot open the current media, in plain words
|
/// 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].
|
/// Self sits out: the player has nothing open until [joinBack].
|
||||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
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;
|
Timer? _ticker;
|
||||||
StreamSubscription? _stateSub;
|
StreamSubscription? _stateSub;
|
||||||
StreamSubscription? _feedSub;
|
StreamSubscription? _feedSub;
|
||||||
@@ -362,7 +354,6 @@ class SessionCoordinator {
|
|||||||
String _resumeToken = '';
|
String _resumeToken = '';
|
||||||
DateTime? _lastStateAt;
|
DateTime? _lastStateAt;
|
||||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||||
int _toastId = 0;
|
|
||||||
ConnectParams? _params;
|
ConnectParams? _params;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
/// The connection-lost sound played; the reconnected one answers it once.
|
/// 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.
|
// Members' last `error`, by id, so a new one toasts once.
|
||||||
Map<String, String> _memberErrors = const {};
|
Map<String, String> _memberErrors = const {};
|
||||||
|
|
||||||
|
/// [notifier] defaults to one playing [sfx] (tests).
|
||||||
SessionCoordinator(
|
SessionCoordinator(
|
||||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
{SfxService sfx = const NoopSfxService(),
|
||||||
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
Notifier? notifier,
|
||||||
|
JellyfinAccounts? jellyfin,
|
||||||
|
TrackPrefs? trackPrefs,
|
||||||
|
LinkResolver? ytDlp})
|
||||||
|
: notifier = notifier ?? Notifier(sfx: sfx),
|
||||||
|
ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||||
|
|
||||||
@@ -438,7 +435,7 @@ class SessionCoordinator {
|
|||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
final e = now[m.id];
|
final e = now[m.id];
|
||||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
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;
|
_memberErrors = now;
|
||||||
@@ -452,7 +449,7 @@ class SessionCoordinator {
|
|||||||
final warning = slowSourceWarning(r, sync.memberId);
|
final warning = slowSourceWarning(r, sync.memberId);
|
||||||
if (warning == null) return;
|
if (warning == null) return;
|
||||||
_slowWarnedFor = r.mediaId;
|
_slowWarnedFor = r.mediaId;
|
||||||
_pushToast(warning);
|
notify(SlowSource(warning));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _reloadingVideo = false;
|
bool _reloadingVideo = false;
|
||||||
@@ -528,8 +525,7 @@ class SessionCoordinator {
|
|||||||
diag
|
diag
|
||||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||||
..start();
|
..start();
|
||||||
feedLog.value = [];
|
notifier.clear();
|
||||||
toasts.value = [];
|
|
||||||
await initPlayer();
|
await initPlayer();
|
||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
health.value =
|
health.value =
|
||||||
@@ -807,9 +803,7 @@ class SessionCoordinator {
|
|||||||
_relaySource = warm.src;
|
_relaySource = warm.src;
|
||||||
_relaySourceMediaId = mediaId;
|
_relaySourceMediaId = mediaId;
|
||||||
relaying.value = true;
|
relaying.value = true;
|
||||||
if (announce) {
|
if (announce) notify(const RelayStarted());
|
||||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
|
||||||
}
|
|
||||||
unawaited(warm.src.done.then((_) {
|
unawaited(warm.src.done.then((_) {
|
||||||
if (identical(_relaySource, warm.src)) {
|
if (identical(_relaySource, warm.src)) {
|
||||||
_relaySource = null;
|
_relaySource = null;
|
||||||
@@ -823,9 +817,7 @@ class SessionCoordinator {
|
|||||||
_relaySource = src;
|
_relaySource = src;
|
||||||
_relaySourceMediaId = mediaId;
|
_relaySourceMediaId = mediaId;
|
||||||
relaying.value = true;
|
relaying.value = true;
|
||||||
if (announce) {
|
if (announce) notify(const RelayStarted());
|
||||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
|
||||||
}
|
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await src.start();
|
await src.start();
|
||||||
@@ -834,7 +826,7 @@ class SessionCoordinator {
|
|||||||
if (identical(_relaySource, src)) {
|
if (identical(_relaySource, src)) {
|
||||||
_relaySource = null;
|
_relaySource = null;
|
||||||
relaying.value = false;
|
relaying.value = false;
|
||||||
_pushToast('Cannot pass the video on: $e');
|
notify(RelayFailed('$e'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1138,62 +1130,19 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onFeed(FeedMessage f) {
|
void _onFeed(FeedMessage f) {
|
||||||
feedLog.value = [...feedLog.peek(), f].length > 100
|
final event = FeedEvent.of(f,
|
||||||
? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99)
|
selfName: selfMember.peek()?.name,
|
||||||
: [...feedLog.peek(), f];
|
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
|
||||||
if (_toastActions.contains(f.action)) {
|
notify(event);
|
||||||
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)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// The server gave up fetching what everyone waits for.
|
// 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");
|
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
|
/// Someone poked this member: sound (even when its switch is off; the
|
||||||
/// volume still applies), a toast, and the window asks for attention.
|
/// volume still applies), a toast, and the window asks for attention.
|
||||||
void _onNudge(NudgeNotice n) {
|
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
|
||||||
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 _startTicker() {
|
void _startTicker() {
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
@@ -1227,8 +1176,7 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
Future<void> _tick() async {
|
Future<void> _tick() async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
toasts.value =
|
notifier.expire(now);
|
||||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
|
||||||
final r = serverState.peek();
|
final r = serverState.peek();
|
||||||
if (sync.token.isNotEmpty) sync.sendPing();
|
if (sync.token.isNotEmpty) sync.sendPing();
|
||||||
diag
|
diag
|
||||||
@@ -1271,17 +1219,7 @@ class SessionCoordinator {
|
|||||||
selfReady: selfMember.peek()?.ready ?? false);
|
selfReady: selfMember.peek()?.ready ?? false);
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case IdleEvent.warn:
|
case IdleEvent.warn:
|
||||||
_toastId++;
|
notify(IdleWarning(sitsOut: sitOutWhenIdle));
|
||||||
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)));
|
|
||||||
case IdleEvent.unready:
|
case IdleEvent.unready:
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
if (sitOutWhenIdle) {
|
if (sitOutWhenIdle) {
|
||||||
@@ -1738,17 +1676,9 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
// ------------------------------------------------------------- jellyfin
|
// ------------------------------------------------------------- jellyfin
|
||||||
|
|
||||||
/// A toast from outside the session (e.g. the Schedule tab's request
|
/// News for the user, from the session or anywhere in the app (see
|
||||||
/// results), shown like the session's own.
|
/// [Notifier]).
|
||||||
void showToast(String text) => _pushToast(text);
|
void notify(AppEvent event) => notifier.notify(event);
|
||||||
|
|
||||||
void _pushToast(String text, {String? actor}) {
|
|
||||||
_toastId++;
|
|
||||||
toasts.value = [
|
|
||||||
...toasts.peek(),
|
|
||||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _resetJellyfin() {
|
void _resetJellyfin() {
|
||||||
_jfGen++;
|
_jfGen++;
|
||||||
@@ -1928,7 +1858,7 @@ class SessionCoordinator {
|
|||||||
onError: (e) {
|
onError: (e) {
|
||||||
if (_jfToastShown) return;
|
if (_jfToastShown) return;
|
||||||
_jfToastShown = true;
|
_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();
|
final snap = await _snapshot();
|
||||||
@@ -2153,7 +2083,7 @@ class SessionCoordinator {
|
|||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
if (_lostSounded) {
|
if (_lostSounded) {
|
||||||
_lostSounded = false;
|
_lostSounded = false;
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
notify(const ConnectionBack());
|
||||||
}
|
}
|
||||||
_resumeToken = sync.token;
|
_resumeToken = sync.token;
|
||||||
_lastStateAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
@@ -2195,7 +2125,7 @@ class SessionCoordinator {
|
|||||||
void _soundLost() {
|
void _soundLost() {
|
||||||
if (!_everConnected || _lostSounded) return;
|
if (!_everConnected || _lostSounded) return;
|
||||||
_lostSounded = true;
|
_lostSounded = true;
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
notify(const ConnectionLost());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _invalidateAttempts() {
|
void _invalidateAttempts() {
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import 'dart:io' show File;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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:signals/signals_flutter.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../controls/frame.dart' show DoubleClickListener;
|
import '../controls/frame.dart' show DoubleClickListener;
|
||||||
import '../files/folder_store.dart';
|
import '../files/folder_store.dart';
|
||||||
@@ -14,6 +12,7 @@ import '../jellyfin/accounts.dart';
|
|||||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
|
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||||
import '../session/session_coordinator.dart' show ConnectParams;
|
import '../session/session_coordinator.dart' show ConnectParams;
|
||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
@@ -25,6 +24,7 @@ import '../update/update_controller.dart';
|
|||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
|
import 'library_link.dart' show LinkProber, defaultLinkProber;
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'party_strip.dart';
|
import 'party_strip.dart';
|
||||||
@@ -58,6 +58,9 @@ class AppShell extends StatefulWidget {
|
|||||||
final JellyfinAccounts? jellyfin;
|
final JellyfinAccounts? jellyfin;
|
||||||
/// Stand-in for media_kit's `Video` (tests).
|
/// Stand-in for media_kit's `Video` (tests).
|
||||||
final VideoViewBuilder? videoBuilder;
|
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({
|
const AppShell({
|
||||||
super.key,
|
super.key,
|
||||||
this.updates,
|
this.updates,
|
||||||
@@ -66,6 +69,7 @@ class AppShell extends StatefulWidget {
|
|||||||
this.healthGet = defaultHealthGet,
|
this.healthGet = defaultHealthGet,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
|
this.linkProbe = defaultLinkProber,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -329,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Future<void> _setWindowFullscreen(bool on) async {
|
Future<void> _setWindowFullscreen(bool on) async {
|
||||||
if (!widget.windowControls) return;
|
if (!widget.windowControls) return;
|
||||||
try {
|
try {
|
||||||
await windowManager.setFullScreen(on);
|
await SessionState.instance.ports.shell.setFullScreen(on);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// No window manager.
|
// 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
|
/// 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.
|
/// image has no taker yet, so the clipboard's image data is not read.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
var files = const <String>[];
|
final clip = await SessionState.instance.ports.shell.readPaste();
|
||||||
String? text;
|
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
||||||
try {
|
|
||||||
files = await ClipboardX.readFiles();
|
|
||||||
} catch (_) {}
|
|
||||||
if (files.isEmpty) {
|
|
||||||
try {
|
|
||||||
text = await ClipboardX.readText();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
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):
|
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||||
final error = await _putUp(FileMedia(paths.first));
|
final error = await _putUp(FileMedia(paths.first));
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.showToast(error);
|
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||||
} else {
|
} else {
|
||||||
await FolderStore.instance.ensureLoaded();
|
await FolderStore.instance.ensureLoaded();
|
||||||
FolderStore.instance.addRecent(paths.first);
|
FolderStore.instance.addRecent(paths.first);
|
||||||
@@ -500,7 +495,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
for (final p in next ? paths.reversed : paths) {
|
for (final p in next ? paths.reversed : paths) {
|
||||||
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.showToast(error);
|
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,7 +504,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
final error = await coord.queue
|
final error = await coord.queue
|
||||||
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
.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):
|
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||||
await _addFolder(File(paths.first).parent.path);
|
await _addFolder(File(paths.first).parent.path);
|
||||||
case (HandoffFolder(:final 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.
|
// 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,
|
enable: _tab != ShellTab.settings,
|
||||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
onEnter: () => setState(() => _dragging = _onTop),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, at) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
_handOff(classifyHandoff(paths: paths), at);
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -718,12 +713,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Up next needs a server (a solo session has one).
|
// Up next needs a server (a solo session has one).
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||||
|
probe: widget.linkProbe,
|
||||||
),
|
),
|
||||||
ShellTab.schedule => SchedulePage(
|
ShellTab.schedule => SchedulePage(
|
||||||
solo: alone,
|
solo: alone,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onToast: coord.showToast,
|
onToast: (text) => coord.notify(ScheduleResult(text)),
|
||||||
),
|
),
|
||||||
ShellTab.settings => SettingsTab(
|
ShellTab.settings => SettingsTab(
|
||||||
section: _section,
|
section: _section,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:sync_relay_core/core.dart'
|
import 'package:sync_relay_core/core.dart'
|
||||||
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
||||||
|
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
// The Library's Link source: a big paste field, a preview of what the link
|
// 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);
|
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;
|
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||||
|
|
||||||
|
import '../session/app_events.dart' show ProblemMarked;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
|
|||||||
if (note == null) return;
|
if (note == null) return;
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
final inSession = await coord.markProblem(note);
|
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
|
/// The small card: a one-line note and Mark. Pops with the note (maybe
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.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 '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
@@ -263,7 +262,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
// their own clicks.
|
// their own clicks.
|
||||||
if (windowControls)
|
if (windowControls)
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: DragToMoveArea(child: SizedBox.expand()),
|
child: WindowDragArea(child: SizedBox.expand()),
|
||||||
),
|
),
|
||||||
Positioned.fill(child: row),
|
Positioned.fill(child: row),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
@@ -329,18 +328,17 @@ class _CropPickerState extends State<CropPicker> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pick() async {
|
Future<void> _pick() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a picture',
|
dialogTitle: 'Pick a picture',
|
||||||
type: FileType.custom,
|
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
final path = files.firstOrNull;
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
try {
|
try {
|
||||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../queue/queue_ui.dart';
|
|||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
|
import '../session/app_events.dart' show ShaderMessage;
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
@@ -92,8 +93,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
// Own toast ids, negative: the coordinator counts up from 1.
|
|
||||||
int _toastSeq = 0;
|
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the controls are up (their frame reports it).
|
// Whether the controls are up (their frame reports it).
|
||||||
@@ -134,7 +133,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final n = _coord.playback.shaderNotice.value;
|
final n = _coord.playback.shaderNotice.value;
|
||||||
if (n == null || n.id == _shaderNoticeSeen) return;
|
if (n == null || n.id == _shaderNoticeSeen) return;
|
||||||
_shaderNoticeSeen = n.id;
|
_shaderNoticeSeen = n.id;
|
||||||
_toast(n.text);
|
_coord.notify(ShaderMessage(n.text));
|
||||||
});
|
});
|
||||||
_stopPopoverWatch = effect(() {
|
_stopPopoverWatch = effect(() {
|
||||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||||
@@ -162,13 +161,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
super.dispose();
|
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();
|
void _toggleChat() => _model.toggleChat();
|
||||||
|
|
||||||
/// The player keys, while the player fills the body and nothing (a
|
/// The player keys, while the player fills the body and nothing (a
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart'
|
import 'package:sync_relay_core/core.dart'
|
||||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
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 '../controls/player_settings.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_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
|
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||||
/// last error.
|
/// last error.
|
||||||
class YtDlpSettings extends StatefulWidget {
|
class YtDlpSettings extends StatefulWidget {
|
||||||
/// Defaults to [AppYtDlp.instance]'s.
|
/// Defaults to the app's ([Ports.links]).
|
||||||
final Future<YtDlp>? ytDlp;
|
final Future<YtDlp>? ytDlp;
|
||||||
const YtDlpSettings({super.key, this.ytDlp});
|
const YtDlpSettings({super.key, this.ytDlp});
|
||||||
|
|
||||||
@@ -389,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -472,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||||
type: FileType.custom,
|
extensions: const ['txt', 'json'],
|
||||||
allowedExtensions: const ['txt', 'json'],
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_reload('file dialog unavailable — try again');
|
_reload('file dialog unavailable — try again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final path = files.firstOrNull?.path;
|
final path = files.firstOrNull;
|
||||||
if (path != null) await _importFile(path);
|
if (path != null) await _importFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,12 +531,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return DropTarget(
|
return SessionState.instance.ports.shell.dropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, _) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
final path = paths.firstOrNull;
|
||||||
if (path != null) _importFile(path);
|
if (path != null) _importFile(path);
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../platform/app_log.dart' show appLogDir;
|
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/sfx.dart';
|
||||||
import '../sfx/sound_bank.dart';
|
import '../sfx/sound_bank.dart';
|
||||||
import '../sfx/synth_sfx.dart';
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
|
import '../session/notifier.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
@@ -167,7 +169,11 @@ class SessionState {
|
|||||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||||
presetFor: soundPresetFor,
|
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();
|
final lastForm = ConnectForm();
|
||||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||||
/// Set when the hosted server died mid-session; Home shows it on the
|
/// 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.
|
/// default) when the app-support folder is not available.
|
||||||
static Future<String> _sharedYtDlpDir() async {
|
static Future<String> _sharedYtDlpDir() async {
|
||||||
try {
|
try {
|
||||||
return (await AppYtDlp.instance.ytDlp).dir;
|
return (await instance.ports.links.ytDlp).dir;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -285,7 +291,7 @@ class SessionState {
|
|||||||
try {
|
try {
|
||||||
if (carry != null) await _resume(carry);
|
if (carry != null) await _resume(carry);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
coordinator.showToast("couldn't put it up again: $e");
|
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
switching.value = false;
|
switching.value = false;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
||||||
@@ -22,28 +21,10 @@ abstract final class ReuseText {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void Function(String text)? debugReuseToast;
|
void Function(String text)? debugReuseToast;
|
||||||
|
|
||||||
void _toast(String text) {
|
void _notify(AppEvent event) {
|
||||||
final debug = debugReuseToast;
|
final debug = debugReuseToast;
|
||||||
if (debug != null) return debug(text);
|
if (debug != null) return debug(noticeFor(event).toast ?? '');
|
||||||
SessionState.instance.coordinator.showToast(text);
|
SessionState.instance.coordinator.notify(event);
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
|
/// 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 {
|
try {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_toast("Couldn't copy");
|
_notify(const CopyFailed());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_toast(done);
|
_notify(Copied(done));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens [url] in the browser; when none opens, copies it instead and
|
/// Opens [url] in the browser; when none opens, copies it instead and
|
||||||
/// says so.
|
/// says so.
|
||||||
Future<void> openLink(String url) async {
|
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");
|
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
|
|||||||
State<WindowButtons> createState() => _WindowButtonsState();
|
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;
|
bool _maximized = false;
|
||||||
|
late final void Function() _stopWatch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
windowManager.addListener(this);
|
_stopWatch = _shell.watchMaximized((v) {
|
||||||
windowManager.isMaximized().then((v) {
|
if (mounted) setState(() => _maximized = v);
|
||||||
|
});
|
||||||
|
_shell.isMaximized().then((v) {
|
||||||
if (mounted) setState(() => _maximized = v);
|
if (mounted) setState(() => _maximized = v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
windowManager.removeListener(this);
|
_stopWatch();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void onWindowMaximize() {
|
|
||||||
if (mounted) setState(() => _maximized = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onWindowUnmaximize() {
|
|
||||||
if (mounted) setState(() => _maximized = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleMaximize() async {
|
Future<void> _toggleMaximize() async {
|
||||||
if (widget.onToggleFullscreen != null) {
|
if (widget.onToggleFullscreen != null) {
|
||||||
widget.onToggleFullscreen!();
|
widget.onToggleFullscreen!();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await windowManager.isMaximized()) {
|
if (await _shell.isMaximized()) {
|
||||||
await windowManager.unmaximize();
|
await _shell.unmaximize();
|
||||||
} else {
|
} 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.coordinator.leave();
|
||||||
await SessionState.instance.stopServer();
|
await SessionState.instance.stopServer();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await windowManager.close();
|
await _shell.requestClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
|||||||
icon: Icons.remove,
|
icon: Icons.remove,
|
||||||
tooltip: 'Minimize',
|
tooltip: 'Minimize',
|
||||||
focusNode: focus,
|
focusNode: focus,
|
||||||
onPressed: () => windowManager.minimize(),
|
onPressed: () => _shell.minimize(),
|
||||||
),
|
),
|
||||||
_WinBtn(
|
_WinBtn(
|
||||||
icon: restored ? Icons.filter_none : Icons.crop_square,
|
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:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
|||||||
@@ -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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 4 (Library + Media identity): series
|
||||||
|
// grouping on the page follows L1/L2.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
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 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): hosted recents and the host
|
||||||
|
// card; the rest stays.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -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 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
@@ -364,11 +366,6 @@ void main() {
|
|||||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
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', () {
|
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||||
final members = [
|
final members = [
|
||||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||||
|
|||||||
@@ -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 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|||||||
@@ -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 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
|
||||||
|
// (one computed level); the title block and overflow checks stay.
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -165,16 +167,6 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
||||||
expect(tester.takeException(), isNull);
|
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 {
|
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
||||||
|
|||||||
@@ -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:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/queue/queue_logic.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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -65,6 +68,7 @@ Future<(HttpServer, List<String>)> _origin(String file) async {
|
|||||||
void main() {
|
void main() {
|
||||||
late SyncServer server;
|
late SyncServer server;
|
||||||
late List<SessionCoordinator> all;
|
late List<SessionCoordinator> all;
|
||||||
|
late List<Object> serverErrors;
|
||||||
|
|
||||||
Future<SessionCoordinator> member(String name) async {
|
Future<SessionCoordinator> member(String name) async {
|
||||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
@@ -102,7 +106,15 @@ void main() {
|
|||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
all = [];
|
all = [];
|
||||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
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 {
|
tearDown(() async {
|
||||||
@@ -110,6 +122,7 @@ void main() {
|
|||||||
await x.dispose();
|
await x.dispose();
|
||||||
}
|
}
|
||||||
await server.stop();
|
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)),
|
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.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||||
expect(relay.sourceToken, bob.sync.token);
|
expect(relay.sourceToken, bob.sync.token);
|
||||||
|
|
||||||
// The next swap retires bob's source and the buffer.
|
// The next swap retires bob's source and the buffer. Nothing answers
|
||||||
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
|
// 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');
|
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
||||||
expect(server.hub.relay, isNull);
|
expect(server.hub.relay, isNull);
|
||||||
});
|
});
|
||||||
@@ -155,7 +169,8 @@ void main() {
|
|||||||
await playTogether([alice, bob]);
|
await playTogether([alice, bob]);
|
||||||
expect(hits.length, greaterThan(before));
|
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);
|
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
page.listen((r) {
|
page.listen((r) {
|
||||||
r.response.headers.contentType = ContentType.html;
|
r.response.headers.contentType = ContentType.html;
|
||||||
@@ -163,7 +178,7 @@ void main() {
|
|||||||
r.response.close();
|
r.response.close();
|
||||||
});
|
});
|
||||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
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);
|
await page.close(force: true);
|
||||||
} finally {
|
} finally {
|
||||||
await origin.close(force: true);
|
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:convert';
|
||||||
import 'dart:io';
|
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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:watch_together/src/session/idle.dart';
|
import 'package:watch_together/src/session/idle.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status
|
||||||
|
// pill; in step 3 (Your server): the solo strip and Invite friends.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -79,6 +81,8 @@ void main() {
|
|||||||
autoUpdateCheck: false,
|
autoUpdateCheck: false,
|
||||||
windowControls: false,
|
windowControls: false,
|
||||||
videoBuilder: (context, controls) => const FakeVideo(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
@@ -192,18 +196,6 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
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', () {
|
group('popovers', () {
|
||||||
@@ -418,16 +410,4 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Shaders off'), findsNothing);
|
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 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|||||||
+53
-4
@@ -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
|
in order, a link in the Link page's mode). All go through
|
||||||
`QueueController.add`. None of it offline.
|
`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
|
## Desktop UI
|
||||||
|
|
||||||
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
||||||
(online = `SessionState.route` is `session`). The window comes back as it was
|
(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
|
resize/move/maximize and close): its normal size and maximized state
|
||||||
everywhere, its position where an app may place itself (Windows, X11;
|
everywhere, its position where an app may place itself (Windows, X11;
|
||||||
not Wayland, where the compositor decides) and only if it still lands
|
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
|
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.
|
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.
|
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
|
unready, force-ready, join, leave, swap and others' chat (feed
|
||||||
events), being sat out by someone (the `kick` sound), a nudge (always,
|
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).
|
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
|
the controls down and the video not playing a glass chip
|
||||||
(`PlayerGlass`) top right shows it (while it plays nothing sits on the
|
(`PlayerGlass`) top right shows it (while it plays nothing sits on the
|
||||||
picture).
|
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
|
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
||||||
own have none); each
|
own have none); each
|
||||||
slides in, stays 5 s, then slides out and collapses its gap. A click on
|
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
|
`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.
|
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`);
|
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.
|
fullscreen has no drag area and shows the buttons in the player's top bar.
|
||||||
|
|
||||||
|
|||||||
+49
-23
@@ -56,31 +56,53 @@ and `e2e_check` (simulated viewers against a live server).
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `cd packages/core && dart test`, `cd packages/server && dart test`.
|
Three suites, run from each package's folder:
|
||||||
- `cd apps/desktop && flutter test test/<file>`.
|
|
||||||
- Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
|
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
|
the sync engine (clock, gate, correction, end of file), queue model,
|
||||||
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),
|
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
|
`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
|
fake Jellyfin and link host: an episode, a direct link, a link passed on
|
||||||
on and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
|
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
|
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync,
|
||||||
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
|
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
|
||||||
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
|
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
|
||||||
open video-only files, so test media needs an audio track. Tests always log
|
desktop tests that write files use it). Headless mpv (no video output
|
||||||
media_kit "S/W rendering" because they have no display; that says nothing
|
attached) won't open video-only files, so test media needs an audio
|
||||||
about the app. Run only tests related to
|
track. Tests always log media_kit "S/W rendering" because they have no
|
||||||
what you changed.
|
display; that says nothing about the app. `sound_device_e2e` opens the
|
||||||
- `sound_device_e2e` opens the real audio device (flutter_soloud) and plays
|
real audio device (flutter_soloud) and plays every sound of every preset
|
||||||
every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1);
|
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
|
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
|
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||||
`packages/server`). Desktop tests are not in CI.
|
`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).
|
|
||||||
|
|
||||||
Traps:
|
Traps:
|
||||||
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
|
- 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
|
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
|
swap or stop) to the zone the server started in, which fails the
|
||||||
test; outside tests it is only logged. Start it in `runZonedGuarded`
|
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
|
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
||||||
deadline polling.
|
deadline polling.
|
||||||
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
||||||
|
|||||||
+98
-7
@@ -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).
|
- 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).
|
- 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).
|
- 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
|
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
|
||||||
Jellyfin items everyone has an account for.
|
Jellyfin items everyone has an account for.
|
||||||
- P29 Relay start delay after a seek into an unbuffered part.
|
- 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,
|
(client id, library and folders, match corrections, watch history,
|
||||||
settings, notification prefs, what this app is sourcing) and the
|
settings, notification prefs, what this app is sourcing) and the
|
||||||
integrations (Jellyfin played/resume, AniList list).
|
integrations (Jellyfin played/resume, AniList list).
|
||||||
0. Safety net and seams, no visible change: fix P27; an `AppEvent` type;
|
0. Safety net and seams, no visible change. Left: Connection extracted
|
||||||
platform ports wrapping today's calls (window/shell, open URL,
|
from the coordinator. (Done: tests audited and tagged per step, P27
|
||||||
notifications, yt-dlp process, folder access, player host);
|
fixed, AppEvent + Notifier seam, platform ports, protocol 16 planned.)
|
||||||
Connection extracted from the coordinator; the proto 16 spec written.
|
|
||||||
1. Connection + Identity + Presence, proto 16 in full (one bump, with a
|
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
|
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
|
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
|
friends stay on 1.2.x until the 1.3.0 release (Windows-only fixes like
|
||||||
P30 reach them then).
|
P30 reach them then).
|
||||||
Rename to Starsync everywhere (repo, app id, data folder, updater URL
|
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
|
sit-out: idle or minimised for a while sets you Away, activity sets you
|
||||||
In; it replaces the 10-minute auto-unready.
|
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
|
- D10 was dropped on purpose (preload/warm relay/fresh player are folded
|
||||||
into step 6, not a separate decision).
|
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
|
## Backlog
|
||||||
|
|
||||||
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
|
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
|
||||||
|
|||||||
@@ -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 'dart:convert';
|
||||||
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
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:sync_relay_core/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 1 (Connection + Identity + Presence): "anyone
|
||||||
|
// sits anyone out" (Away); the rest stays.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
Reference in new issue
Block a user