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:
foxyzandClaude Opus 5.5 committed 2026-10-15 19:24:11 +03:00
1 parent 7e98ebd1db
commit a1b270fd4e
63 files changed
+1231 -434

No files matched your search

+19 -23
View File
@@ -3,9 +3,7 @@ import 'dart:io' show File;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../controls/frame.dart' show DoubleClickListener;
import '../files/folder_store.dart';
@@ -14,6 +12,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
@@ -25,6 +24,7 @@ import '../update/update_controller.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'handoff.dart';
import 'library_link.dart' show LinkProber, defaultLinkProber;
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
@@ -58,6 +58,9 @@ class AppShell extends StatefulWidget {
final JellyfinAccounts? jellyfin;
/// Stand-in for media_kit's `Video` (tests).
final VideoViewBuilder? videoBuilder;
/// What the Library's Link page asks about a link (tests: a fake, so no
/// network or yt-dlp runs).
final LinkProber linkProbe;
const AppShell({
super.key,
this.updates,
@@ -66,6 +69,7 @@ class AppShell extends StatefulWidget {
this.healthGet = defaultHealthGet,
this.jellyfin,
this.videoBuilder,
this.linkProbe = defaultLinkProber,
});
@override
@@ -329,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return;
try {
await windowManager.setFullScreen(on);
await SessionState.instance.ports.shell.setFullScreen(on);
} catch (_) {
// No window manager.
}
@@ -457,17 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Clipboard files first, then text (a link or absolute paths). A pasted
/// image has no taker yet, so the clipboard's image data is not read.
Future<void> _paste() async {
var files = const <String>[];
String? text;
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
final clip = await SessionState.instance.ports.shell.readPaste();
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
}
Future<void> _handOff(Handoff item, Offset? at) async {
@@ -489,7 +484,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
case (HandoffVideos(:final paths), HandoffAction.putUp):
final error = await _putUp(FileMedia(paths.first));
if (error != null) {
SessionState.instance.coordinator.showToast(error);
SessionState.instance.coordinator.notify(ActionFailed(error));
} else {
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
@@ -500,7 +495,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.showToast(error);
SessionState.instance.coordinator.notify(ActionFailed(error));
break;
}
}
@@ -509,7 +504,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final coord = SessionState.instance.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.showToast(error);
if (error != null) coord.notify(ActionFailed(error));
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
@@ -540,13 +535,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
// Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => DropTarget(
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
enable: _tab != ShellTab.settings,
onDragEntered: (_) => setState(() => _dragging = _onTop),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
onEnter: () => setState(() => _dragging = _onTop),
onExit: () => setState(() => _dragging = false),
onDrop: (paths, at) {
setState(() => _dragging = false);
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
_handOff(classifyHandoff(paths: paths), at);
},
child: Stack(
fit: StackFit.expand,
@@ -718,12 +713,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Up next needs a server (a solo session has one).
onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
probe: widget.linkProbe,
),
ShellTab.schedule => SchedulePage(
solo: alone,
onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast,
onToast: (text) => coord.notify(ScheduleResult(text)),
),
ShellTab.settings => SettingsTab(
section: _section,
+3 -3
View File
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link
@@ -236,10 +236,10 @@ class LinkPreviewStore {
}
}
/// What a link is (see [AppYtDlp.probe]).
/// What a link is (see [LinkResolver.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url);
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../session/app_events.dart' show ProblemMarked;
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart';
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
if (note == null) return;
final coord = SessionState.instance.coordinator;
final inSession = await coord.markProblem(note);
coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log");
coord.notify(ProblemMarked(inSession: inSession));
}
/// The small card: a one-line note and Mark. Pops with the note (maybe
+1 -2
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart';
@@ -263,7 +262,7 @@ class PartyStrip extends StatelessWidget {
// their own clicks.
if (windowControls)
const Positioned.fill(
child: DragToMoveArea(child: SizedBox.expand()),
child: WindowDragArea(child: SizedBox.expand()),
),
Positioned.fill(child: row),
],
+4 -6
View File
@@ -3,7 +3,6 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
@@ -329,18 +328,17 @@ class _CropPickerState extends State<CropPicker> {
}
Future<void> _pick() async {
final List<PlatformFile> files;
final List<String> files;
try {
files = await FilePicker.pickFiles(
files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a picture',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
);
} catch (e) {
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
return;
}
final path = files.isEmpty ? null : files.first.path;
final path = files.firstOrNull;
if (path == null) return;
try {
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
+2 -10
View File
@@ -15,6 +15,7 @@ import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart';
import '../session/app_events.dart' show ShaderMessage;
import '../session/session_coordinator.dart';
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
@@ -92,8 +93,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch;
// Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0;
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the controls are up (their frame reports it).
@@ -134,7 +133,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
final n = _coord.playback.shaderNotice.value;
if (n == null || n.id == _shaderNoticeSeen) return;
_shaderNoticeSeen = n.id;
_toast(n.text);
_coord.notify(ShaderMessage(n.text));
});
_stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
@@ -162,13 +161,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
super.dispose();
}
void _toast(String text) {
_coord.toasts.value = [
..._coord.toasts.peek(),
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
];
}
void _toggleChat() => _model.toggleChat();
/// The player keys, while the player fills the body and nothing (a
+11 -15
View File
@@ -2,9 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
@@ -12,7 +10,6 @@ import 'package:sync_relay_core/core.dart'
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
@@ -380,7 +377,7 @@ class RecentServersPanel extends StatelessWidget {
/// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error.
class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s.
/// Defaults to the app's ([Ports.links]).
final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@@ -389,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
@override
Widget build(BuildContext context) {
@@ -472,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
};
Future<void> _pickFile() async {
final List<PlatformFile> files;
final List<String> files;
try {
files = await FilePicker.pickFiles(
files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom,
allowedExtensions: const ['txt', 'json'],
extensions: const ['txt', 'json'],
);
} catch (_) {
_reload('file dialog unavailable — try again');
return;
}
final path = files.firstOrNull?.path;
final path = files.firstOrNull;
if (path != null) await _importFile(path);
}
@@ -535,12 +531,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
return SessionState.instance.ports.shell.dropTarget(
onEnter: () => setState(() => _dragging = true),
onExit: () => setState(() => _dragging = false),
onDrop: (paths, _) {
setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
final path = paths.firstOrNull;
if (path != null) _importFile(path);
},
child: AnimatedContainer(