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
+1231
-434
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:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/playback/yt_dlp_app.dart';
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/window_memory.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/update_controller.dart' show launchArgs;
|
||||
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
@@ -46,24 +44,11 @@ Future<void> main(List<String> args) async {
|
||||
await startAppLogging();
|
||||
await Hive.initFlutter();
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
shutdownWindowMemory = memory;
|
||||
windowManager.addListener(_CloseGuard());
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
center: !memory.hasPosition,
|
||||
backgroundColor: const Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await memory.restore();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
// see "left", a hosted server shuts down and no player outlives the
|
||||
// engine.
|
||||
final ports = SessionState.instance.ports;
|
||||
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
@@ -71,18 +56,19 @@ Future<void> main(List<String> args) async {
|
||||
}
|
||||
MediaKit.ensureInitialized();
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
AppYtDlp.instance.warmUp();
|
||||
ports.links.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
// A found update is an event too (the band shows it).
|
||||
final updates = UpdateController.instance;
|
||||
updates.status.addListener(() {
|
||||
final s = updates.status.value;
|
||||
final version = s.manifest?.version;
|
||||
if (s.phase == UpdatePhase.available && version != null) {
|
||||
SessionState.instance.notifier.notify(UpdateAvailable(version));
|
||||
}
|
||||
});
|
||||
runApp(const WatchTogetherApp());
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
/// see "left", a hosted server shuts down and no player outlives the
|
||||
/// engine.
|
||||
class _CloseGuard with WindowListener {
|
||||
@override
|
||||
void onWindowClose() => unawaited(shutDownApp());
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
@@ -32,6 +33,8 @@ String? _lastOpen;
|
||||
final _expanded = <String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
FolderAccess get _folders => SessionState.instance.ports.folders;
|
||||
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
/// Defaults to [scanFolder].
|
||||
/// Defaults to [FolderAccess.scan].
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
void _shown() => _select(_lastOpen);
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
setState(() {
|
||||
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await (widget.pickFolder ??
|
||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
||||
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
|
||||
} catch (e) {
|
||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||
}
|
||||
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
|
||||
static Future<String?> _pickVideo() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a video',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: videoExtensions.toList(),
|
||||
);
|
||||
return files.isEmpty ? null : files.first.path;
|
||||
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||
return files.firstOrNull;
|
||||
}
|
||||
|
||||
void _removeFolder(String dir) {
|
||||
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
if (scan.videos.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Directory(dir).existsSync()
|
||||
child: _folders.folderExists(dir)
|
||||
? const FilesEmpty(
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
File(widget.path).stat().then((s) {
|
||||
_folders.stat(widget.path).then((s) {
|
||||
if (mounted) setState(() => _stat = s);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../state/session_state.dart';
|
||||
|
||||
@@ -59,7 +58,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
||||
b.writeln('mpv: unknown');
|
||||
}
|
||||
try {
|
||||
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
|
||||
final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
||||
final cookies = y.cookies.read(count: false);
|
||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||
|
||||
@@ -2,11 +2,9 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'window_memory.dart';
|
||||
|
||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||
// Order matters: every mpv player goes before the engine does (a live one
|
||||
@@ -15,9 +13,6 @@ import 'window_memory.dart';
|
||||
// inside the message loop. The runners end the process right after
|
||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||
|
||||
/// Saved on the way out; set by main once known.
|
||||
WindowMemory? shutdownWindowMemory;
|
||||
|
||||
bool _shuttingDown = false;
|
||||
|
||||
/// True once [shutDownApp] started.
|
||||
@@ -30,9 +25,10 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||
if (_shuttingDown) return;
|
||||
_shuttingDown = true;
|
||||
final session = SessionState.instance;
|
||||
final shell = session.ports.shell;
|
||||
final total = Stopwatch()..start();
|
||||
await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500));
|
||||
await _step('hide', windowManager.hide(), const Duration(milliseconds: 500));
|
||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||
await Future.wait([
|
||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
@@ -48,13 +44,7 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
try {
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.close();
|
||||
} catch (e) {
|
||||
debugPrint('window close failed: $e');
|
||||
await windowManager.destroy();
|
||||
}
|
||||
await shell.quit();
|
||||
// The engine stops with the window, so this only fires when the close
|
||||
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||
// EGL one segfaults, Windows DLL detach can hang).
|
||||
|
||||
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:sync_relay_core/core.dart';
|
||||
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
|
||||
/// mpv's `ytdl-format` for links every player opens itself.
|
||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||
|
||||
@@ -16,13 +18,14 @@ const ytdlSubLangs = 'all,-danmaku';
|
||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||
/// so the Link preview and the swap ask once.
|
||||
class AppYtDlp {
|
||||
class AppYtDlp implements LinkResolver {
|
||||
static final instance = AppYtDlp();
|
||||
|
||||
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||
|
||||
Future<YtDlp>? _ytDlp;
|
||||
@override
|
||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
return YtDlp(dir(base.path));
|
||||
@@ -32,10 +35,12 @@ class AppYtDlp {
|
||||
|
||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||
/// update check afterwards).
|
||||
@override
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
|
||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||
/// yt-dlp folder (see [YtDlpCookies]).
|
||||
@override
|
||||
Future<String> mpvRawOptions() async {
|
||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||
try {
|
||||
@@ -54,6 +59,7 @@ class AppYtDlp {
|
||||
/// is probed again before that runs out). [fresh] probes again: the
|
||||
/// remembered result stays until the new one is in, and stays when the
|
||||
/// new one fails.
|
||||
@override
|
||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||
final now = DateTime.now();
|
||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||
@@ -80,6 +86,7 @@ class AppYtDlp {
|
||||
}
|
||||
|
||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||
@override
|
||||
void release(String url) {
|
||||
final hit = _probes[url];
|
||||
if (hit == null) return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/app_events.dart' show AddedToUpNext;
|
||||
import '../session/media_art.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -82,7 +83,6 @@ class QueueController {
|
||||
if (_attached) return;
|
||||
_attached = true;
|
||||
coord.sync.states.listen(_onState);
|
||||
coord.sync.feeds.listen(_onFeed);
|
||||
effect(() {
|
||||
final next = coord.nextEpisode.value;
|
||||
final plan = coord.jellyfinPlan.value;
|
||||
@@ -122,7 +122,7 @@ class QueueController {
|
||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||
final thumb = built.thumb;
|
||||
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
coord.showToast('Added ${item.title} to Up next');
|
||||
coord.notify(AddedToUpNext(item.title));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -369,12 +369,6 @@ class QueueController {
|
||||
_warm(r);
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
if (f.action == 'queue-skip') {
|
||||
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets what no longer matters: performing marks after a minute (a
|
||||
/// swap that never landed may be tried again), local picks no longer
|
||||
/// queued.
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -640,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<({List<String> files, String? text})> _readClipboard() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||
Future<void> _paste() async {
|
||||
final clip = await (widget.readClipboard ?? _readClipboard)();
|
||||
final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
|
||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||
if (!mounted) return;
|
||||
if (picks.isEmpty) {
|
||||
@@ -829,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.sizeOf(context).height;
|
||||
final drop = DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
final drop = SessionState.instance.ports.shell.dropTarget(
|
||||
onEnter: () => setState(() => _dragging = true),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, _) {
|
||||
setState(() => _dragging = false);
|
||||
_dropped(DesktopDropX.pathsOf(d.files));
|
||||
_dropped(paths);
|
||||
},
|
||||
// Esc: the popover's own (it fades out).
|
||||
child: Builder(
|
||||
|
||||
@@ -119,8 +119,7 @@ class AboutPanel extends StatelessWidget {
|
||||
static Future<void> _openLogs() async {
|
||||
final dir = appLogDir;
|
||||
if (dir == null) return;
|
||||
// The file manager opens a folder path the way a browser opens a link.
|
||||
if (!await openInBrowser(Uri.directory(dir).toString())) {
|
||||
if (!await SessionState.instance.ports.folders.reveal(dir)) {
|
||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'ui.dart';
|
||||
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
|
||||
const CustomShaderFiles({super.key});
|
||||
|
||||
Future<void> _addFiles() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick mpv GLSL shaders',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['glsl', 'hook'],
|
||||
extensions: const ['glsl', 'hook'],
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
|
||||
final s = AppSettings.instance;
|
||||
final current = s.customShaders.peek();
|
||||
final added = [
|
||||
for (final f in files)
|
||||
if (f.path case final p? when !current.contains(p)) p,
|
||||
for (final p in files)
|
||||
if (!current.contains(p)) p,
|
||||
];
|
||||
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
// Everything that may become a toast, a sound, an attention request or an
|
||||
// activity-log line, as one sealed type. Code that has news emits an
|
||||
// event ([Notifier.notify]); only the notifier decides what the user sees
|
||||
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
|
||||
|
||||
sealed class AppEvent {
|
||||
const AppEvent();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- feed
|
||||
|
||||
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
|
||||
/// in the activity log.
|
||||
sealed class FeedEvent extends AppEvent {
|
||||
final FeedMessage feed;
|
||||
const FeedEvent(this.feed);
|
||||
|
||||
String get actor => feed.actor;
|
||||
|
||||
/// [feed] as its typed event. [selfName]: this member's name (the feed
|
||||
/// names members by name); [readyCount]: members ready in the latest
|
||||
/// state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'stale' => MemberStale(f),
|
||||
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
|
||||
'back' => MemberBack(f),
|
||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, ready: false),
|
||||
'force-ready' => ForcedReady(f, ready: true),
|
||||
'force-unready' => ForcedReady(f, ready: false),
|
||||
'force-play' => ForcedTransport(f, play: true),
|
||||
'force-hold' => ForcedTransport(f, play: false),
|
||||
'swap' => MediaSwapped(f),
|
||||
'seek' => Seeked(f),
|
||||
'pause' => Paused(f),
|
||||
'resume' => Resumed(f),
|
||||
'finished' => Finished(f),
|
||||
'chat' => ChatPosted(f, own: f.actor == selfName),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
};
|
||||
}
|
||||
|
||||
class MemberJoined extends FeedEvent {
|
||||
const MemberJoined(super.feed);
|
||||
}
|
||||
|
||||
class MemberLeft extends FeedEvent {
|
||||
const MemberLeft(super.feed);
|
||||
}
|
||||
|
||||
class MemberStale extends FeedEvent {
|
||||
const MemberStale(super.feed);
|
||||
}
|
||||
|
||||
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
|
||||
/// sat this member out.
|
||||
class MemberSatOut extends FeedEvent {
|
||||
final bool atYou;
|
||||
const MemberSatOut(super.feed, {this.atYou = false});
|
||||
}
|
||||
|
||||
class MemberBack extends FeedEvent {
|
||||
const MemberBack(super.feed);
|
||||
}
|
||||
|
||||
class ReadyChanged extends FeedEvent {
|
||||
final bool ready;
|
||||
/// Members ready when it came (pitch of the ready sound).
|
||||
final int readyIndex;
|
||||
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
|
||||
}
|
||||
|
||||
class ForcedReady extends FeedEvent {
|
||||
final bool ready;
|
||||
const ForcedReady(super.feed, {required this.ready});
|
||||
}
|
||||
|
||||
/// Force play / hold.
|
||||
class ForcedTransport extends FeedEvent {
|
||||
final bool play;
|
||||
const ForcedTransport(super.feed, {required this.play});
|
||||
}
|
||||
|
||||
class MediaSwapped extends FeedEvent {
|
||||
const MediaSwapped(super.feed);
|
||||
}
|
||||
|
||||
class Seeked extends FeedEvent {
|
||||
const Seeked(super.feed);
|
||||
}
|
||||
|
||||
class Paused extends FeedEvent {
|
||||
const Paused(super.feed);
|
||||
}
|
||||
|
||||
class Resumed extends FeedEvent {
|
||||
const Resumed(super.feed);
|
||||
}
|
||||
|
||||
class Finished extends FeedEvent {
|
||||
const Finished(super.feed);
|
||||
}
|
||||
|
||||
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||
class ChatPosted extends FeedEvent {
|
||||
final bool own;
|
||||
const ChatPosted(super.feed, {this.own = false});
|
||||
}
|
||||
|
||||
class MemberRenamed extends FeedEvent {
|
||||
const MemberRenamed(super.feed);
|
||||
}
|
||||
|
||||
class AvatarChanged extends FeedEvent {
|
||||
const AvatarChanged(super.feed);
|
||||
}
|
||||
|
||||
class QueueAdded extends FeedEvent {
|
||||
const QueueAdded(super.feed);
|
||||
}
|
||||
|
||||
class QueueRemoved extends FeedEvent {
|
||||
const QueueRemoved(super.feed);
|
||||
}
|
||||
|
||||
/// Up next skipped an item (`target` its title, `reason` why).
|
||||
class QueueSkipped extends FeedEvent {
|
||||
const QueueSkipped(super.feed);
|
||||
}
|
||||
|
||||
/// The server could not fetch the current media.
|
||||
class FetchFailed extends FeedEvent {
|
||||
const FetchFailed(super.feed);
|
||||
}
|
||||
|
||||
/// An action this app does not know (a newer server).
|
||||
class OtherFeed extends FeedEvent {
|
||||
const OtherFeed(super.feed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- session
|
||||
|
||||
/// Someone poked this member.
|
||||
class Nudged extends AppEvent {
|
||||
/// Their name; '' when unknown.
|
||||
final String name;
|
||||
const Nudged(this.name);
|
||||
}
|
||||
|
||||
/// Another member's player cannot open the current media.
|
||||
class MemberCantPlay extends AppEvent {
|
||||
final String name;
|
||||
final String error;
|
||||
const MemberCantPlay(this.name, this.error);
|
||||
}
|
||||
|
||||
/// This member passes the video on slower than it plays.
|
||||
class SlowSource extends AppEvent {
|
||||
final String text;
|
||||
const SlowSource(this.text);
|
||||
}
|
||||
|
||||
/// This member started passing the video on through the server.
|
||||
class RelayStarted extends AppEvent {
|
||||
const RelayStarted();
|
||||
}
|
||||
|
||||
class RelayFailed extends AppEvent {
|
||||
final String error;
|
||||
const RelayFailed(this.error);
|
||||
}
|
||||
|
||||
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
|
||||
class IdleWarning extends AppEvent {
|
||||
final bool sitsOut;
|
||||
const IdleWarning({required this.sitsOut});
|
||||
}
|
||||
|
||||
/// The connection to the server dropped (once per drop).
|
||||
class ConnectionLost extends AppEvent {
|
||||
const ConnectionLost();
|
||||
}
|
||||
|
||||
/// It came back after [ConnectionLost].
|
||||
class ConnectionBack extends AppEvent {
|
||||
const ConnectionBack();
|
||||
}
|
||||
|
||||
/// Jellyfin progress reports fail (once per media).
|
||||
class JellyfinProgressFailed extends AppEvent {
|
||||
final bool signInExpired;
|
||||
const JellyfinProgressFailed({required this.signInExpired});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ app
|
||||
|
||||
/// The player's shader message (a preset change, shaders turned off).
|
||||
class ShaderMessage extends AppEvent {
|
||||
final String text;
|
||||
const ShaderMessage(this.text);
|
||||
}
|
||||
|
||||
/// This app added an item to Up next.
|
||||
class AddedToUpNext extends AppEvent {
|
||||
final String title;
|
||||
const AddedToUpNext(this.title);
|
||||
}
|
||||
|
||||
/// Putting something up or adding it to Up next failed ([text]: why).
|
||||
class ActionFailed extends AppEvent {
|
||||
final String text;
|
||||
const ActionFailed(this.text);
|
||||
}
|
||||
|
||||
/// The Schedule tab's request results and errors.
|
||||
class ScheduleResult extends AppEvent {
|
||||
final String text;
|
||||
const ScheduleResult(this.text);
|
||||
}
|
||||
|
||||
/// "Mark a problem" done; [inSession]: in the server's session log.
|
||||
class ProblemMarked extends AppEvent {
|
||||
final bool inSession;
|
||||
const ProblemMarked({required this.inSession});
|
||||
}
|
||||
|
||||
/// Something went on the clipboard ([text]: the confirmation, e.g.
|
||||
/// "Link copied").
|
||||
class Copied extends AppEvent {
|
||||
final String text;
|
||||
const Copied(this.text);
|
||||
}
|
||||
|
||||
class CopyFailed extends AppEvent {
|
||||
const CopyFailed();
|
||||
}
|
||||
|
||||
/// A newer release is out (the update band shows it).
|
||||
class UpdateAvailable extends AppEvent {
|
||||
final String version;
|
||||
const UpdateAvailable(this.version);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals_core.dart';
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
import '../sfx/sfx.dart';
|
||||
import 'app_events.dart';
|
||||
|
||||
export 'app_events.dart';
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||
}
|
||||
|
||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||
/// the window asking for attention, an activity-log line.
|
||||
class Notice {
|
||||
final String? toast;
|
||||
final String? actor;
|
||||
final SfxCue? sound;
|
||||
final bool attention;
|
||||
/// The feed line it goes into the activity log as.
|
||||
final FeedMessage? log;
|
||||
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
|
||||
|
||||
static const none = Notice();
|
||||
}
|
||||
|
||||
/// The policy: what each event does.
|
||||
Notice noticeFor(AppEvent e) => switch (e) {
|
||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||
// pop up; the social actions have a sound.
|
||||
MemberJoined(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
|
||||
MemberLeft(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
|
||||
MediaSwapped(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
|
||||
ChatPosted(:final feed, :final own) => Notice(
|
||||
log: feed,
|
||||
toast: '${feed.actor}: ${feed.reason}',
|
||||
actor: feed.actor,
|
||||
// Your own message needs no sound.
|
||||
sound: own ? null : const SfxCue(SfxKind.chat)),
|
||||
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
||||
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||
QueueSkipped(:final feed) => Notice(
|
||||
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||
FeedEvent(:final feed) => Notice(log: feed),
|
||||
// The session.
|
||||
Nudged(:final name) => Notice(
|
||||
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||
actor: name.isEmpty ? null : name,
|
||||
sound: const SfxCue(SfxKind.nudge),
|
||||
attention: true),
|
||||
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
|
||||
SlowSource(:final text) => Notice(toast: text),
|
||||
RelayStarted() =>
|
||||
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
||||
IdleWarning(:final sitsOut) => Notice(
|
||||
toast: sitsOut
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||
JellyfinProgressFailed(:final signInExpired) =>
|
||||
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||
// The app.
|
||||
ShaderMessage(:final text) => Notice(toast: text),
|
||||
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
|
||||
ActionFailed(:final text) => Notice(toast: text),
|
||||
ScheduleResult(:final text) => Notice(toast: text),
|
||||
ProblemMarked(:final inSession) =>
|
||||
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||
Copied(:final text) => Notice(toast: text),
|
||||
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||
// The update band shows it.
|
||||
UpdateAvailable() => Notice.none,
|
||||
};
|
||||
|
||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||
/// toasts ([toasts]), sounds, an attention request and the activity log
|
||||
/// ([feedLog]) per [noticeFor].
|
||||
class Notifier {
|
||||
final SfxService sfx;
|
||||
final Future<void> Function() attention;
|
||||
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
||||
: attention = attention ?? _noAttention;
|
||||
|
||||
static Future<void> _noAttention() async {}
|
||||
|
||||
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log: the last 100 feed lines.
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
int _toastId = 0;
|
||||
|
||||
void notify(AppEvent e) {
|
||||
final n = noticeFor(e);
|
||||
final log = n.log;
|
||||
if (log != null) {
|
||||
final all = [...feedLog.peek(), log];
|
||||
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
|
||||
}
|
||||
final text = n.toast;
|
||||
if (text != null) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor),
|
||||
];
|
||||
}
|
||||
final sound = n.sound;
|
||||
if (sound != null) unawaited(sfx.play(sound));
|
||||
if (n.attention) unawaited(attention());
|
||||
}
|
||||
|
||||
/// Drops toasts 4 s old or older.
|
||||
void expire(DateTime now) {
|
||||
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
}
|
||||
|
||||
/// A new session: no toasts, an empty log.
|
||||
void clear() {
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ import '../jellyfin/session_media.dart';
|
||||
import '../jellyfin/track_prefs.dart';
|
||||
import '../playback/media_marks.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../platform/attention.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||
import '../schedule/anilist.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sfx.dart' show NoopSfxService, SfxService;
|
||||
import '../state/settings.dart';
|
||||
import '../sync/avatar_cache.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
@@ -30,10 +30,13 @@ import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
import 'media_art.dart';
|
||||
import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'notifier.dart' show ToastItem;
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||
@@ -100,18 +103,6 @@ class AutoAdvance {
|
||||
/// Seconds of the end-of-episode countdown.
|
||||
const autoAdvanceSecs = 10;
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||
}
|
||||
|
||||
const _toastActions = {'join', 'leave', 'swap', 'chat'};
|
||||
|
||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||
@@ -164,7 +155,9 @@ String? stateMediaUrl(StateBroadcast state) {
|
||||
}
|
||||
|
||||
class SessionCoordinator {
|
||||
final SfxService sfx;
|
||||
/// Toasts, sounds and the activity log: every event goes through
|
||||
/// [notify].
|
||||
final Notifier notifier;
|
||||
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
||||
final JellyfinAccounts jellyfin;
|
||||
/// Audio/subtitle carry-over between Jellyfin items.
|
||||
@@ -173,7 +166,7 @@ class SessionCoordinator {
|
||||
/// null when this app is not the host.
|
||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||
/// The app's yt-dlp and link probes (tests pass their own).
|
||||
final AppYtDlp ytDlp;
|
||||
final LinkResolver ytDlp;
|
||||
final SyncClient sync = SyncClient();
|
||||
/// This app's log lines to the server's session log (Settings: "Share
|
||||
/// diagnostics with the server"), and "Mark a problem".
|
||||
@@ -217,8 +210,10 @@ class SessionCoordinator {
|
||||
final health =
|
||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final serverState = signal<StateBroadcast?>(null);
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log ([Notifier.feedLog]).
|
||||
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
|
||||
/// Toasts on screen ([Notifier.toasts]).
|
||||
Signal<List<ToastItem>> get toasts => notifier.toasts;
|
||||
final reconnecting = signal(false);
|
||||
final starving = signal<String?>(null);
|
||||
/// Why this player cannot open the current media, in plain words
|
||||
@@ -279,9 +274,6 @@ class SessionCoordinator {
|
||||
/// Self sits out: the player has nothing open until [joinBack].
|
||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||
|
||||
/// Asks the desktop to point at the window (a nudge); tests swap it.
|
||||
Future<void> Function() requestAttention = requestWindowAttention;
|
||||
|
||||
Timer? _ticker;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
@@ -362,7 +354,6 @@ class SessionCoordinator {
|
||||
String _resumeToken = '';
|
||||
DateTime? _lastStateAt;
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
bool _everConnected = false;
|
||||
/// The connection-lost sound played; the reconnected one answers it once.
|
||||
@@ -388,9 +379,15 @@ class SessionCoordinator {
|
||||
// Members' last `error`, by id, so a new one toasts once.
|
||||
Map<String, String> _memberErrors = const {};
|
||||
|
||||
/// [notifier] defaults to one playing [sfx] (tests).
|
||||
SessionCoordinator(
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
||||
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||
{SfxService sfx = const NoopSfxService(),
|
||||
Notifier? notifier,
|
||||
JellyfinAccounts? jellyfin,
|
||||
TrackPrefs? trackPrefs,
|
||||
LinkResolver? ytDlp})
|
||||
: notifier = notifier ?? Notifier(sfx: sfx),
|
||||
ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||
|
||||
@@ -438,7 +435,7 @@ class SessionCoordinator {
|
||||
for (final m in r.members) {
|
||||
final e = now[m.id];
|
||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
||||
_pushToast("${m.name} can't play this: $e", actor: m.name);
|
||||
notify(MemberCantPlay(m.name, e));
|
||||
}
|
||||
}
|
||||
_memberErrors = now;
|
||||
@@ -452,7 +449,7 @@ class SessionCoordinator {
|
||||
final warning = slowSourceWarning(r, sync.memberId);
|
||||
if (warning == null) return;
|
||||
_slowWarnedFor = r.mediaId;
|
||||
_pushToast(warning);
|
||||
notify(SlowSource(warning));
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
@@ -528,8 +525,7 @@ class SessionCoordinator {
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..start();
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
notifier.clear();
|
||||
await initPlayer();
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
health.value =
|
||||
@@ -807,9 +803,7 @@ class SessionCoordinator {
|
||||
_relaySource = warm.src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) {
|
||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||
}
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(warm.src.done.then((_) {
|
||||
if (identical(_relaySource, warm.src)) {
|
||||
_relaySource = null;
|
||||
@@ -823,9 +817,7 @@ class SessionCoordinator {
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) {
|
||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||
}
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
@@ -834,7 +826,7 @@ class SessionCoordinator {
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
_pushToast('Cannot pass the video on: $e');
|
||||
notify(RelayFailed('$e'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1138,62 +1130,19 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
feedLog.value = [...feedLog.peek(), f].length > 100
|
||||
? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99)
|
||||
: [...feedLog.peek(), f];
|
||||
if (_toastActions.contains(f.action)) {
|
||||
final text = f.action == 'chat'
|
||||
? '${f.actor}: ${f.reason}'
|
||||
: _feedText(f);
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
||||
];
|
||||
}
|
||||
final event = FeedEvent.of(f,
|
||||
selfName: selfMember.peek()?.name,
|
||||
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
|
||||
notify(event);
|
||||
// The server gave up fetching what everyone waits for.
|
||||
if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
||||
}
|
||||
final selfName = selfMember.peek()?.name;
|
||||
// Sat out by someone else: say who (the feed names members by name).
|
||||
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
|
||||
_pushToast('${f.actor} sat you out', actor: f.actor);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.kick)));
|
||||
}
|
||||
final kind = sfxForFeedAction(f.action);
|
||||
// Your own message needs no sound.
|
||||
final own = f.action == 'chat' && f.actor == selfName;
|
||||
if (kind != null && !own) {
|
||||
var readyIndex = 0;
|
||||
if (f.action == 'ready') {
|
||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||
}
|
||||
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
String _feedText(FeedMessage f) {
|
||||
switch (f.action) {
|
||||
case 'join':
|
||||
return '${f.actor} joined';
|
||||
case 'leave':
|
||||
return '${f.actor} left';
|
||||
case 'swap':
|
||||
return '${f.actor} changed media';
|
||||
default:
|
||||
return '${f.actor} ${f.action}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone poked this member: sound (even when its switch is off; the
|
||||
/// volume still applies), a toast, and the window asks for attention.
|
||||
void _onNudge(NudgeNotice n) {
|
||||
final name = n.name.isEmpty ? 'Someone' : n.name;
|
||||
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
||||
unawaited(requestAttention());
|
||||
}
|
||||
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
@@ -1227,8 +1176,7 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _tick() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
toasts.value =
|
||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
notifier.expire(now);
|
||||
final r = serverState.peek();
|
||||
if (sync.token.isNotEmpty) sync.sendPing();
|
||||
diag
|
||||
@@ -1271,17 +1219,7 @@ class SessionCoordinator {
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
case IdleEvent.warn:
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: sitOutWhenIdle
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
notify(IdleWarning(sitsOut: sitOutWhenIdle));
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
if (sitOutWhenIdle) {
|
||||
@@ -1738,17 +1676,9 @@ class SessionCoordinator {
|
||||
|
||||
// ------------------------------------------------------------- jellyfin
|
||||
|
||||
/// A toast from outside the session (e.g. the Schedule tab's request
|
||||
/// results), shown like the session's own.
|
||||
void showToast(String text) => _pushToast(text);
|
||||
|
||||
void _pushToast(String text, {String? actor}) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
||||
];
|
||||
}
|
||||
/// News for the user, from the session or anywhere in the app (see
|
||||
/// [Notifier]).
|
||||
void notify(AppEvent event) => notifier.notify(event);
|
||||
|
||||
void _resetJellyfin() {
|
||||
_jfGen++;
|
||||
@@ -1928,7 +1858,7 @@ class SessionCoordinator {
|
||||
onError: (e) {
|
||||
if (_jfToastShown) return;
|
||||
_jfToastShown = true;
|
||||
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
|
||||
notify(JellyfinProgressFailed(signInExpired: e is JellyfinException && e.isUnauthorized));
|
||||
},
|
||||
);
|
||||
final snap = await _snapshot();
|
||||
@@ -2153,7 +2083,7 @@ class SessionCoordinator {
|
||||
_everConnected = true;
|
||||
if (_lostSounded) {
|
||||
_lostSounded = false;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
||||
notify(const ConnectionBack());
|
||||
}
|
||||
_resumeToken = sync.token;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
@@ -2195,7 +2125,7 @@ class SessionCoordinator {
|
||||
void _soundLost() {
|
||||
if (!_everConnected || _lostSounded) return;
|
||||
_lostSounded = true;
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
||||
notify(const ConnectionLost());
|
||||
}
|
||||
|
||||
void _invalidateAttempts() {
|
||||
|
||||
@@ -3,9 +3,7 @@ import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../files/folder_store.dart';
|
||||
@@ -14,6 +12,7 @@ import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../session/session_coordinator.dart' show ConnectParams;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
@@ -25,6 +24,7 @@ import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'library_link.dart' show LinkProber, defaultLinkProber;
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
@@ -58,6 +58,9 @@ class AppShell extends StatefulWidget {
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Stand-in for media_kit's `Video` (tests).
|
||||
final VideoViewBuilder? videoBuilder;
|
||||
/// What the Library's Link page asks about a link (tests: a fake, so no
|
||||
/// network or yt-dlp runs).
|
||||
final LinkProber linkProbe;
|
||||
const AppShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
@@ -66,6 +69,7 @@ class AppShell extends StatefulWidget {
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
this.videoBuilder,
|
||||
this.linkProbe = defaultLinkProber,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -329,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
if (!widget.windowControls) return;
|
||||
try {
|
||||
await windowManager.setFullScreen(on);
|
||||
await SessionState.instance.ports.shell.setFullScreen(on);
|
||||
} catch (_) {
|
||||
// No window manager.
|
||||
}
|
||||
@@ -457,17 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||
/// image has no taker yet, so the clipboard's image data is not read.
|
||||
Future<void> _paste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||
final clip = await SessionState.instance.ports.shell.readPaste();
|
||||
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
||||
}
|
||||
|
||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||
@@ -489,7 +484,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||
final error = await _putUp(FileMedia(paths.first));
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||
} else {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addRecent(paths.first);
|
||||
@@ -500,7 +495,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final p in next ? paths.reversed : paths) {
|
||||
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -509,7 +504,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final error = await coord.queue
|
||||
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
||||
if (error != null) coord.showToast(error);
|
||||
if (error != null) coord.notify(ActionFailed(error));
|
||||
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||
await _addFolder(File(paths.first).parent.path);
|
||||
case (HandoffFolder(:final path), _):
|
||||
@@ -540,13 +535,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
|
||||
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||
Widget _dropTarget(Widget child) => DropTarget(
|
||||
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
|
||||
enable: _tab != ShellTab.settings,
|
||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
onEnter: () => setState(() => _dragging = _onTop),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, at) {
|
||||
setState(() => _dragging = false);
|
||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||
_handOff(classifyHandoff(paths: paths), at);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -718,12 +713,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// Up next needs a server (a solo session has one).
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
probe: widget.linkProbe,
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
solo: alone,
|
||||
onPutUp: _putUp,
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onToast: coord.showToast,
|
||||
onToast: (text) => coord.notify(ScheduleResult(text)),
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
section: _section,
|
||||
|
||||
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart'
|
||||
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The Library's Link source: a big paste field, a preview of what the link
|
||||
@@ -236,10 +236,10 @@ class LinkPreviewStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
/// What a link is (see [LinkResolver.probe]).
|
||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||
|
||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
|
||||
|
||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||
|
||||
import '../session/app_events.dart' show ProblemMarked;
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import 'motion.dart';
|
||||
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
|
||||
if (note == null) return;
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final inSession = await coord.markProblem(note);
|
||||
coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log");
|
||||
coord.notify(ProblemMarked(inSession: inSession));
|
||||
}
|
||||
|
||||
/// The small card: a one-line note and Mark. Pops with the note (maybe
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
@@ -263,7 +262,7 @@ class PartyStrip extends StatelessWidget {
|
||||
// their own clicks.
|
||||
if (windowControls)
|
||||
const Positioned.fill(
|
||||
child: DragToMoveArea(child: SizedBox.expand()),
|
||||
child: WindowDragArea(child: SizedBox.expand()),
|
||||
),
|
||||
Positioned.fill(child: row),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
@@ -329,18 +328,17 @@ class _CropPickerState extends State<CropPicker> {
|
||||
}
|
||||
|
||||
Future<void> _pick() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick a picture',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
final path = files.firstOrNull;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/app_events.dart' show ShaderMessage;
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
@@ -92,8 +93,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
// Own toast ids, negative: the coordinator counts up from 1.
|
||||
int _toastSeq = 0;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up (their frame reports it).
|
||||
@@ -134,7 +133,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final n = _coord.playback.shaderNotice.value;
|
||||
if (n == null || n.id == _shaderNoticeSeen) return;
|
||||
_shaderNoticeSeen = n.id;
|
||||
_toast(n.text);
|
||||
_coord.notify(ShaderMessage(n.text));
|
||||
});
|
||||
_stopPopoverWatch = effect(() {
|
||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||
@@ -162,13 +161,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toast(String text) {
|
||||
_coord.toasts.value = [
|
||||
..._coord.toasts.peek(),
|
||||
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
|
||||
];
|
||||
}
|
||||
|
||||
void _toggleChat() => _model.toggleChat();
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
|
||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart'
|
||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||
@@ -12,7 +10,6 @@ import 'package:sync_relay_core/core.dart'
|
||||
import '../controls/player_settings.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
@@ -380,7 +377,7 @@ class RecentServersPanel extends StatelessWidget {
|
||||
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||
/// last error.
|
||||
class YtDlpSettings extends StatefulWidget {
|
||||
/// Defaults to [AppYtDlp.instance]'s.
|
||||
/// Defaults to the app's ([Ports.links]).
|
||||
final Future<YtDlp>? ytDlp;
|
||||
const YtDlpSettings({super.key, this.ytDlp});
|
||||
|
||||
@@ -389,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -472,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||
};
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['txt', 'json'],
|
||||
extensions: const ['txt', 'json'],
|
||||
);
|
||||
} catch (_) {
|
||||
_reload('file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.firstOrNull?.path;
|
||||
final path = files.firstOrNull;
|
||||
if (path != null) await _importFile(path);
|
||||
}
|
||||
|
||||
@@ -535,12 +531,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
return SessionState.instance.ports.shell.dropTarget(
|
||||
onEnter: () => setState(() => _dragging = true),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, _) {
|
||||
setState(() => _dragging = false);
|
||||
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
||||
final path = paths.firstOrNull;
|
||||
if (path != null) _importFile(path);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
|
||||
@@ -11,11 +11,13 @@ import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../platform/desktop/desktop_ports.dart';
|
||||
import '../platform/ports/ports.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../session/health.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
@@ -167,7 +169,11 @@ class SessionState {
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||
final Ports ports = desktopPorts();
|
||||
/// Every toast, sound and activity-log line goes through it.
|
||||
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(notifier: notifier, ytDlp: ports.links);
|
||||
final lastForm = ConnectForm();
|
||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||
/// Set when the hosted server died mid-session; Home shows it on the
|
||||
@@ -188,7 +194,7 @@ class SessionState {
|
||||
/// default) when the app-support folder is not available.
|
||||
static Future<String> _sharedYtDlpDir() async {
|
||||
try {
|
||||
return (await AppYtDlp.instance.ytDlp).dir;
|
||||
return (await instance.ports.links.ytDlp).dir;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
@@ -285,7 +291,7 @@ class SessionState {
|
||||
try {
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
coordinator.showToast("couldn't put it up again: $e");
|
||||
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
||||
}
|
||||
} finally {
|
||||
switching.value = false;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
|
||||
import '../state/session_state.dart';
|
||||
|
||||
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
||||
@@ -22,28 +21,10 @@ abstract final class ReuseText {
|
||||
@visibleForTesting
|
||||
void Function(String text)? debugReuseToast;
|
||||
|
||||
void _toast(String text) {
|
||||
void _notify(AppEvent event) {
|
||||
final debug = debugReuseToast;
|
||||
if (debug != null) return debug(text);
|
||||
SessionState.instance.coordinator.showToast(text);
|
||||
}
|
||||
|
||||
/// Opens [url] in the default browser (no url_launcher dependency); false
|
||||
/// when no browser could be started.
|
||||
Future<bool> openInBrowser(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (debug != null) return debug(noticeFor(event).toast ?? '');
|
||||
SessionState.instance.coordinator.notify(event);
|
||||
}
|
||||
|
||||
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
|
||||
@@ -52,15 +33,15 @@ Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} catch (_) {
|
||||
_toast("Couldn't copy");
|
||||
_notify(const CopyFailed());
|
||||
return;
|
||||
}
|
||||
_toast(done);
|
||||
_notify(Copied(done));
|
||||
}
|
||||
|
||||
/// Opens [url] in the browser; when none opens, copies it instead and
|
||||
/// says so.
|
||||
Future<void> openLink(String url) async {
|
||||
if (await openInBrowser(url)) return;
|
||||
if (await SessionState.instance.ports.urls.open(url)) return;
|
||||
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
|
||||
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
class _WindowButtonsState extends State<WindowButtons> {
|
||||
final _shell = SessionState.instance.ports.shell;
|
||||
bool _maximized = false;
|
||||
late final void Function() _stopWatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
windowManager.addListener(this);
|
||||
windowManager.isMaximized().then((v) {
|
||||
_stopWatch = _shell.watchMaximized((v) {
|
||||
if (mounted) setState(() => _maximized = v);
|
||||
});
|
||||
_shell.isMaximized().then((v) {
|
||||
if (mounted) setState(() => _maximized = v);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
_stopWatch();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
if (mounted) setState(() => _maximized = true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
if (mounted) setState(() => _maximized = false);
|
||||
}
|
||||
|
||||
Future<void> _toggleMaximize() async {
|
||||
if (widget.onToggleFullscreen != null) {
|
||||
widget.onToggleFullscreen!();
|
||||
return;
|
||||
}
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
if (await _shell.isMaximized()) {
|
||||
await _shell.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
await _shell.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +56,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
await SessionState.instance.coordinator.leave();
|
||||
await SessionState.instance.stopServer();
|
||||
} catch (_) {}
|
||||
await windowManager.close();
|
||||
await _shell.requestClose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Minimize',
|
||||
focusNode: focus,
|
||||
onPressed: () => windowManager.minimize(),
|
||||
onPressed: () => _shell.minimize(),
|
||||
),
|
||||
_WinBtn(
|
||||
icon: restored ? Icons.filter_none : Icons.crop_square,
|
||||
@@ -131,3 +124,27 @@ class _WinBtn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The title area: drag moves the window, a double click maximizes or
|
||||
/// restores it (window_manager's DragToMoveArea, through [ShellPort]).
|
||||
class WindowDragArea extends StatelessWidget {
|
||||
final Widget child;
|
||||
const WindowDragArea({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shell = SessionState.instance.ports.shell;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (_) => shell.startDragging(),
|
||||
onDoubleTap: () async {
|
||||
if (await shell.isMaximized()) {
|
||||
await shell.unmaximize();
|
||||
} else {
|
||||
await shell.maximize();
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
|
||||
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
|
||||
// part of the one computed status (and the source-gone half of P2).
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 4 (Library + Media identity): series
|
||||
// grouping on the page follows L1/L2.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
|
||||
// becomes L1 local grouping; the scan and classify tests stay.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 3 (Your server): hosted recents and the host
|
||||
// card; the rest stays.
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
|
||||
// checks; the guest-through-the-relay flow stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 5 (What's next + Watch tracking): the
|
||||
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -364,11 +366,6 @@ void main() {
|
||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||
});
|
||||
|
||||
test('the floating prompt starts two minutes before the end', () {
|
||||
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
|
||||
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
|
||||
});
|
||||
|
||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||
final members = [
|
||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 3 (Your server): the "solo" and "hosting"
|
||||
// tests (D9: no solo mode); the rest stays.
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/widgets/media_input.dart';
|
||||
|
||||
void main() {
|
||||
test('http url routes to linkUrl', () {
|
||||
expect(parseMediaInput('http://example.com/v.mp4'),
|
||||
(mediaPath: '', linkUrl: 'http://example.com/v.mp4'));
|
||||
});
|
||||
|
||||
test('https url routes to linkUrl', () {
|
||||
expect(parseMediaInput('https://example.com/v.mp4'),
|
||||
(mediaPath: '', linkUrl: 'https://example.com/v.mp4'));
|
||||
});
|
||||
|
||||
test('scheme match is case-insensitive and trimmed', () {
|
||||
expect(parseMediaInput(' HTTPS://example.com/v.mp4 '),
|
||||
(mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4'));
|
||||
});
|
||||
|
||||
test('file path routes to mediaPath', () {
|
||||
expect(parseMediaInput('/home/u/Videos/chiyo.mp4'),
|
||||
(mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: ''));
|
||||
});
|
||||
|
||||
test('empty input stays empty', () {
|
||||
expect(parseMediaInput(' '), (mediaPath: '', linkUrl: ''));
|
||||
});
|
||||
|
||||
test('non-http scheme stays a path', () {
|
||||
expect(parseMediaInput('ftp://example.com/v.mp4'),
|
||||
(mediaPath: 'ftp://example.com/v.mp4', linkUrl: ''));
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 3 (Your server): invite addresses (P24) and
|
||||
// the hosting invite; the settings cards stay.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
|
||||
// (one computed level); the title block and overflow checks stay.
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -165,16 +167,6 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a new media fades through', (tester) async {
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Frieren'), findsNothing);
|
||||
expect(find.text('Dune'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 5 (What's next): who performs an item and
|
||||
// the janitor become server state.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/queue/queue_logic.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
|
||||
// relay become one pipeline; keep the timing per source.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
|
||||
// source drops uncleanly and resumes" (resume token, D6 source resume); the
|
||||
// rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -65,6 +68,7 @@ Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
late List<SessionCoordinator> all;
|
||||
late List<Object> serverErrors;
|
||||
|
||||
Future<SessionCoordinator> member(String name) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
@@ -102,7 +106,15 @@ void main() {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||
await server.start();
|
||||
// Relic reports an error writing a response (a relay read cut short
|
||||
// when its relay is dropped at a swap or at the stop in tearDown) to
|
||||
// the zone the server started in, as it logs it outside tests;
|
||||
// anything else fails the test.
|
||||
serverErrors = [];
|
||||
await runZonedGuarded(server.start, (e, _) {
|
||||
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||
serverErrors.add(e);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -110,6 +122,7 @@ void main() {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
expect(serverErrors, isEmpty, reason: 'server errors');
|
||||
});
|
||||
|
||||
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
|
||||
@@ -127,8 +140,9 @@ void main() {
|
||||
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||
expect(relay.sourceToken, bob.sync.token);
|
||||
|
||||
// The next swap retires bob's source and the buffer.
|
||||
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
|
||||
// The next swap retires bob's source and the buffer. Nothing answers
|
||||
// at that link, so its probe refuses it unless put up anyway.
|
||||
expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull);
|
||||
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
@@ -155,7 +169,8 @@ void main() {
|
||||
await playTogether([alice, bob]);
|
||||
expect(hits.length, greaterThan(before));
|
||||
|
||||
// A page yt-dlp cannot read is refused up front.
|
||||
// A page is refused up front when this computer cannot read it (here
|
||||
// yt-dlp is off, and the refusal says so).
|
||||
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
page.listen((r) {
|
||||
r.response.headers.contentType = ContentType.html;
|
||||
@@ -163,7 +178,7 @@ void main() {
|
||||
r.response.close();
|
||||
});
|
||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||
contains('Not a video'));
|
||||
'yt-dlp is turned off');
|
||||
await page.close(force: true);
|
||||
} finally {
|
||||
await origin.close(force: true);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 3 (Your server): "solo: the button says
|
||||
// Play"; the rest stays.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 3 (Your server): one hub with listeners
|
||||
// attached, nothing restarts, no solo mode.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
|
||||
// linkDisplayName stays.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/session/idle.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status
|
||||
// pill; in step 3 (Your server): the solo strip and Invite friends.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -79,6 +81,8 @@ void main() {
|
||||
autoUpdateCheck: false,
|
||||
windowControls: false,
|
||||
videoBuilder: (context, controls) => const FakeVideo(),
|
||||
// A real probe (network, yt-dlp) never answers on the test clock.
|
||||
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -192,18 +196,6 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('tabs: the underline moves to the selected one', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
double underline(String tab) => tester
|
||||
.getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer)))
|
||||
.width;
|
||||
expect(underline('library'), 22);
|
||||
expect(underline('settings'), 0);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(underline('library'), 0);
|
||||
expect(underline('settings'), 22);
|
||||
});
|
||||
});
|
||||
|
||||
group('popovers', () {
|
||||
@@ -418,16 +410,4 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Shaders off'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('status avatar fills its box at any size', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(
|
||||
home: Center(
|
||||
child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88),
|
||||
),
|
||||
));
|
||||
final circle = tester.getSize(find.byType(CircleAvatar));
|
||||
// Ring and gap (2 x 4 px each side at this size) inside the 88 px box.
|
||||
expect(circle.width, 88 - 4 * 4);
|
||||
expect(find.text('Z'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// restructure: rewrite in step 6 (Media pipeline + player host): the
|
||||
// fresh player per video.
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Reference in new issue
Block a user