Restructure step 3: your server, Invite, version gate, AppGraph
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s

- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:52:52 +03:00
1 parent 629be0d9ed
commit 4d00b7455e
86 files changed
+2993 -2071

No files matched your search

+56 -41
View File
@@ -60,36 +60,37 @@ telling you to update.
## Getting started
Three ways in, all at the top of the Library when you are not in a server.
None of them needs Jellyfin or an account.
The app always runs its own server, quietly: your server. When it starts
you are on it, alone, and it is a plain player. Three ways in, at the top
of the Library the first time; none needs Jellyfin or an account.
- **Join a friend** (most people): your friend sends you an address and,
maybe, a password. See [Join](#join).
- **Host**: you have the video and friends watch it with you. See
[Host from the app](#host-from-the-app).
- **Watch solo**: just play something from the Library. It plays for you
alone, with everything a server has (Jellyfin progress, next episode,
skip buttons); nothing is reachable from outside this computer. Invite
friends in the strip turns it into a hosted server (the Host card's port
and password) and the video carries on from where it was; Stop ends it.
- **Join a friend** (most people): your friend sends you an invite (an
address) and, maybe, a password. See [Join](#join).
- **Invite friends**: you have the video and friends watch it with you.
See [Your server and Invite](#your-server-and-invite).
- **Watch on your own**: just play something from the Library, with
everything a server has (Jellyfin progress, next episode, skip buttons).
Nobody else can reach your server until you invite them; inviting never
interrupts what plays.
## The window
One window. Along the top, the strip: your avatar (click it for your
profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Offline
the party side has Join a friend and Host; watching solo it says Solo,
with Invite friends and Stop; in a server it shows everyone's avatar
settings), the Library / Schedule / Settings tabs, and the party. Alone
on your server it has just Join a friend and Invite (and "Sharing" while
your server is open to friends); with others there it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready,
a little faded joining, dimmed reconnecting, dimmed with a moon sitting
out; a crown on whoever
hosts, a tower on whoever passes the video on). Click the avatars for the
member list (each member's app version, amber when it differs from yours,
and the server's at the top; force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
network addresses with the port, and friends outside your network need
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or
and the server's at the top; force ready, Nudge, Sit out; on your
server the invite with Copy invite and Stop sharing, on a friend's "Copy
invite address"). Beside them the server and "ready n/m" (who is sitting out or
reconnecting does not count), the chat button with unread
count, and Leave. Empty strip space drags the window.
count, and Invite on your server or Leave on a friend's. Empty strip
space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to
a mini player in a corner: drag it anywhere and it snaps to the nearest
@@ -160,19 +161,28 @@ same profile) takes your place over, and the first one says so and stops.
only they hear it (a sound, a pop-up, and the taskbar entry asks for
attention when the window is in the background).
## Host from the app
## Your server and Invite
Host in the strip: set a port and an optional password (blank = anyone can
join), then Host. The app starts a server inside itself and joins it; you
are the host (the crown). Nothing is up yet: pick what to
watch in the Library tab (below) and put it up. Leaving or
closing the app stops the server. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open
nothing. Ports you hosted on before are one click away in the Host card.
Your server starts with the app and stops when it closes. Only this app
can reach it until you press **Invite** (in the strip, or in the member
list): that opens it on your network at the port from Settings → Party &
network (default 8080, with the password set there; blank = anyone can
join) and copies an invite ("Join me on Starsync: 192.168.1.20:8080 ·
Starsync 1.3.0") to send to friends. Nothing restarts: what plays and Up
next carry on, and you are the host (the crown). The invite picks this
computer's home-network address (not a VPN or proxy one, nor a Docker or
VM bridge). Friends elsewhere need the TCP port forwarded on your router
(or a VPN address, or a server they can all reach, like a VPS). Friends
open nothing. **Stop sharing** (in the member list) closes it again:
everyone else leaves (their app goes back to their own server and says
why), you and Up next stay.
If you put up a local file, the server streams it to everyone from your
If you put up a local file, your server streams it to everyone from your
disk. Nothing is uploaded or stored anywhere.
Joining a friend leaves your server for theirs; it stays up meanwhile,
and leaving theirs brings you back to it as you left it.
## Library
What to watch, from three sources (the switch at the top). Drop a video,
@@ -209,12 +219,12 @@ Up next, add the folder to Files, or fill Link).
- **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you.
Put it up and the player opens. Not in a server, the buttons say Play
and it plays for you alone ([Watch solo](#getting-started)).
Put it up and the player opens. With nobody else in the server, the
buttons say Play and a link plays straight from the site.
### Up next (the queue)
In a server (solo too), anything can wait its turn instead of going up
Anything can wait its turn instead of going up
now: **Add to queue** on the Link page (in the way you picked), or
right-click a Jellyfin card or episode, a video in Files, a recent link
or an episode on the Schedule for **Add to queue** / **Play next**; the
@@ -302,8 +312,8 @@ clipboard: paste it to whoever helps you, from both sides if you can.
While you are in a server, the app also sends its log to that server, which
merges everyone's lines and its own into one session log
(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps
it in its own logs folder), so a problem can be read afterwards from every
(`session-YYYY-MM-DD.log` next to the server's logs; your own server
keeps it in the app's logs folder), so a problem can be read afterwards from every
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
the player's ⚙ and Settings → About) and add a few words if you like: a
@@ -315,18 +325,22 @@ notes that you don't share; a problem you mark still goes in).
## Join
Join in the strip. Servers you got into are listed first (up to 6), with
live status (online and how many are watching, or offline, plus "needs
update" when the server runs another protocol): click one to rejoin, x to
forget it. Or enter the host's address, port, and the password if there
is one, then Enter or Join. Your name and picture come from your profile
(your avatar at the top left).
Join a friend in the strip. Servers you got into are listed first (up
to 6), with live status (online and how many are watching, or offline,
plus "needs update" when its version and yours do not match): click one
to rejoin, x to forget it. Or paste the invite (or the address and port)
and the password if there is one, then Enter or Join. Before connecting
the app checks the server's version: when they do not match it says who
has to update ("Bob's server runs Starsync 1.2.1 — they need to
update"), and offers the update when it is you. Your name and picture
come from your profile (your avatar at the top left). Leave goes back to
your own server.
## Settings
The Settings tab (Ctrl+,) has a page per topic, each setting with a line
of help: Profile; Party & network (the Host card's default port and
password, recent servers with remove); Player (below); Jellyfin &
of help: Profile; Party & network (your server's port and password for
Invite, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up, chat
@@ -412,7 +426,8 @@ during the first 6 minutes.
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
| Local file streaming | same port | path `/m/...`, needs a session token |
Only the host needs the port reachable.
Only the one who invites needs the port reachable; until Invite your
server listens on 127.0.0.1 and a free port only.
## Jellyfin
+17 -12
View File
@@ -7,13 +7,13 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/app/app_graph.dart';
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.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 UpdateController, UpdatePhase, launchArgs;
@@ -46,20 +46,22 @@ Future<void> main(List<String> args) async {
await startAppLogging(dirs);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded();
// The composition root: every system, made once.
final graph = AppGraph.build();
final ports = graph.ports;
// 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;
// see "left", your server shuts down and no player outlives the engine.
final close = closeAppWith(graph);
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: () => close());
// Focus, minimise and restore feed auto sit-out and the Notifier (in
// the background, notices go out as system notifications).
ports.shell.watchActivity((a) {
SessionState.instance.coordinator.windowActivity(a);
SessionState.instance.notifier.window = a;
graph.coordinator.windowActivity(a);
graph.notifier.window = a;
});
try {
await SessionState.instance.loadForm();
await graph.forms.load();
} catch (e, st) {
debugPrint('load connect form failed: $e\n$st');
}
@@ -74,10 +76,13 @@ Future<void> main(List<String> args) async {
final s = updates.status.value;
final version = s.manifest?.version;
if (s.phase == UpdatePhase.available && version != null) {
SessionState.instance.notifier.notify(UpdateAvailable(version));
graph.notifier.notify(UpdateAvailable(version));
}
});
runApp(const WatchTogetherApp());
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
// Home: your own server, quietly (only this app can reach it until
// Invite).
unawaited(graph.navigation.start());
// The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
Future.delayed(const Duration(seconds: 2), graph.sfx.warmUp);
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart';
import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart';
import '../platform/ports/ports.dart';
import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile;
import '../session/navigation.dart';
import '../session/notifier.dart';
import '../session/session_coordinator.dart';
import '../session/your_server.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart';
import '../state/settings.dart';
/// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no
/// signals of its own. Widgets reach it through [AppScope].
class AppGraph {
/// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports;
/// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx;
/// Every toast, sound, system notification and activity-log line.
final Notifier notifier;
/// The link to whichever server this app is in.
final Connection connection;
final SessionCoordinator coordinator;
/// The connect form and recent servers.
final ConnectStore forms;
/// This app's own server, alive for the whole run.
final YourServer yourServer;
/// Home (your server) or a friend's.
final Navigation navigation;
AppGraph._({
required this.ports,
required this.sfx,
required this.notifier,
required this.connection,
required this.coordinator,
required this.forms,
required this.yourServer,
required this.navigation,
});
/// The desktop app's graph. Tests pass their own [ports], [identity]
/// (default: the install's stored one), [healthGet] and server starter.
factory AppGraph.build({
Ports? ports,
ClientIdentity? identity,
HealthGetter healthGet = defaultHealthGet,
ServerStarter? serverStarter,
JellyfinAccounts? jellyfin,
}) {
final p = ports ?? desktopPorts();
final sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
);
final notifier = Notifier(
sfx: sfx,
attention: p.shell.requestAttention,
system: p.notifications,
focusWindow: p.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
);
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
final coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
final forms = ConnectStore();
final yourServer = YourServer(
forms: forms,
jellyfin: jellyfin,
// The app's yt-dlp folder; '' (the server's own default) when the
// app-support folder is not available.
ytDlpDir: () async {
try {
return (await p.links.ytDlp).dir;
} catch (_) {
return '';
}
},
logDir: () => appLogDir,
starter: serverStarter,
);
final navigation = Navigation(
coordinator: coordinator,
yourServer: yourServer,
forms: forms,
healthGet: healthGet,
);
// The profile keeps its name in the connect form and pushes changes
// to whichever server this app is in.
Profile.instance
..forms = forms
..push = ({String? name, String? avatar}) {
if (navigation.online.peek()) coordinator.updateProfile(name: name, avatar: avatar);
};
return AppGraph._(
ports: p,
sfx: sfx,
notifier: notifier,
connection: connection,
coordinator: coordinator,
forms: forms,
yourServer: yourServer,
navigation: navigation,
);
}
Future<void> dispose() async {
navigation.dispose();
await coordinator.dispose();
await yourServer.stop();
await sfx.dispose();
}
}
/// The sound set [kind] plays from: its own pick in Settings, else the
/// overall one.
SoundPreset soundPresetFor(SfxKind kind) {
final s = AppSettings.instance;
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
}
/// Hands the [AppGraph] to every widget below: `AppScope.of(context)`.
/// The graph never changes, so reading it registers no dependency (safe
/// in `initState`).
class AppScope extends InheritedWidget {
final AppGraph graph;
const AppScope({super.key, required this.graph, required super.child});
static AppGraph of(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'no AppScope above this widget');
return scope!.graph;
}
@override
bool updateShouldNotify(AppScope oldWidget) => !identical(graph, oldWidget.graph);
}
@@ -121,7 +121,7 @@ Future<void> runControlAction(BuildContext context, PlayerControlsModel model, C
model.changeVideo();
case ControlAction.copyLink:
final link = model.link();
if (link != null) await copyToClipboard(link, done: 'Link copied');
if (link != null) await copyToClipboard(context, link, done: 'Link copied');
case ControlAction.reload:
model.reload();
case ControlAction.markAllReady:
@@ -113,7 +113,7 @@ class ControlsSnapshot {
final bool hasDuration;
/// Watching alone (no one to mark ready).
final bool solo;
final bool alone;
/// A link to copy (`SessionCoordinator.currentLink`).
final bool hasLink;
@@ -126,7 +126,7 @@ class ControlsSnapshot {
this.nextIsUpNext = false,
this.hasChapters = false,
this.hasDuration = false,
this.solo = false,
this.alone = false,
this.hasLink = false,
this.fullscreen = false,
this.chatOpen = false,
@@ -167,7 +167,7 @@ List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden)
ControlAction.skipForward => s.hasDuration,
ControlAction.chat => hidden.contains(OptionalControl.chat),
ControlAction.copyLink => s.hasLink,
ControlAction.markAllReady => !s.solo,
ControlAction.markAllReady => !s.alone,
_ => true,
})
a,
@@ -208,7 +208,7 @@ String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => 'Pause for everyone (Space)',
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
PlayState.paused => s.alone ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
PlayState.finished => 'Ready (Space)',
},
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
@@ -7,7 +7,6 @@ import '../../playback/playback_controller.dart';
import '../../queue/queue_controller.dart';
import '../../session/session_coordinator.dart';
import '../../shaders/shader_presets.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../player_settings.dart' show applyShaderChoice;
import '../volume.dart' as vol;
@@ -91,7 +90,7 @@ class PlayerControlsModel {
nextIsUpNext: items.isNotEmpty,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
solo: SessionState.instance.solo.value,
alone: coord.alone.value,
// Not a signal: read again whenever the state changes.
hasLink: coord.serverState.value != null && coord.currentLink() != null,
fullscreen: fullscreen.value,
@@ -184,7 +183,7 @@ class PlayerControlsModel {
/// Saved as the choice and applied live; a null quality keeps the
/// saved one.
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(coord, preset, quality);
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
@@ -6,8 +6,9 @@ import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../shell/mark_problem.dart';
import '../shell/motion.dart';
import '../state/settings.dart';
@@ -200,11 +201,10 @@ class ProgressLineSetting extends StatelessWidget {
/// Saves a shader choice (also used on the next join) and puts it on the
/// running player, if any; the player announces the result.
void applyShaderChoice(ShaderPreset preset, ShaderQuality? quality) {
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
final coord = SessionState.instance.coordinator;
coord.markInput();
coord.playback.applyShaders(announce: true);
}
@@ -242,7 +242,7 @@ class ShaderPresetSetting extends StatelessWidget {
for (final p in ShaderPreset.values)
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
],
onChanged: (p) => applyShaderChoice(p, null),
onChanged: (p) => applyShaderChoice(AppScope.of(context).coordinator, p, null),
),
if (preset.isAnime4k) ...[
const SizedBox(width: 8),
@@ -250,7 +250,7 @@ class ShaderPresetSetting extends StatelessWidget {
key: const ValueKey('shader-quality'),
value: quality,
items: [for (final q in ShaderQuality.values) (q, q.label)],
onChanged: (q) => applyShaderChoice(preset, q),
onChanged: (q) => applyShaderChoice(AppScope.of(context).coordinator, preset, q),
),
],
],
@@ -265,9 +265,8 @@ class ShaderPresetSetting extends StatelessWidget {
/// Puts the hwdec setting on the running player (mpv switches decoders
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
Future<void> applyHwdecLive() async {
Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
final playback = SessionState.instance.coordinator.playback;
if (!playback.alive.peek()) return;
try {
final dynamic platform = playback.player.platform;
@@ -308,7 +307,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
onChanged: (m) {
_s.setHwdecMode(m);
applyHwdecLive();
applyHwdecLive(AppScope.of(context).coordinator.playback);
},
),
below: mode != HwdecMode.custom
@@ -324,7 +323,7 @@ class _HwdecSettingState extends State<HwdecSetting> {
border: OutlineInputBorder(),
),
onChanged: _s.setHwdecCustom,
onSubmitted: (_) => applyHwdecLive(),
onSubmitted: (_) => applyHwdecLive(AppScope.of(context).coordinator.playback),
),
),
);
@@ -342,7 +341,7 @@ class TrackSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
return SignalBuilder(builder: (context) {
final list = [
for (final t in coord.playback.tracks.value)
+11 -8
View File
@@ -6,7 +6,7 @@ import 'package:signals/signals_flutter.dart';
import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart';
import '../../state/session_state.dart';
import '../../app/app_graph.dart';
import '../../widgets/app_menu.dart';
import '../folder_store.dart';
import '../name_parser.dart';
@@ -33,7 +33,6 @@ 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.
@@ -52,8 +51,8 @@ void resetFilesPageSession() {
}
class FilesPage extends StatefulWidget {
/// Nobody else watches (offline or solo): no relay notice.
final bool solo;
/// Nobody else is in the server: no relay notice.
final bool alone;
/// The file will stream from this computer through the server.
final bool relayed;
/// Plays [path] in the session; returns an error text, or null.
@@ -73,7 +72,7 @@ class FilesPage extends StatefulWidget {
const FilesPage({
super.key,
this.solo = false,
this.alone = false,
required this.relayed,
required this.onPlay,
this.onAddToQueue,
@@ -112,6 +111,8 @@ class _Library {
final _libraries = Expando<_Library>();
class _FilesPageState extends State<FilesPage> {
FolderAccess get _folders => AppScope.of(context).ports.folders;
late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance;
@@ -198,7 +199,7 @@ class _FilesPageState extends State<FilesPage> {
if (path != null && mounted) await _play(path);
}
static Future<String?> _pickVideo() async {
Future<String?> _pickVideo() async {
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
return files.firstOrNull;
}
@@ -247,7 +248,7 @@ class _FilesPageState extends State<FilesPage> {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
@@ -375,7 +376,7 @@ class _FilesPageState extends State<FilesPage> {
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (!widget.solo && widget.relayed) const _RelayNotice(),
if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
@@ -695,6 +696,8 @@ class _RecentRow extends StatefulWidget {
}
class _RecentRowState extends State<_RecentRow> {
FolderAccess get _folders => AppScope.of(context).ports.folders;
FileStat? _stat;
@override
@@ -339,9 +339,9 @@ class BrowseScope extends InheritedWidget {
case 2:
queue!(item, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(item), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
if (context.mounted) await openLink(context, jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
}
}
@@ -35,15 +35,15 @@ typedef _Data = ({
/// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
/// [alone]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller;
final JellyfinSelection selection;
/// Nobody else watches (offline or solo): the button says Play.
final bool solo;
/// Nobody else is in the server: the button says Play.
final bool alone;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
/// Adds [item] to Up next (with next: right after what plays); null: no
@@ -57,7 +57,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
required this.onPutUp,
this.onAddToQueue,
required this.onClose,
this.solo = false,
this.alone = false,
});
@override
@@ -156,7 +156,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
final client = _client;
final pick = await showAppMenu<int>(context, items: [
if (queue) ...[
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
],
@@ -171,9 +171,9 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
case 2:
await _queue(e, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
if (context.mounted) await copyToClipboard(context, jellyfinCopyTitle(e), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
if (context.mounted) await openLink(context, jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
}
}
@@ -308,7 +308,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched',
].join(' · ');
final verb = widget.solo ? 'Play' : 'Put it up';
final verb = widget.alone ? 'Play' : 'Put it up';
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
@@ -397,7 +397,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
IconButton(
key: const ValueKey('details-open-jellyfin'),
tooltip: ReuseText.openInJellyfin,
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
onPressed: () => openLink(context, jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
icon: const Icon(Icons.open_in_browser_rounded),
),
],
+5 -5
View File
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'ports/app_dirs.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
@@ -50,19 +50,19 @@ Future<void> startAppLogging(AppDirs dirs) async {
/// the app log (its hosted server's records included), already redacted;
/// with [sessionLog], the server's session log of the same hours after it
/// (every member's shared lines and the server's, merged).
Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
final b = StringBuffer()
..writeln('$appName ${displayVersion()}')
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln('Dart: ${Platform.version.split(' ').first}');
try {
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
b.writeln('mpv: ${mpv ?? 'no player yet'}');
} catch (_) {
b.writeln('mpv: unknown');
}
try {
final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
final y = await graph.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'}');
@@ -74,7 +74,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
b.writeln('--- last ${since.inHours} h of the log ---');
b.write(files?.recent(since) ?? '');
if (sessionLog) {
final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours);
final server = await graph.coordinator.sessionLog(hours: since.inHours);
b
..writeln()
..writeln("--- last ${since.inHours} h of the server's session log ---")
+21 -11
View File
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../state/session_state.dart';
import '../app/app_graph.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
@@ -18,33 +18,43 @@ bool _shuttingDown = false;
/// True once [shutDownApp] started.
bool get shuttingDown => _shuttingDown;
/// The systems the close stops; `main` hands them over once.
AppGraph? _graph;
/// Makes [graph] the one [shutDownApp] stops and returns the close
/// request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph) {
_graph = graph;
return shutDownApp;
}
/// Hides the window, releases every player and the sound device, leaves
/// (the `bye` goes out first, so others see "left" at once; a hosted
/// (the `bye` goes out first, so others see "left" at once; then your
/// server stops), runs [beforeQuit], then closes the window. [restart]
/// (Update and restart) leaves without the `bye`: the member stays
/// `gone`, and the restarted app, same client id, takes it back within
/// the grace. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
if (_shuttingDown) return;
final graph = _graph;
if (_shuttingDown || graph == null) return;
_shuttingDown = true;
final session = SessionState.instance;
final shell = session.ports.shell;
final shell = graph.ports.shell;
final total = Stopwatch()..start();
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('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
]);
// Windows: media_kit_video frees a player's video output on a thread of
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
// hosted server: 3 s in all.
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
// Jellyfin stop report, last diagnostics, the bye (flushed), then your
// server: 3 s in all.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', graph.yourServer.stop(), 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');
await shell.quit();
@@ -12,7 +12,7 @@ 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.
/// root ([AppGraph]) and handed on from there.
class Ports {
final ShellPort shell;
final UrlOpener urls;
@@ -181,7 +181,7 @@ class QueueController {
);
case LinkMedia(:final url, :final anyway):
// Alone, a link plays direct: there is nobody to pass it on to.
final relay = coord.solo ? LinkRelay.direct : pick.relay;
final relay = coord.alone.peek() ? LinkRelay.direct : pick.relay;
final linked = await mediaForLink(coord.jellyfin, url);
final ref = linked.jellyfin;
if (ref != null) {
+13 -12
View File
@@ -18,7 +18,7 @@ import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds;
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
import '../shell/motion.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart' show AppGlass, AppMenuItem, AppMenuStyle, showAppMenu;
import '../widgets/copy_open.dart';
import '../widgets/text_focus.dart' show KeyboardClaim;
@@ -641,7 +641,7 @@ class _QueuePanelState extends State<QueuePanel> {
/// Ctrl+V: the clipboard's video files, else the links in its text.
Future<void> _paste() async {
final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
final clip = await (widget.readClipboard ?? AppScope.of(context).ports.shell.readPaste)();
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
if (!mounted) return;
if (picks.isEmpty) {
@@ -682,7 +682,8 @@ class _QueuePanelState extends State<QueuePanel> {
final row = rows[i];
if (p case LinkMedia(:final url)) {
// Its title and picture while it resolves (at once when seen before).
unawaited((widget.preview ?? cachedLinkPreview)(url).then((v) {
final probe = AppScope.of(context).ports.links.probe;
unawaited((widget.preview ?? (u) => cachedLinkPreview(u, probe: probe))(url).then((v) {
if (mounted) setState(() => row.preview = v);
}, onError: (Object _) {}));
}
@@ -768,13 +769,13 @@ class _QueuePanelState extends State<QueuePanel> {
case 'remove':
_q.remove(item.id);
case 'title':
await copyToClipboard(item.title, done: 'Title copied');
if (context.mounted) await copyToClipboard(context, item.title, done: 'Title copied');
case 'link':
await copyToClipboard(link!, done: 'Link copied');
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
case 'open':
await openLink(link!);
if (context.mounted) await openLink(context, link!);
case 'jellyfin':
await openLink(page!);
if (context.mounted) await openLink(context, page!);
}
}
@@ -814,7 +815,7 @@ class _QueuePanelState extends State<QueuePanel> {
@override
Widget build(BuildContext context) {
final height = MediaQuery.sizeOf(context).height;
final drop = SessionState.instance.ports.shell.dropTarget(
final drop = AppScope.of(context).ports.shell.dropTarget(
onEnter: () => setState(() => _dragging = true),
onExit: () => setState(() => _dragging = false),
onDrop: (paths, _) {
@@ -1192,8 +1193,8 @@ IconData howIcon(QueueHow how) => switch (how) {
};
/// Who added it: their picture (still here) or initial, and name.
AvatarInfo _adderAvatar(QueueItem item, List<MemberView> members) {
final coord = SessionState.instance.coordinator;
AvatarInfo _adderAvatar(BuildContext context, QueueItem item, List<MemberView> members) {
final coord = AppScope.of(context).coordinator;
for (final m in members) {
if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord);
}
@@ -1227,7 +1228,7 @@ class _MetaLine extends StatelessWidget {
children: [
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
Text('added by ', style: style),
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
const SizedBox(width: 4),
Text(name, style: style),
dot,
@@ -1243,7 +1244,7 @@ class _MetaLine extends StatelessWidget {
children: [
if (dur.isNotEmpty) ...[Text(dur, style: style), dot],
Text('added by ', style: style),
StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false),
StatusAvatar(info: _adderAvatar(context, item, members), size: fontSize + 3, badges: false, ring: false),
const SizedBox(width: 4),
// Capped rather than flexible: two flexibles split the room in
// half and would cut the end off even when it fits.
@@ -13,7 +13,7 @@ enum CardAction {
none,
putUp,
/// Available, nobody else watching (offline or solo): plays it here.
/// Available, nobody else in the server: plays it here.
play,
request,
requestSeries,
@@ -24,9 +24,8 @@ import 'schedule_card.dart';
enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget {
/// Nobody else watches (offline or solo): available episodes say Play
/// (offline that starts a solo session).
final bool solo;
/// Nobody else is in the server (D9): available episodes say Play.
final bool alone;
/// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp;
@@ -43,7 +42,7 @@ class SchedulePage extends StatefulWidget {
const SchedulePage({
super.key,
this.solo = false,
this.alone = false,
required this.onPutUp,
this.onAddToQueue,
required this.onToast,
@@ -228,7 +227,7 @@ class _SchedulePageState extends State<SchedulePage> {
(CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
final put = widget.solo ? CardAction.play : CardAction.putUp;
final put = widget.alone ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (put, null);
}
@@ -287,7 +286,7 @@ class _SchedulePageState extends State<SchedulePage> {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
AppMenuItem(value: 0, label: widget.alone ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
@@ -3,7 +3,7 @@ import 'package:signals/signals_flutter.dart';
import '../../platform/app_log.dart';
import '../../shell/mark_problem.dart';
import '../../state/session_state.dart';
import '../../app/app_graph.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import '../../widgets/app_menu.dart';
@@ -43,10 +43,10 @@ class AboutPanel extends StatelessWidget {
IconButton(
tooltip: ReuseText.copyLink,
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
onPressed: () => copyToClipboard(context, repoUrl, done: 'Link copied'),
),
FilledButton.tonalIcon(
onPressed: () => openLink(repoUrl),
onPressed: () => openLink(context, repoUrl),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Source'),
),
@@ -75,7 +75,7 @@ class AboutPanel extends StatelessWidget {
),
const MarkProblemButton(outlined: true),
OutlinedButton.icon(
onPressed: appLogDir == null ? null : () => _openLogs(),
onPressed: appLogDir == null ? null : () => _openLogs(context),
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Open logs folder'),
),
@@ -102,7 +102,8 @@ class AboutPanel extends StatelessWidget {
/// In a session it asks whether to add the server's session log.
static Future<void> _copyDiagnostics(BuildContext context) async {
var withServer = false;
if (SessionState.instance.route.peek() == AppRoute.session) {
final graph = AppScope.of(context);
if (graph.navigation.online.peek()) {
final pick = await showAppMenu<bool>(context, items: const [
AppMenuItem(value: false, label: "This app's log"),
AppMenuItem(value: true, label: "This app's and the server's session log"),
@@ -110,17 +111,20 @@ class AboutPanel extends StatelessWidget {
if (pick == null) return;
withServer = pick;
}
final text = await diagnosticsText(sessionLog: withServer);
final text = await diagnosticsText(graph, sessionLog: withServer);
final saved = await saveDiagnostics(text);
await copyToClipboard(text,
if (!context.mounted) return;
await copyToClipboard(context, text,
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
}
static Future<void> _openLogs() async {
static Future<void> _openLogs(BuildContext context) async {
final dir = appLogDir;
if (dir == null) return;
if (!await SessionState.instance.ports.folders.reveal(dir)) {
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
final folders = AppScope.of(context).ports.folders;
if (!await folders.reveal(dir)) {
if (!context.mounted) return;
await copyToClipboard(context, dir, done: "Couldn't open it — folder path copied");
}
}
}
@@ -2,26 +2,35 @@ import 'package:flutter/foundation.dart';
import '../../session/connection.dart' show connectErrorText;
import '../../session/identity.dart';
import '../../session/navigation.dart';
import '../../session/session_coordinator.dart';
import '../../state/session_state.dart';
import '../../state/connect_prefs.dart';
import '../../sync/sync_client.dart' show HelloRefused;
import 'health_probe.dart' show VersionGateRefused;
/// What a card shows under its fields after a failed attempt.
/// What the Join card shows under its fields after a failed attempt.
/// [offerUpdate]: this app is the older one (the version gate), so the
/// card offers the updater.
class CardError {
final String title;
final String message;
const CardError(this.title, this.message);
final bool offerUpdate;
const CardError(this.title, this.message, {this.offerUpdate = false});
}
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
/// [cancel] abandons it via [SessionState.cancelPending].
/// The Join card's submit state, owned by the shell so an attempt and its
/// error outlive the card. One attempt at a time; [cancel] abandons it
/// ([Navigation.cancelPending]).
class ConnectFlow extends ChangeNotifier {
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
final SessionState _session;
ConnectFlow({required this.navigation, required this.forms, this.profile});
final Navigation navigation;
final ConnectStore forms;
/// Name and picture for the hello (default: the app's).
final Profile? profile;
ConnectMode? busy;
final errors = <ConnectMode, CardError?>{};
final notices = <ConnectMode, String?>{};
bool busy = false;
CardError? error;
String? notice;
int _attempt = 0;
bool _disposed = false;
@@ -35,116 +44,101 @@ class ConnectFlow extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
void showError(ConnectMode mode, CardError error) {
errors[mode] = error;
notices[mode] = null;
void showError(CardError e) {
error = e;
notice = null;
_notify();
}
void clearError(ConnectMode mode) {
if (errors[mode] == null && notices[mode] == null) return;
errors[mode] = null;
notices[mode] = null;
void clearError() {
if (error == null && notice == null) return;
error = null;
notice = null;
_notify();
}
/// Validates and builds the params; throws [FormatException] with a
/// user-facing message. [avatar] is the base64 picture (the profile's).
/// Hosting puts nothing up: what to watch is picked in the library.
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
final port = int.tryParse(rawPort.trim());
static ConnectParams buildParams(ConnectForm f, {String avatar = ''}) {
final port = int.tryParse(f.port.trim());
if (port == null || port <= 0 || port > 65535) {
throw const FormatException('port must be 1–65535');
}
if (f.name.trim().isEmpty) {
throw const FormatException('set your display name first (click your avatar at the top left)');
}
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
if (f.host.trim().isEmpty) {
throw const FormatException('enter the server address');
}
return ConnectParams(
host: f.host.trim(),
port: port,
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
name: f.name.trim(),
avatar: avatar,
);
return ConnectParams(host: f.host.trim(), port: port, secret: f.secret, name: f.name.trim(), avatar: avatar);
}
/// [edit] fills the card's fields into a copy of the last form, so the
/// other card's saved values survive.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
if (busy != null) return;
/// [edit] fills the card's fields into a copy of the form; the form is
/// saved whatever happens (a failed join is when the user wants it back).
Future<void> submit(void Function(ConnectForm f) edit) async {
if (busy) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
final form = forms.form.copy();
edit(form);
form.name = form.name.trim();
form.host = form.host.trim();
busy = mode;
errors[mode] = null;
notices[mode] = null;
busy = true;
error = null;
notice = null;
_notify();
ConnectParams params;
try {
final profile = Profile.instance;
final profile = this.profile ?? Profile.instance;
await profile.ensureLoaded();
params = buildParams(mode, form, avatar: profile.avatarBase64());
params = buildParams(form, avatar: profile.avatarBase64());
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
errors[mode] = CardError('Check the form',
e is FormatException ? e.message : 'check the form and try again');
busy = false;
error = CardError('Check the form', e is FormatException ? e.message : 'check the form and try again');
_notify();
return;
}
final fromSolo = mode == ConnectMode.host && _session.solo.value;
forms.form
..host = form.host
..port = form.port
..secret = form.secret;
try {
if (mode == ConnectMode.host) {
await _session.host(params, form);
} else {
await _session.join(params, form);
}
await navigation.join(params);
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
// A refusal says why in plain words (an older host, a wrong password,
// another window).
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
busy = false;
// In plain words: the version gate (who has to update), a refusal
// (an older host, a wrong password, another window), offline.
error = switch (e) {
VersionGateRefused(:final message, :final youUpdate) =>
CardError("Versions don't match", message, offerUpdate: youUpdate),
HelloRefused() => CardError('Could not connect', connectErrorText(e)),
_ => CardError('Could not connect', '$e'),
};
_notify();
await forms.saveQuietly();
return;
}
if (myAttempt != _attempt) {
// Cancelled after it got through: back out (an Invite: back to solo).
// Cancelled after it got through: back home.
try {
if (fromSolo) {
await _session.cancelInvite();
} else {
await _session.leave();
await _session.stopServer();
}
await navigation.goHome();
} catch (_) {}
return;
}
busy = null;
busy = false;
_notify();
await _session.rememberServer(RecentServer(
hosted: mode == ConnectMode.host,
host: mode == ConnectMode.host ? '' : form.host,
port: mode == ConnectMode.host ? form.hostPort.trim() : form.port.trim(),
secret: mode == ConnectMode.host ? form.hostSecret : form.secret,
));
}
Future<void> cancel() async {
final mode = busy;
final was = busy;
_attempt++;
try {
await _session.cancelPending();
await navigation.cancelPending();
} catch (_) {}
busy = null;
if (mode != null) notices[mode] = 'Cancelled';
busy = false;
if (was) notice = 'Cancelled';
_notify();
}
}
@@ -1,21 +1,20 @@
import 'package:flutter/material.dart';
import '../../state/session_state.dart';
import '../../update/update_controller.dart';
import 'connect_flow.dart';
import 'ui.dart';
import 'update_indicator.dart' show runUpdateCheck;
/// The submit button for one [ConnectMode], swapped for a progress bar and
/// Cancel while that attempt runs. Disabled while the other mode is busy.
/// The Join button, swapped for a progress bar and Cancel while the
/// attempt runs.
class ConnectButton extends StatelessWidget {
final ConnectFlow flow;
final ConnectMode mode;
final String label;
final VoidCallback onSubmit;
final bool expand;
const ConnectButton({
super.key,
required this.flow,
required this.mode,
required this.label,
required this.onSubmit,
this.expand = false,
@@ -23,8 +22,7 @@ class ConnectButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final busyHere = flow.busy == mode;
if (busyHere) {
if (flow.busy) {
return SizedBox(
width: expand ? null : 220,
child: Column(
@@ -35,10 +33,7 @@ class ConnectButton extends StatelessWidget {
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
style: Theme.of(context).textTheme.bodySmall),
),
Expanded(child: Text('Connecting…', style: Theme.of(context).textTheme.bodySmall)),
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
],
),
@@ -46,35 +41,48 @@ class ConnectButton extends StatelessWidget {
),
);
}
final button = FilledButton(
onPressed: flow.busy != null ? null : onSubmit,
child: Text(label),
);
final button = FilledButton(onPressed: onSubmit, child: Text(label));
return expand ? SizedBox(width: double.infinity, child: button) : button;
}
}
/// Last error or notice for [mode], or nothing.
/// The last error or notice, or nothing. A version gate that says this
/// app is the older one offers the update ([updates], default the app's
/// [UpdateController]).
class ConnectFeedback extends StatelessWidget {
final ConnectFlow flow;
final ConnectMode mode;
const ConnectFeedback({super.key, required this.flow, required this.mode});
final UpdateController? updates;
const ConnectFeedback({super.key, required this.flow, this.updates});
@override
Widget build(BuildContext context) {
final error = flow.errors[mode];
final notice = flow.notices[mode];
final error = flow.error;
final notice = flow.notice;
if (error != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ErrorBox(title: error.title, message: error.message),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ErrorBox(title: error.title, message: error.message),
if (error.offerUpdate)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('join-update'),
onPressed: () => runUpdateCheck(updates, true),
icon: const Icon(Icons.system_update_alt, size: 16),
label: const Text('Check for the update'),
),
),
],
),
);
}
if (notice != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(notice,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
child: Text(notice, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
);
}
return const SizedBox.shrink();
@@ -10,26 +10,61 @@ typedef HealthGetter = Future<String> Function(Uri uri);
const healthPollEvery = Duration(seconds: 10);
const _timeout = Duration(seconds: 4);
/// What a recent-server tile shows: unknown until the first probe answers.
/// Whether this app and a server have a wire version in common.
enum VersionFit {
/// Their ranges meet: the join can go ahead.
ok,
/// The server's newest version is older than anything this app speaks.
theyUpdate,
/// The server's oldest version is newer than anything this app speaks.
youUpdate,
}
/// [VersionFit] of a server that speaks [min] .. [max] (`/health`
/// `protoMin` / `protoMax`), against this build's
/// [RelayConstants.protoMin] .. [RelayConstants.protoMax].
VersionFit versionFit(int min, int max,
{int ownMin = RelayConstants.protoMin, int ownMax = RelayConstants.protoMax}) {
if (max < ownMin) return VersionFit.theyUpdate;
if (min > ownMax) return VersionFit.youUpdate;
return VersionFit.ok;
}
/// What a recent-server row and the version gate read from `/health`:
/// unknown until the first probe answers.
class ServerHealth {
final bool online;
final String name;
final int? proto;
/// The server's wire range (`protoMin` .. `protoMax`; a server too old
/// to say has only `proto`, used for both). Null when it says nothing.
final int? protoMin;
final int? protoMax;
final int viewers;
/// Its Starsync version (`version`), '' from a server too old to say.
final String version;
const ServerHealth.offline()
: online = false,
name = '',
proto = null,
protoMin = null,
protoMax = null,
viewers = 0,
version = '';
const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.version = ''})
const ServerHealth.online({required this.name, this.protoMin, this.protoMax, this.viewers = 0, this.version = ''})
: online = true;
/// True when the server speaks another protocol than this build.
bool get needsUpdate =>
online && proto != null && proto != RelayConstants.protoVersion;
/// Null while unknown (offline, or no version given).
VersionFit? get fit {
final min = protoMin, max = protoMax;
if (!online || min == null || max == null) return null;
return versionFit(min, max);
}
/// True when the server and this app have no version in common.
bool get needsUpdate => fit != null && fit != VersionFit.ok;
/// The server's own name for people ('' for the dedicated server's
/// default `server`).
String get label => name.isNotEmpty && name != 'server' ? name : '';
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
static ServerHealth parse(String body) {
@@ -37,9 +72,12 @@ class ServerHealth {
final j = jsonDecode(body);
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version'];
final min = j['protoMin'], max = j['protoMax'];
final p = proto is num ? proto.toInt() : null;
return ServerHealth.online(
name: name is String ? name.trim() : '',
proto: proto is num ? proto.toInt() : null,
protoMin: min is num ? min.toInt() : p,
protoMax: max is num ? max.toInt() : p,
viewers: viewers is num ? viewers.toInt() : 0,
version: version is String ? version.trim() : '',
);
@@ -49,6 +87,26 @@ class ServerHealth {
}
}
/// The version gate's message for a server that does not fit: who has
/// to update, in plain words ("Bob's server runs Starsync 1.2.1 — they
/// need to update").
String versionGateText(ServerHealth h) {
final who = h.label.isNotEmpty ? h.label : 'The server';
final runs = h.version.isNotEmpty ? '$who runs Starsync ${h.version}' : '$who runs ${h.fit == VersionFit.youUpdate ? 'a newer' : 'an older'} Starsync';
return h.fit == VersionFit.youUpdate ? '$runs — you need to update' : '$runs — they need to update';
}
/// A join stopped before connecting: the server and this app have no
/// version in common ([versionGateText]). [youUpdate]: this app is the
/// older one, so the updater can help.
class VersionGateRefused implements Exception {
final String message;
final bool youUpdate;
const VersionGateRefused(this.message, {required this.youUpdate});
@override
String toString() => message;
}
Uri healthUri(String host, String port) {
final h = host.trim();
// Bare IPv6 literals need brackets in a URL.
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import '../../app/app_graph.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'ui.dart';
@@ -88,10 +88,11 @@ class SettingsHintCard extends StatelessWidget {
class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key});
Future<void> _addFiles() async {
Future<void> _addFiles(BuildContext context) async {
final folders = AppScope.of(context).ports.folders;
final List<String> files;
try {
files = await SessionState.instance.ports.folders.pickFiles(
files = await folders.pickFiles(
dialogTitle: 'Pick mpv GLSL shaders',
extensions: const ['glsl', 'hook'],
);
@@ -123,7 +124,7 @@ class CustomShaderFiles extends StatelessWidget {
style: muted),
),
TextButton.icon(
onPressed: _addFiles,
onPressed: () => _addFiles(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add .glsl files'),
),
@@ -89,7 +89,7 @@ class AutoSitOut {
/// Once a second. [self]: this member's presence in the latest state
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
/// [enabled]: the setting (and not a solo session).
/// [enabled]: the setting.
AutoSitOutStep tick(DateTime now,
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
if (playing) _lastActive = now;
+8 -1
View File
@@ -142,7 +142,8 @@ final class ConnReconnecting extends ConnState {
}
/// The server refused this app; nothing retries. [code]: `version` or
/// `secret` when the server said so, else empty (offline, timeout,
/// `secret` when the server said so, `closed` when the host stopped
/// sharing (close 4016), else empty (offline, timeout,
/// anything else on the first dial); [message]: in plain words
/// ([connectErrorText]).
final class ConnRefused extends ConnState {
@@ -166,6 +167,10 @@ final class ConnReplaced extends ConnState {
/// [ConnReplaced] in plain words.
const replacedText = 'Starsync was opened in another window';
/// `ConnRefused('closed')`: the host closed their server to friends
/// (close 4016).
const stoppedSharingText = 'the host stopped sharing their server';
/// A version refusal in plain words: [err] is the server's `err`
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
/// `proto mismatch`, which is always an older host.
@@ -518,6 +523,8 @@ class Connection {
_end(ConnRefused('version', versionText(null)));
case CloseCodes.secret:
_end(const ConnRefused('secret', 'wrong server password (401)'));
case CloseCodes.closed:
_end(const ConnRefused('closed', stoppedSharingText));
default:
_dropped(now());
}
+9 -15
View File
@@ -9,7 +9,6 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show validClientId;
import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import '../state/settings.dart' show settingsBoxName;
import '../widgets/avatar_pipeline.dart';
@@ -96,17 +95,11 @@ const _keyColor = 'profileColor';
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// Display name, picture and colour of this app's user. Backed by the
/// connect form ([forms]: the name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
/// server it is pushed through [push] so everyone sees it at once. The
/// composition root sets both.
class Profile {
static final Profile instance = Profile._();
Profile._();
@@ -116,7 +109,8 @@ class Profile {
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
ProfilePush push = pushProfileToSession;
ProfilePush push = ({String? name, String? avatar}) {};
ConnectStore forms = ConnectStore();
bool _loaded = false;
Timer? _nameTimer;
@@ -131,7 +125,7 @@ class Profile {
}
Future<void> _load() async {
final form = SessionState.instance.lastForm;
final form = forms.form;
name.value = form.name;
try {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
@@ -162,7 +156,7 @@ class Profile {
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
forms.form.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
@@ -187,7 +181,7 @@ class Profile {
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
await forms.save();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
+123
View File
@@ -0,0 +1,123 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
/// What a friend needs to join your server: where ([host], [port]), what
/// it is called ([name]), whether it asks a password ([password]; the
/// password itself is never in it) and which Starsync runs it
/// ([version]). [fp]: reserved for a pinned certificate fingerprint
/// (`wss://` with a self-signed certificate); always null for now.
class Invite {
final String host;
final int port;
final String name;
final bool password;
final String version;
final String? fp;
const Invite({
required this.host,
required this.port,
this.name = '',
this.password = false,
this.version = '',
this.fp,
});
/// What goes in the Join card's address and port.
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// The text Invite copies: the address, whether to ask for the password,
/// the version to match.
String get text => [
'Join me on Starsync: $address',
if (password) '(ask me for the password)',
if (version.isNotEmpty) '· Starsync $version',
].join(' ');
@override
bool operator ==(Object other) =>
other is Invite &&
other.host == host &&
other.port == port &&
other.name == name &&
other.password == password &&
other.version == version &&
other.fp == fp;
@override
int get hashCode => Object.hash(host, port, name, password, version, fp);
@override
String toString() => 'Invite($address${password ? ', password' : ''})';
}
/// The `host:port` in a pasted invite (or a bare address); null when
/// there is none.
({String host, String port})? addressIn(String text) {
final m = RegExp(r'(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+):(\d{1,5})\b').firstMatch(text);
if (m == null) return null;
final host = m.group(1)!;
return (host: host.startsWith('[') ? host.substring(1, host.length - 1) : host, port: m.group(2)!);
}
/// Interface names of container and VM bridges, never a way in for a
/// friend (Docker, Podman, libvirt, LXC, VirtualBox, VMware, WSL/Hyper-V).
final _virtualName = RegExp(
r'^(docker|br-|veth|virbr|vmnet|vboxnet|cni|flannel|podman|lxcbr|lxdbr|vEthernet|VirtualBox|VMware)',
caseSensitive: false);
/// The proxy fake-IP range (198.18.0.0/15, benchmarking space that TUN
/// proxies like Clash hand out): this machine's address on it is useless
/// to anyone else.
bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
bool _isPrivate(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
/// no loopback, link-local or fake-IP address, no container or VM bridge;
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
/// VPN's, then anything else (a public address). No repeats.
List<String> inviteAddresses(Iterable<({String name, InternetAddress address})> interfaces) {
int rank(List<int> b) => b[0] == 192 && b[1] == 168
? 0
: _isPrivate(b)
? 1
: _isShared(b)
? 2
: 3;
final usable = [
for (final i in interfaces)
if (i.address.type == InternetAddressType.IPv4 &&
!i.address.isLoopback &&
!i.address.isLinkLocal &&
!(i.address.rawAddress[0] == 169 && i.address.rawAddress[1] == 254) &&
!isFakeIp(i.address.rawAddress) &&
!_virtualName.hasMatch(i.name))
i.address,
];
final indexed = [for (var n = 0; n < usable.length; n++) (n, usable[n])];
indexed.sort((a, b) {
final r = rank(a.$2.rawAddress).compareTo(rank(b.$2.rawAddress));
return r != 0 ? r : a.$1.compareTo(b.$1);
});
return {for (final (_, a) in indexed) a.address}.toList();
}
/// This machine's interfaces as [inviteAddresses] takes them (empty when
/// they can't be listed).
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
try {
final list = await NetworkInterface.list(type: InternetAddressType.IPv4);
return [
for (final i in list)
for (final a in i.addresses) (name: i.name, address: a),
];
} catch (e) {
debugPrint('list network interfaces failed: $e');
return const [];
}
}
@@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/health_probe.dart';
import '../state/connect_prefs.dart';
import 'app_events.dart' show ActionFailed, ServerRefused;
import 'connection.dart';
import 'identity.dart' show Profile;
import 'session_coordinator.dart';
import 'your_server.dart';
/// Which server this app is in.
enum Place {
/// None yet (starting, or between two servers).
none,
/// Your own server ([YourServer]): home.
yours,
/// A friend's (joined from the Join card).
friend,
}
/// A join that did not get as far as connecting, in plain words.
class JoinFailed implements Exception {
final String message;
const JoinFailed(this.message);
@override
String toString() => message;
}
/// Where this app is: home on your own server, or in a friend's. It
/// starts the app at home, switches to a friend's server on a join (after
/// the version gate) and back home when you leave theirs, a join fails or
/// they stop sharing. Your server stays up all the while; you leave it
/// (with `bye`) while you are away. Made by the composition root.
class Navigation {
Navigation({
required this.coordinator,
required this.yourServer,
required this.forms,
this.profile,
this.healthGet = defaultHealthGet,
}) {
yourServer.onCrash = () {
if (place.peek() != Place.friend) unawaited(goHome());
};
// A friend closed their server to friends: back home, and say so.
_stops.add(effect(() {
final s = coordinator.connection.state.value;
if (s is ConnRefused && s.code == 'closed' && untracked(() => place.value) == Place.friend) {
// Said once home (a connect clears the toasts).
scheduleMicrotask(() => unawaited(goHome().then(
(_) => coordinator.notify(const ServerRefused('The host stopped sharing their server')))));
}
}));
}
final SessionCoordinator coordinator;
final YourServer yourServer;
final ConnectStore forms;
/// Name and picture for your own connection (default: the app's).
final Profile? profile;
final HealthGetter healthGet;
final _stops = <void Function()>[];
Profile get _me => profile ?? Profile.instance;
final place = signal(Place.none);
/// In a server (yours or a friend's).
late final Computed<bool> online = computed(() => place.value != Place.none);
/// On your own server.
late final Computed<bool> home = computed(() => place.value == Place.yours);
/// A friend's server's own name (`/health`), '' for yours, until known
/// or when it only has the default name.
final serverLabel = signal('');
/// The server's Starsync version: yours is this app's; a friend's
/// comes from its `/health`; '' until known.
final serverVersion = signal('');
/// The address dialled for a friend's server ("host:port"), '' at home.
final address = signal('');
// Bumped by every move; a superseded one stops at its next step.
int _op = 0;
/// The app starts: home, on your own server.
Future<void> start() => goHome();
/// To your own server (started if it is not running). False when it
/// could not start or the move was superseded; the reason is a notice.
Future<bool> goHome() async {
final op = ++_op;
_arrive(Place.none);
try {
await _me.ensureLoaded();
final name = _me.name.peek().trim();
final port = await yourServer.start(name: name);
if (op != _op) return false;
coordinator.hostFile = yourServer.setFile;
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: port,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: _me.avatarBase64(),
hostKey: yourServer.hostKey,
));
if (op != _op) return false;
serverVersion.value = appVersion;
_arrive(Place.yours);
return true;
} on ConnectCancelled {
return false;
} catch (e, st) {
debugPrint('your server: $e\n$st');
if (op == _op) coordinator.notify(ActionFailed("couldn't start your server: $e"));
return false;
}
}
void _arrive(Place p) {
if (p != Place.yours) coordinator.hostFile = null;
if (p == Place.none) {
serverLabel.value = '';
serverVersion.value = '';
address.value = '';
}
place.value = p;
}
/// Joins a friend's server. The version gate first (`/health`): an
/// unreachable server or one without a version in common stops here,
/// still at home ([JoinFailed], [VersionGateRefused]). Then you leave
/// your server and connect; when that fails you are back home and the
/// error is rethrown (a refusal: [HelloRefused]).
Future<void> join(ConnectParams params) async {
final op = ++_op;
final health = await probeHealth(params.host, '${params.port}', healthGet);
if (op != _op) throw const ConnectCancelled();
if (!health.online) throw const JoinFailed('host offline — check address and port');
final fit = health.fit;
if (fit != null && fit != VersionFit.ok) {
throw VersionGateRefused(versionGateText(health), youUpdate: fit == VersionFit.youUpdate);
}
_arrive(Place.none);
try {
await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled();
} catch (_) {
if (op == _op) unawaited(goHome());
rethrow;
}
serverLabel.value = health.label;
serverVersion.value = health.version;
address.value = '${params.host}:${params.port}';
_arrive(Place.friend);
await forms.remember(RecentServer(host: params.host, port: '${params.port}', secret: params.secret));
}
/// The Join card's Cancel: stops a join under way; when it had already
/// left home, back home.
Future<void> cancelPending() async {
_op++;
try {
await coordinator.cancelConnect();
} catch (_) {}
if (place.peek() != Place.yours) await goHome();
}
/// Out of a friend's server (Leave): back home.
Future<bool> leaveFriend() => goHome();
/// The app closes: out of whichever server ([bye] false: Update and
/// restart, the member stays `gone` for the restarted app).
Future<void> leave({bool bye = true}) async {
_op++;
await coordinator.leave(bye: bye);
_arrive(Place.none);
}
void dispose() {
for (final stop in _stops) {
stop();
}
}
}
@@ -268,9 +268,12 @@ class SessionCoordinator {
StreamSubscription? _nudgeSub;
StreamSubscription? _errSub;
/// A solo session (set by SessionState): no auto sit-out, there is
/// nobody to hold up.
bool solo = false;
/// Nobody else is in the server (D9): a plain player, Play rather than
/// Ready, links go up direct. Also true before the first state.
late final Computed<bool> alone = computed(() {
final r = serverState.value;
return r == null || aloneIn(r.members, connection.memberId);
});
bool _playerReady = false;
bool _wasPlayAllowed = false;
// Last ready flag the server reported for self; sent again when a
@@ -316,10 +319,6 @@ class SessionCoordinator {
final memberNames = MemberNames();
// The media this member was told its upload is too slow for.
int _slowWarnedFor = -1;
/// What this client put up last in this connection (a solo session
/// carries it over when it becomes a hosted server).
PickedMedia? get lastPick => _lastPick;
PickedMedia? _lastPick;
// Server seek applied to the player (event id and when), checked once it
// should have landed.
int? _pendingSeekEvent;
@@ -548,7 +547,6 @@ class SessionCoordinator {
_seekGate.reset();
_clearPendingSeek();
_lastEventId = -1;
_lastPick = null;
_resetJellyfin();
_jfToastShown = false;
if (!_jellyfinListening) {
@@ -1152,7 +1150,7 @@ class SessionCoordinator {
self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId),
enabled: !solo && AppSettings.instance.autoSitOut.peek());
enabled: AppSettings.instance.autoSitOut.peek());
switch (step) {
case AutoAway(:final reason):
_log.info('auto sit-out: away (${reason.name})');
@@ -1475,7 +1473,6 @@ class SessionCoordinator {
Future<String?> swapLink(String url,
{LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async {
markInput();
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
@@ -1557,7 +1554,6 @@ class SessionCoordinator {
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
}
@@ -1567,7 +1563,6 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path, {String? queueId}) async {
markInput();
_lastPick = FileMedia(path);
final host = hostFile;
if (host != null) {
final file = await host(path);
@@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show InternetAddress;
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart' show relayCredentialFor;
import '../state/connect_prefs.dart';
import '../state/server_isolate.dart';
import 'invite.dart';
/// A fresh key per app run: this app's own client sends it in hello to be
/// recognised as the host (`members[].host`; it also stands in for the
/// password).
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
}
/// Starts the server isolate (tests may pass their own).
typedef ServerStarter = Future<ServerIsolate> Function(
AppConfig config, {
bool loopbackOnly,
String? logDir,
required void Function(String line) onLog,
required void Function(String error) onCrash,
});
Future<ServerIsolate> _startIsolate(
AppConfig config, {
bool loopbackOnly = false,
String? logDir,
required void Function(String line) onLog,
required void Function(String error) onCrash,
}) =>
ServerIsolate.start(config, loopbackOnly: loopbackOnly, logDir: logDir, onLog: onLog, onCrash: onCrash);
/// "Your server": the one server this app runs for its whole life (a
/// [ServerIsolate] running `serve`). It starts on 127.0.0.1 and a free
/// port, so only this app reaches it; [invite] attaches a second
/// listener on every interface at the configured port (Settings → Party
/// & network: port and password) and [stopSharing] detaches it. Members,
/// what plays and Up next never restart or reconnect for either. Made
/// by the composition root.
class YourServer {
YourServer({
required this.forms,
this.jellyfin,
Future<String> Function()? ytDlpDir,
String? Function()? logDir,
ServerStarter? starter,
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
_logDir = logDir ?? (() => null),
_starter = starter ?? _startIsolate,
_interfaces = interfaces ?? machineInterfaces;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay login.
final JellyfinAccounts? jellyfin;
final Future<String> Function() _ytDlpDir;
final String? Function() _logDir;
final ServerStarter _starter;
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
/// Sent by this app's own client (see [newHostKey]).
final String hostKey = newHostKey();
/// The loopback port while it runs; null before [start] and after
/// [stop] or a crash.
final port = signal<int?>(null);
/// The invite while shared (the listener attached); null when only this
/// app can reach it.
final shared = signal<Invite?>(null);
/// An [invite] or [stopSharing] is under way.
final busy = signal(false);
/// Set when the server died without being stopped; the shell says so
/// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null);
/// Called after a crash (the navigation reconnects home).
void Function()? onCrash;
ServerIsolate? _server;
Future<int>? _starting;
@visibleForTesting
ServerIsolate? get server => _server;
JellyfinAccounts get _accounts => jellyfin ?? JellyfinAccounts.instance;
/// Starts it once (later calls get the same port); a new one after a
/// crash. [name]: what `/health` calls it ("Bob's server").
Future<int> start({String name = ''}) {
final running = _server;
if (running != null && running.isRunning) return Future.value(running.port);
return _starting ??= _start(name).whenComplete(() => _starting = null);
}
Future<int> _start(String name) async {
final who = name.trim();
late final ServerIsolate server;
server = await _starter(
AppConfig(
port: 0,
secret: '',
name: who.isEmpty ? 'server' : "$who's server",
hostKey: hostKey,
ytDlpDir: await _ytDlpDir(),
),
loopbackOnly: true,
// Its session log (every member's shared diagnostics and its own
// records) goes next to the app's.
logDir: _logDir(),
// Its records join the app's log as `server.<logger>`.
onLog: (line) => relogLine('server', line),
onCrash: (cause) => _onCrash(server, cause),
);
_server = server;
port.value = server.port;
return server.port;
}
void _onCrash(ServerIsolate server, String cause) {
if (!identical(_server, server)) return;
_server = null;
port.value = null;
shared.value = null;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
crash.value = message;
onCrash?.call();
}
/// Opens it to friends (attaching the network listener if it is not
/// yet) and returns the invite to hand out. Throws when the port can't
/// be opened (in use) or the server is not running.
Future<Invite> invite() async {
final server = _server;
if (server == null || !server.isRunning) throw StateError('your server is not running');
final current = shared.peek();
if (current != null) return current;
busy.value = true;
try {
final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false);
final bound = await server.share(
port: want,
secret: form.hostSecret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in.
jellyfin: relayCredentialFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
shared.value = invite;
return invite;
} finally {
busy.value = false;
}
}
/// The invite for [port] from this machine's best address (see
/// [inviteAddresses]); 127.0.0.1 when there is none.
Future<Invite> inviteFor(int port, {required bool password}) async {
final addresses = inviteAddresses(await _interfaces());
return Invite(
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
port: port,
password: password,
version: appVersion,
);
}
/// Closes it to friends again: everyone but you leaves (told, so their
/// apps do not retry); you, what plays and Up next stay.
Future<void> stopSharing() async {
final server = _server;
if (server == null || shared.peek() == null) return;
busy.value = true;
try {
await server.unshare();
shared.value = null;
} finally {
busy.value = false;
}
}
/// Makes [path] the hosted file (a File put up here; `/m` serves it).
Future<({String name, int size})?> setFile(String path) async => _server?.setFile(path);
/// The app closes: the server stops (sockets closed, port freed).
Future<void> stop() async {
final server = _server;
_server = null;
port.value = null;
shared.value = null;
await server?.stop();
}
}
+87 -118
View File
@@ -14,18 +14,18 @@ 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';
import '../session/identity.dart';
import '../screens/home/update_indicator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../session/navigation.dart' show Place;
import '../state/settings.dart';
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_link.dart' show LinkProber;
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
@@ -38,7 +38,8 @@ import 'welcome.dart';
/// The one window: the party strip over a body that fades through between
/// the tabs and the full player, the floating mini player, the chat panel
/// and the toasts. Online/offline follows [SessionState.route].
/// and the toasts. Where the app is (your server, a friend's) follows
/// [Navigation.place].
///
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
/// the body: full it fills the body (fullscreen: the window, strip
@@ -61,7 +62,7 @@ class AppShell extends StatefulWidget {
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;
final LinkProber? linkProbe;
const AppShell({
super.key,
this.updates,
@@ -70,7 +71,7 @@ class AppShell extends StatefulWidget {
this.healthGet = defaultHealthGet,
this.jellyfin,
this.videoBuilder,
this.linkProbe = defaultLinkProber,
this.linkProbe,
});
@override
@@ -112,9 +113,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool _dragging = false;
bool _handoffOpen = false;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final AppGraph _graph = AppScope.of(context);
late final _flow = ConnectFlow(navigation: _graph.navigation, forms: _graph.forms);
late final _join = JoinFields(_graph.forms.form);
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late MediaPickerController _picker = _newPicker();
BrowseStart? _libraryStart;
@@ -126,22 +127,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool _dialogOpen = false;
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
/// Nobody else watches: offline, solo, or solo becoming a server.
final _alone = computed(() {
final s = SessionState.instance;
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
});
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
late final _online = computed(() => _graph.navigation.online.value);
/// Nobody else is in the server (D9): a plain player.
late final _alone = computed(() => !_graph.navigation.online.value || _graph.coordinator.alone.value);
late final _hasMedia =
computed(() => _graph.navigation.online.value && _graph.coordinator.serverState.value?.media != null);
/// A video is on in the session (not finished): the update bar stays
/// off the full player.
final _watching = computed(() {
final s = SessionState.instance;
final r = s.coordinator.serverState.value;
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
late final _watching = computed(() {
final r = _graph.coordinator.serverState.value;
return _graph.navigation.online.value && r?.media != null && r?.transport != 'finished';
});
PlayerMode get mode => _mode;
@@ -149,7 +145,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool get fullscreen => _fullscreen;
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
final f = SessionState.instance.lastForm;
final f = _graph.forms.form;
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
return c;
@@ -163,14 +159,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance;
final coord = session.coordinator;
final nav = _graph.navigation;
final coord = _graph.coordinator;
_feedSeen = coord.feedLog.peek().length;
_stops.add(effect(() {
final online = session.route.value == AppRoute.session;
final place = nav.place.value;
final online = place != Place.none;
// Set now, not later: the media effect below may run first in this
// same flush, and its scheduled show reads these.
_autoFull = online;
// same flush, and its scheduled show reads these. A friend's video
// opens full; back home, yours waits in the mini player.
_autoFull = place == Place.friend;
if (!online) _lastMediaId = -1;
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
}));
@@ -178,7 +176,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// after joining or a put-up of ours, else as the mini player).
_stops.add(effect(() {
final r = coord.serverState.value;
final online = session.route.value == AppRoute.session;
final online = nav.online.value;
if (!online || r == null || r.media == null) return;
if (r.mediaId == _lastMediaId) return;
_lastMediaId = r.mediaId;
@@ -218,31 +216,21 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
var wasOut = false;
_stops.add(effect(() {
final out = coord.selfAway.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) {
if (wasOut && !out && nav.online.peek()) {
_later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
}
wasOut = out;
}));
// Solo becoming a hosted server: the player closes while the media
// moves over and comes back the way it was once it is up again.
// Your server died (the navigation starts a new one): say so.
final yours = _graph.yourServer;
_stops.add(effect(() {
if (!session.switching.value) return;
_later(() {
_lastMediaId = -1;
_autoFull = _mode == PlayerMode.full;
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.none);
});
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
final crash = session.serverCrash.value;
final crash = yours.crash.value;
if (crash == null) return;
_later(() {
session.serverCrash.value = null;
_alert('Server crashed', crash);
yours.crash.value = null;
_alert('Your server crashed', '$crash\n\nA new one starts; anyone who was in it joins again.');
});
}));
}
@@ -261,7 +249,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_watching.dispose();
_flow.dispose();
_join.dispose();
_host.dispose();
_picker.dispose();
_chat.dispose();
_chatFocus.dispose();
@@ -291,8 +278,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _wentOnline() {
_unread = 0;
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
// Hosting starts with nothing up: the library is where to pick it.
_feedSeen = _graph.coordinator.feedLog.peek().length;
// A new server: the library is where to pick something.
setState(() => _tab = ShellTab.library);
}
@@ -338,7 +325,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// The player's ×: sit out (stay in the server, nothing loaded; the
/// player shows the join-back card). Once out, × closes the player.
void _closePlayer() {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
if (coord.selfAway.peek()) {
_show(PlayerMode.none);
} else {
@@ -349,7 +336,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return;
try {
await SessionState.instance.ports.shell.setFullScreen(on);
await _graph.ports.shell.setFullScreen(on);
} catch (_) {
// No window manager.
}
@@ -384,7 +371,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// "Change video": the library, opened on the series of the episode
/// playing (season selected, episode highlighted), else where it was.
void _changeVideo() {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
coord.markInput();
final ref = coord.currentRef.peek();
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
@@ -401,10 +388,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full. Offline
/// it starts a solo session first (just you, on this computer).
/// Library → "Put it up for everyone" (alone: Play); the player opens
/// full. Before your server is up (starting) it waits for it.
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
final coord = _graph.coordinator;
if (media is JellyfinMedia) {
try {
putUpJellyfinMedia(_jellyfin, media);
@@ -414,47 +401,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
unawaited(_jellyfin.rememberPick(media));
}
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
if (!await _graph.navigation.goHome()) return "couldn't start your server";
if (!mounted) return null;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) {
if (media is LinkMedia && coord.alone.peek()) {
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
}
final error = await session.coordinator.swapPicked(media);
final error = await coord.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
final f = _graph.forms.form;
f
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
unawaited(session.saveForm().catchError((Object _) {}));
unawaited(_graph.forms.save().catchError((Object _) {}));
_autoFull = true;
_show(PlayerMode.full);
return null;
}
/// Starts a solo session; returns an error text, or null.
Future<String?> _startSolo() async {
final profile = Profile.instance;
try {
await profile.ensureLoaded();
final name = profile.name.peek().trim();
await SessionState.instance.startSolo(ConnectParams(
host: '127.0.0.1',
port: 0,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: profile.avatarBase64(),
));
return null;
} catch (e) {
return "couldn't start playing: $e";
}
}
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
/// else Files).
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
/// The welcome band's "Watch on your own": somewhere to pick from
/// (Jellyfin when linked, else Files).
void _watchAlone() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
// --------------------------------------------------------------- handoff
@@ -464,7 +432,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) {
// Any key is activity (auto sit-out).
SessionState.instance.coordinator.markInput();
_graph.coordinator.markInput();
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
@@ -479,7 +447,7 @@ 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 {
final clip = await SessionState.instance.ports.shell.readPaste();
final clip = await _graph.ports.shell.readPaste();
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
}
@@ -493,7 +461,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context,
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
at: align, child: HandoffCard(item: item, alone: _alone.peek(), queue: _online.peek()));
} finally {
_handoffOpen = false;
}
@@ -502,7 +470,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.notify(ActionFailed(error));
_graph.coordinator.notify(ActionFailed(error));
} else {
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
@@ -511,15 +479,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// In order: with Play next, the last one added goes first.
final next = action == HandoffAction.playNext;
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
final error = await _graph.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.notify(ActionFailed(error));
_graph.coordinator.notify(ActionFailed(error));
break;
}
}
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
// In the Link page's mode, probed and refused like there.
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.notify(ActionFailed(error));
@@ -553,7 +521,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
// Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
Widget _dropTarget(Widget child) => _graph.ports.shell.dropTarget(
enable: _tab != ShellTab.settings,
onEnter: () => setState(() => _dragging = _onTop),
onExit: () => setState(() => _dragging = false),
@@ -581,18 +549,19 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
/// A friend's server: Leave asks first, then you are back on yours.
/// Yours has nothing to leave: the player closes ([_closePlayer]).
Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
final nav = _graph.navigation;
if (nav.place.peek() != Place.friend) {
_closePlayer();
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave server?'),
content: const Text('You can rejoin right away.'),
content: const Text('You go back to your own server. You can rejoin right away.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(
@@ -603,7 +572,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
],
),
);
if (ok == true) await SessionState.instance.leave();
if (ok == true) await nav.leaveFriend();
}
Future<void> _alert(String title, String text) async {
@@ -623,15 +592,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
}
void _connect(ConnectMode mode) => showConnectCard(
void _joinFriend() => showJoinCard(
context,
mode: mode,
flow: _flow,
join: _join,
host: _host,
healthGet: widget.healthGet,
updates: widget.updates,
);
void _invite() => unawaited(inviteFriends(context));
// ----------------------------------------------------------------- build
@override
@@ -658,10 +628,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
chatOpen: _chatOpen,
unread: _unread,
onChat: _toggleChat,
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onJoin: _joinFriend,
onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onInvite: _invite,
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
windowControls: widget.windowControls,
@@ -694,7 +663,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
),
);
// Any pointer use anywhere is activity (auto sit-out).
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
return Listener(
onPointerHover: (e) {
_pointer = e.localPosition;
@@ -709,41 +678,41 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
);
}
/// No Jellyfin account, no server joined or hosted yet, band not
/// No Jellyfin account, no friend's server joined yet, band not
/// dismissed: the welcome band explains the three ways in.
bool get _newcomer =>
!AppSettings.instance.welcomeDismissed.value &&
_jellyfin.isEmpty &&
SessionState.instance.recents.value.isEmpty;
!AppSettings.instance.welcomeDismissed.value && _jellyfin.isEmpty && _graph.forms.recents.value.isEmpty;
Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
final friend = _graph.navigation.place.value == Place.friend;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
solo: alone,
// Offline, a put-up starts a solo session, which hosts.
isHost: !online || coord.hostFile != null,
// Only for newcomers: the strip has Join and Host for everyone else.
banner: online || !_newcomer
alone: alone,
// Your server serves your files itself; a friend's relays them.
isHost: !friend,
// Only for newcomers alone at home: the strip has Join and Invite
// for everyone else.
banner: !alone || friend || !_newcomer
? null
: WelcomeBand(
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onSolo: _watchSolo,
onJoin: _joinFriend,
onInvite: _invite,
onWatch: _watchAlone,
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
),
jellyfinStart: _libraryStart,
onPutUp: _putUp,
// Up next needs a server (a solo session has one).
// Up next needs a server (yours, once it is up).
onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
probe: widget.linkProbe,
),
ShellTab.schedule => SchedulePage(
solo: alone,
alone: alone,
onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: (text) => coord.notify(ScheduleResult(text)),
+4 -6
View File
@@ -8,7 +8,7 @@ import '../session/identity.dart';
import '../session/delivery.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'motion.dart';
/// What an avatar shows: who, their picture or initial on [color], and
@@ -144,8 +144,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
}
/// Everyone in the server, from the latest state.
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
List<AvatarInfo> partyAvatars(SessionCoordinator c) {
final r = c.serverState.value;
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
}
@@ -158,7 +157,7 @@ class PartyWatch extends StatelessWidget {
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: SessionState.instance.coordinator.avatars,
listenable: AppScope.of(context).coordinator.avatars,
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
);
}
@@ -380,8 +379,7 @@ class Pill extends StatelessWidget {
/// Someone in a feed line or toast: by member [id] when it has one, else
/// by display [name]; from the latest state when still there, else their
/// initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
final c = coord ?? SessionState.instance.coordinator;
AvatarInfo avatarForName(String name, SessionCoordinator c, [String id = '']) {
final r = c.serverState.value;
final members = r?.members ?? const <MemberView>[];
// By id when the line has one (names change and repeat), else by name.
+10 -10
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/text_focus.dart';
@@ -55,7 +55,7 @@ class _ChatPanelState extends State<ChatPanel> {
}
void _send() {
SessionState.instance.coordinator.sendChat(widget.chat.text);
AppScope.of(context).coordinator.sendChat(widget.chat.text);
widget.chat.clear();
widget.chatFocus.requestFocus();
}
@@ -105,7 +105,7 @@ class _ChatPanelState extends State<ChatPanel> {
child: ValueListenableBuilder<bool>(
valueListenable: showActivity,
builder: (context, activity, _) => PartyWatch(builder: (context) {
final all = SessionState.instance.coordinator.feedLog.value;
final all = AppScope.of(context).coordinator.feedLog.value;
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
if (log.isEmpty) {
return Center(
@@ -169,7 +169,7 @@ class ActivityRow extends StatelessWidget {
anchors: state.contextMenuAnchors,
buttonItems: [
...state.contextMenuButtonItems,
for (final (label, action) in _messageActions(item.reason, link))
for (final (label, action) in _messageActions(context, item.reason, link))
ContextMenuButtonItem(
label: label,
onPressed: () {
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
StatusAvatar(info: avatarForName(item.actor, AppScope.of(context).coordinator, item.actorId), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -238,17 +238,17 @@ String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive:
/// What a chat message offers: Copy message, and its link's Copy link and
/// Open in browser.
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
List<(String, VoidCallback)> _messageActions(BuildContext context, String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(context, text, done: 'Message copied')),
if (link != null) ...[
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(link)),
(ReuseText.copyLink, () => copyToClipboard(context, link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(context, link)),
],
];
/// Right-click beside a chat message: [_messageActions] in the app's menu.
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
final actions = _messageActions(text, link);
final actions = _messageActions(context, text, link);
final pick = await showAppMenu<int>(context, items: [
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
]);
+47 -167
View File
@@ -2,12 +2,15 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../app/app_graph.dart';
import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.dart';
import '../screens/home/health_probe.dart';
import '../state/session_state.dart';
import '../session/invite.dart' show addressIn;
import '../session/navigation.dart' show Place;
import '../state/connect_prefs.dart';
import '../update/update_controller.dart';
import 'motion.dart';
/// Join card text, owned by the shell so it survives the card closing.
@@ -27,60 +30,45 @@ class JoinFields {
}
}
/// Host card text, owned by the shell so it survives the card closing.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret);
final TextEditingController port;
final TextEditingController secret;
void dispose() {
port.dispose();
secret.dispose();
}
}
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard(
/// The strip's Join a friend: a card over the window. It closes itself
/// once the join got in; a failed one shows its error inside.
Future<void> showJoinCard(
BuildContext context, {
required ConnectMode mode,
required ConnectFlow flow,
required JoinFields join,
required HostFields host,
HealthGetter healthGet = defaultHealthGet,
UpdateController? updates,
}) =>
showPopover<void>(
context,
dim: true,
at: const Alignment(0.8, -0.75),
child: _CloseWhenOnline(
child: mode == ConnectMode.host
? HostCard(flow: flow, fields: host)
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
child: _CloseInFriends(
child: JoinCard(flow: flow, fields: join, healthGet: healthGet, updates: updates),
),
);
class _CloseWhenOnline extends StatefulWidget {
class _CloseInFriends extends StatefulWidget {
final Widget child;
const _CloseWhenOnline({required this.child});
const _CloseInFriends({required this.child});
@override
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
State<_CloseInFriends> createState() => _CloseInFriendsState();
}
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
class _CloseInFriendsState extends State<_CloseInFriends> {
late final void Function() _stop;
@override
void initState() {
super.initState();
final navigation = AppScope.of(context).navigation;
var was = navigation.place.peek();
_stop = effect(() {
final s = SessionState.instance;
// Solo is in a session too: wait until it is a hosted server.
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
final place = navigation.place.value;
final arrived = place == Place.friend && was != Place.friend;
was = place;
if (!arrived) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
@@ -97,7 +85,7 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
Widget build(BuildContext context) => widget.child;
}
/// Card frame shared by Join and Host.
/// The card frame.
class _Card extends StatelessWidget {
final String title;
final List<Widget> children;
@@ -128,14 +116,15 @@ class _Card extends StatelessWidget {
const _dense = InputDecoration(isDense: true);
/// Recent servers as one-click rows (with live status), then address and
/// password.
/// password. Pasting an invite into the address fills the port too.
class JoinCard extends StatelessWidget {
final ConnectFlow flow;
final JoinFields fields;
final HealthGetter healthGet;
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
final UpdateController? updates;
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet, this.updates});
void _join() => flow.submit(ConnectMode.join, (f) {
void _join() => flow.submit((f) {
f
..host = fields.host.text
..port = fields.port.text.trim()
@@ -149,20 +138,27 @@ class JoinCard extends StatelessWidget {
_join();
}
/// An invite or "host:port" pasted into the address: split it.
void _onAddress(String v) {
flow.clearError();
final a = addressIn(v);
if (a == null || v.trim() == a.host) return;
fields.host.text = a.host;
fields.port.text = a.port;
}
@override
Widget build(BuildContext context) {
final forms = AppScope.of(context).forms;
return ListenableBuilder(
listenable: flow,
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
final busy = flow.busy;
return _Card(
title: 'Join a friend',
children: [
SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (!r.hosted) r
];
final list = forms.recents.value;
if (list.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -170,10 +166,10 @@ class JoinCard extends StatelessWidget {
const Text('Recent', style: TextStyle(color: Colors.grey)),
RecentServerRows(
servers: list,
enabled: flow.busy == null,
enabled: !busy,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
onForget: forms.forget,
),
const Divider(),
],
@@ -192,8 +188,8 @@ class JoinCard extends StatelessWidget {
key: const ValueKey('join-address'),
controller: fields.host,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP, hostname or invite'),
onChanged: _onAddress,
onSubmitted: (_) => _join(),
),
),
@@ -222,12 +218,13 @@ class JoinCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
ConnectButton(flow: flow, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, updates: updates),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you the address and password. No account or Jellyfin needed.',
'Your friend gives you their invite (the address) and the password, if any. '
'No account or Jellyfin needed. Your own server stays as it is.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
@@ -238,122 +235,6 @@ class JoinCard extends StatelessWidget {
}
}
/// Port and password; what to watch is picked afterwards, in the library.
class HostCard extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
const HostCard({super.key, required this.flow, required this.fields});
void _host() => flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text,
);
@override
Widget build(BuildContext context) {
// From a solo session this is "Invite friends": same server settings,
// and what plays carries over.
final solo = SessionState.instance.solo.peek();
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: solo ? 'Invite friends' : 'Host a server',
children: [
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('host-port'),
controller: fields.port,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => _host(),
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: fields.secret,
builder: (context, _) => TextField(
key: const ValueKey('host-password'),
controller: fields.secret,
obscureText: true,
decoration: _dense.copyWith(
labelText: 'Password (optional)',
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
),
onSubmitted: (_) => _host(),
),
),
_HostedRecents(fields: fields, enabled: flow.busy == null),
],
),
),
const SizedBox(height: 16),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
solo
? 'Your video keeps playing from where it is; friends join with your address.'
: 'You pick what to watch afterwards, in the library.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}
/// Ports hosted before: one click refills port and password.
class _HostedRecents extends StatelessWidget {
final HostFields fields;
final bool enabled;
const _HostedRecents({required this.fields, required this.enabled});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (r.hosted) r
];
if (list.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in list)
InputChip(
key: ValueKey('recent-${r.key}'),
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
label: Text('Port ${r.port}'),
tooltip: 'Use port ${r.port} again',
isEnabled: enabled,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () {
fields.port.text = r.port;
fields.secret.text = r.secret;
},
deleteButtonTooltipMessage: 'Forget',
onDeleted: () => SessionState.instance.forgetServer(r),
),
],
),
);
});
}
}
/// Recent servers as rows; polls each one's `/health` every
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
class RecentServerRows extends StatefulWidget {
@@ -476,8 +357,7 @@ class _RecentRow extends StatelessWidget {
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
if (h != null && h.needsUpdate)
Tooltip(
message: 'Server protocol ${h.proto}, this app '
'${RelayConstants.protoVersion} — update so both match',
message: versionGateText(h),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
+7 -7
View File
@@ -99,7 +99,7 @@ Handoff classifyHandoff({
/// What the card's buttons ask the shell to do.
enum HandoffAction {
/// Put the (first) video up (offline: watch it solo).
/// Put the (first) video up.
putUp,
/// Add the video's folder (or the folder) to Files and open it.
addFolder,
@@ -115,11 +115,11 @@ enum HandoffAction {
/// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget {
final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
/// Nobody else is in the server: videos play, no relay talk.
final bool alone;
/// In a server: videos and links can go to Up next.
final bool queue;
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
const HandoffCard({super.key, required this.item, required this.alone, this.queue = false});
@override
Widget build(BuildContext context) {
@@ -144,7 +144,7 @@ class HandoffCard extends StatelessWidget {
baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.putUp, Icons.play_arrow, alone ? 'Play' : 'Put it up', primary: true),
...queueButtons,
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
@@ -158,9 +158,9 @@ class HandoffCard extends StatelessWidget {
HandoffLink(:final url) => (
Icons.link,
url,
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
alone ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
[
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
button(HandoffAction.openLink, Icons.link, alone ? 'Open in Link' : 'Put it up as a link…',
primary: true),
...queueButtons,
],
+32 -30
View File
@@ -10,7 +10,7 @@ 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 '../app/app_graph.dart';
import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link
@@ -239,7 +239,6 @@ class LinkPreviewStore {
/// What a link is (see [LinkResolver.probe]).
typedef LinkProber = Future<LinkProbe> Function(String 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;
@@ -254,7 +253,7 @@ String clockText(double seconds) {
/// duration and thumbnail; a media file its file name and host; a link
/// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
Future<LinkPreview?> previewLink(String url, {required LinkProber probe}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri);
@@ -290,7 +289,7 @@ Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProb
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
/// else probed and kept (the Up next popover's paste; the Link page has
/// its own refresh rules).
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, required LinkProber probe}) async {
final store = (recents ?? RecentLinks.instance).previews;
await store.ensureLoaded();
final kept = store.lookup(url);
@@ -344,27 +343,28 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
};
/// Under Play when nobody else watches.
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
const alonePlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
/// The Link source. [solo]: nobody else watches (offline or solo), so the
/// The Link source. [alone]: nobody else is in the server, so the
/// button says Play and there are no relay modes (the link plays direct).
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final bool solo;
final bool alone;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
/// Adds the link to Up next in the chosen mode; null: no server.
final AddToQueue? onAddToQueue;
final RecentLinks recents;
final LinkProber probe;
/// What a link is; defaults to the app's [LinkResolver.probe].
final LinkProber? probe;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
this.onAddToQueue,
required this.recents,
this.solo = false,
this.probe = defaultLinkProber,
this.alone = false,
this.probe,
});
@override
@@ -372,6 +372,8 @@ class LibraryLinkSource extends StatefulWidget {
}
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
LinkProber get _probe => widget.probe ?? AppScope.of(context).ports.links.probe;
MediaPickerController get _c => widget.controller;
final _previews = <String, LinkPreview?>{};
String _previewUrl = '';
@@ -450,7 +452,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
return;
}
final p = await previewLink(url, probe: widget.probe);
final p = await previewLink(url, probe: _probe);
_previews[url] = p;
_keep(url, p);
if (mounted) setState(() {});
@@ -459,7 +461,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
/// Probes [url] again behind what is shown; a site out of reach keeps
/// what was found before. Returns the preview shown afterwards.
Future<LinkPreview?> _refresh(String url) async {
final p = await previewLink(url, probe: widget.probe);
final p = await previewLink(url, probe: _probe);
if (p == null || p.problem == unreachableText) return _previews[url];
_previews[url] = p;
_keep(url, p);
@@ -491,12 +493,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
// What the probe found refuses it here, with "Put it up anyway": the
// server or a friend (with cookies) may still open it.
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: _probe))?.problem;
String? error = problem;
if (problem == null) {
try {
error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
LinkMedia(url, relay: widget.alone ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) {
error = '$e';
}
@@ -510,7 +512,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (error == null) {
var p = _previews[url];
if (p == null) {
p = _previews[url] = await previewLink(url, probe: widget.probe);
p = _previews[url] = await previewLink(url, probe: _probe);
_keep(url, p);
}
unawaited(widget.recents.add(RecentLink(
@@ -542,7 +544,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
String? error;
try {
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
error = await add(LinkMedia(link, relay: widget.alone ? LinkRelay.direct : _c.linkRelay), next: next);
} catch (e) {
error = '$e';
}
@@ -556,7 +558,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
/// Right-click on a recent link: put it up or queue it, copy or open it.
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
final pick = await showAppMenu<String>(context, items: [
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
AppMenuItem(value: 'up', label: widget.alone ? 'Play' : 'Put it up'),
if (widget.onAddToQueue != null) ...const [
AppMenuItem(value: 'queue', label: 'Add to queue'),
AppMenuItem(value: 'next', label: 'Play next'),
@@ -565,14 +567,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
if (pick == null || !mounted) return;
if (pick == null || !mounted || !context.mounted) return;
switch (pick) {
case 'copy':
return copyToClipboard(l.url, done: 'Link copied');
return copyToClipboard(context, l.url, done: 'Link copied');
case 'title':
return copyToClipboard(l.title, done: 'Title copied');
return copyToClipboard(context, l.title, done: 'Title copied');
case 'open':
return openLink(l.url);
return openLink(context, l.url);
}
_c.link.text = l.url;
switch (pick) {
@@ -592,11 +594,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
]);
switch (pick) {
case 'copy':
await copyToClipboard(url, done: 'Link copied');
if (context.mounted) await copyToClipboard(context, url, done: 'Link copied');
case 'title':
await copyToClipboard(p!.title, done: 'Title copied');
if (context.mounted) await copyToClipboard(context, p!.title, done: 'Title copied');
case 'open':
await openLink(url);
if (context.mounted) await openLink(context, url);
}
}
@@ -614,7 +616,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final solo = widget.solo;
final alone = widget.alone;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -638,7 +640,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(solo ? 'Play' : 'Put it up'),
label: Text(alone ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -705,10 +707,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (queue != null) ...[const SizedBox(width: 8), queue],
]),
),
if (solo || _error != null) ...[
if (alone || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? soloPlayHint,
_error ?? alonePlayHint,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
@@ -718,7 +720,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
child: Text(alone ? 'Play it anyway' : 'Put it up anyway'),
),
),
],
@@ -737,7 +739,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
),
),
),
if (!solo) ...[
if (!alone) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
+17 -17
View File
@@ -14,7 +14,7 @@ import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../session/app_events.dart' show ActionFailed;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'library_link.dart';
import 'motion.dart';
import 'welcome.dart';
@@ -68,12 +68,11 @@ const detailsDockFrom = 1000.0;
/// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. With
/// nobody else watching ([solo]: offline or a solo session) the buttons
/// say Play and putting something up starts (or uses) a solo session.
/// nobody else in the server ([alone], D9) the buttons say Play.
class LibraryTab extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool solo;
final bool alone;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
@@ -85,9 +84,10 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final LinkProber probe;
/// What a link is; defaults to the app's.
final LinkProber? probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
final Widget Function(BuildContext context, {required bool alone, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
@@ -97,7 +97,7 @@ class LibraryTab extends StatefulWidget {
super.key,
required this.controller,
required this.accounts,
required this.solo,
required this.alone,
required this.isHost,
required this.onPutUp,
this.onAddToQueue,
@@ -105,7 +105,7 @@ class LibraryTab extends StatefulWidget {
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.probe = defaultLinkProber,
this.probe,
this.filesBuilder,
this.now = DateTime.now,
this.banner,
@@ -200,14 +200,14 @@ class _LibraryTabState extends State<LibraryTab> {
/// A card's menu: errors as a toast (the Notifier's).
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
final error = await _queueJellyfin(sel, next: next);
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
}
/// Double-click on a card: put it up, errors as a toast.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
final error = await _putUpJellyfin(sel);
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
}
// -------------------------------------------------------------- build
@@ -271,7 +271,7 @@ class _LibraryTabState extends State<LibraryTab> {
),
MediaKind.link => LibraryLinkSource(
controller: _c,
solo: widget.solo,
alone: widget.alone,
recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe,
onPutUp: _put,
@@ -305,7 +305,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
solo: widget.solo,
alone: widget.alone,
onPutUp: _putUpJellyfin,
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
onClose: () => _c.select(null),
@@ -366,17 +366,17 @@ class _LibraryTabState extends State<LibraryTab> {
final relayed = !widget.isHost;
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
final graph = AppScope.of(context);
final now = graph.navigation.online.value
? nowPlayingFile(graph.coordinator.serverState.value?.media, graph.forms.form.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
return b(context, alone: widget.alone, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
final add = widget.onAddToQueue;
return FilesPage(
solo: widget.solo,
alone: widget.alone,
relayed: relayed,
onPlay: play,
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
+3 -3
View File
@@ -3,7 +3,7 @@ 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 '../app/app_graph.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart';
@@ -17,8 +17,8 @@ const markProblemKey = SingleActivator(LogicalKeyboardKey.keyB, control: true, s
/// Asks for the note, then marks; the toast says where it went.
Future<void> showMarkProblem(BuildContext context) async {
final note = await showPopover<String>(context, at: Alignment.center, child: const MarkProblemCard());
if (note == null) return;
final coord = SessionState.instance.coordinator;
if (note == null || !context.mounted) return;
final coord = AppScope.of(context).coordinator;
final inSession = await coord.markProblem(note);
coord.notify(ProblemMarked(inSession: inSession));
}
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'motion.dart';
@@ -15,22 +15,22 @@ Uint8List? Function(String hash)? debugArtBytes;
/// The one lookup for media art (cover, banner) by content hash: null
/// when there is none or it has not arrived yet (listeners of
/// [artChanges] hear when it lands).
Uint8List? artBytes(String hash) {
Uint8List? artBytes(BuildContext context, String hash) {
if (hash.isEmpty) return null;
final debug = debugArtBytes;
if (debug != null) return debug(hash);
return SessionState.instance.coordinator.art.bytesFor(hash);
return AppScope.of(context).coordinator.art.bytesFor(hash);
}
/// Notifies when art arrives.
Listenable artChanges() => SessionState.instance.coordinator.art;
Listenable artChanges(BuildContext context) => AppScope.of(context).coordinator.art;
final _images = <String, MemoryImage>{};
/// [hash]'s art as an image, the same provider while the bytes are the
/// same (so a rebuild never decodes again).
ImageProvider? artImage(String hash) {
final bytes = artBytes(hash);
ImageProvider? artImage(BuildContext context, String hash) {
final bytes = artBytes(context, hash);
if (bytes == null) return null;
final hit = _images[hash];
if (hit != null && identical(hit.bytes, bytes)) return hit;
@@ -59,7 +59,7 @@ class ArtCover extends StatelessWidget {
@override
Widget build(BuildContext context) {
final image = artImage(hash);
final image = artImage(context, hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container(
width: wide ? width * 1.5 * 16 / 9 : width,
@@ -166,10 +166,10 @@ class NowPlayingTitle extends StatelessWidget {
Widget build(BuildContext context) => GestureDetector(
onSecondaryTap: () => nowPlayingMenu(context),
child: ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
now: AppScope.of(context).coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
@@ -182,7 +182,7 @@ class NowPlayingTitle extends StatelessWidget {
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
/// Open in Jellyfin. [context]: where the menu opens.
Future<void> nowPlayingMenu(BuildContext context) async {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final now = coord.nowPlaying.peek();
if (now == null) return;
final link = coord.currentLink();
@@ -200,12 +200,12 @@ Future<void> nowPlayingMenu(BuildContext context) async {
]);
switch (pick) {
case 'title':
await copyToClipboard(title, done: 'Title copied');
if (context.mounted) await copyToClipboard(context, title, done: 'Title copied');
case 'link':
await copyToClipboard(link!, done: 'Link copied');
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
case 'open':
await openLink(link!);
if (context.mounted) await openLink(context, link!);
case 'jellyfin':
await openLink(page!);
if (context.mounted) await openLink(context, page!);
}
}
+193 -216
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
@@ -7,7 +5,10 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart';
import '../session/party.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart';
import '../session/navigation.dart' show Place;
import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
@@ -36,9 +37,11 @@ const stripAvatarSize = 42.0;
const stripCompactBelow = 1100.0;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
/// the party. Alone on your own server it is a plain player's: Join a
/// friend and Invite (a "Sharing" chip while your server is open to
/// friends); with others there the avatars (members card), server and
/// readiness, chat, and Invite (yours) or Leave (a friend's). Empty space
/// drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
@@ -46,11 +49,11 @@ class PartyStrip extends StatelessWidget {
final int unread;
final VoidCallback onChat;
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Opens your server to friends (if it is not yet) and copies the
/// invite.
final VoidCallback onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
@@ -65,10 +68,9 @@ class PartyStrip extends StatelessWidget {
required this.unread,
required this.onChat,
required this.onJoin,
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onInvite,
required this.onInvite,
this.onShowPlayer,
this.windowControls = true,
});
@@ -79,11 +81,18 @@ class PartyStrip extends StatelessWidget {
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
final row = SignalBuilder(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null;
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final online = nav.online.value;
final home = nav.home.value;
final friend = nav.place.value == Place.friend;
// Others there (or a friend's server): the party; else a plain
// player (D9).
final social = friend || (online && !coord.alone.value);
final shared = graph.yourServer.shared.value;
final canInvite = graph.yourServer.port.value != null && !graph.yourServer.busy.value;
final self = social ? coord.selfMember.value : null;
return Row(
children: [
const SizedBox(width: 16),
@@ -97,11 +106,11 @@ class PartyStrip extends StatelessWidget {
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null,
host: social && home,
error: online ? coord.playerError.value : '',
),
size: stripAvatarSize,
ring: online,
ring: social,
),
),
),
@@ -140,29 +149,7 @@ class PartyStrip extends StatelessWidget {
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (solo) ...[
_SoloChip(compact: compact, busy: session.switching.value),
const SizedBox(width: 10),
if (compact)
FilledButton(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
child: const Text('Invite'),
)
else
FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite friends'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: session.switching.value ? null : onLeave,
child: const Text('Stop'),
),
] else if (online) ...[
if (social) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
@@ -196,41 +183,30 @@ class PartyStrip extends StatelessWidget {
),
),
const SizedBox(width: 4),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
),
if (friend)
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
)
else
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
] else ...[
if (!compact) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(width: 12),
],
if (compact) ...[
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
if (shared != null) ...[
_SharingChip(compact: compact, port: shared.port),
const SizedBox(width: 8),
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
] else ...[
FilledButton.icon(
],
if (compact)
FilledButton.tonal(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join'))
else
FilledButton.tonalIcon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
const SizedBox(width: 8),
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
],
const SizedBox(width: 8),
if (windowControls) ...[
@@ -282,14 +258,15 @@ class _ServerLabel extends StatelessWidget {
builder: (context) {
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
// counts who the presence policy counts), from the one status.
final line = SessionState.instance.coordinator.status.value.party;
final graph = AppScope.of(context);
final line = graph.coordinator.status.value.party;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
serverName(),
serverName(graph),
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
@@ -306,50 +283,74 @@ class _ServerLabel extends StatelessWidget {
);
}
/// "Solo" alone, "Your server" when hosting, else the joined server's
/// name (its `--name`), or its address while unknown.
String serverName() {
final session = SessionState.instance;
if (session.solo.value) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server';
final label = session.serverLabel.value;
return label.isNotEmpty ? label : session.lastForm.host;
/// "Your server" at home, else the joined server's name (its `--name`),
/// or its address while unknown.
String serverName(AppGraph graph) {
final nav = graph.navigation;
if (nav.home.value) return 'Your server';
final label = nav.serverLabel.value;
return label.isNotEmpty ? label : nav.address.value;
}
/// Solo: just you, on your own; a spinner while it becomes a server.
class _SoloChip extends StatelessWidget {
/// Invite: opens your server to friends (if it is not yet) and copies
/// the invite.
class _InviteButton extends StatelessWidget {
final bool compact;
final bool busy;
const _SoloChip({required this.compact, required this.busy});
final VoidCallback? onInvite;
const _InviteButton({required this.compact, required this.onInvite});
@override
Widget build(BuildContext context) {
const tip = 'Copy an invite friends can join with (opens your server to them)';
if (compact) {
return Tooltip(
message: tip,
child: FilledButton(key: const ValueKey('strip-invite'), onPressed: onInvite, child: const Text('Invite')),
);
}
return Tooltip(
message: tip,
child: FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite'),
),
);
}
}
/// Your server is open to friends and nobody came yet: the port; a click
/// opens the members card (the invite, Stop sharing).
class _SharingChip extends StatelessWidget {
final bool compact;
final int port;
const _SharingChip({required this.compact, required this.port});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: busy
? 'Starting your server…'
: 'Watching on your own. Nobody can join until you invite friends.',
child: Container(
key: const ValueKey('solo-chip'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
message: 'Friends can join with your invite. Click for the invite or to stop sharing.',
child: InkWell(
key: const ValueKey('sharing-chip'),
borderRadius: BorderRadius.circular(20),
onTap: () => showMembersCard(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
if (!compact) ...[
const SizedBox(width: 6),
Text('port $port', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
],
),
),
),
);
@@ -425,50 +426,16 @@ class StripTab extends StatelessWidget {
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// per member force ready, nudge and sit out, and the address to invite
/// friends.
/// per member force ready, nudge and sit out, and the invite (yours) or
/// the address (a friend's).
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
/// loopback nor link-local, private (LAN) ranges first, no repeats.
List<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
bool private(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
final usable = [
for (final a in addresses)
if (a.type == InternetAddressType.IPv4 &&
!a.isLoopback &&
!a.isLinkLocal &&
!(a.rawAddress[0] == 169 && a.rawAddress[1] == 254))
a,
];
final ordered = [
...usable.where((a) => private(a.rawAddress)),
...usable.where((a) => !private(a.rawAddress)),
];
return {for (final a in ordered) '${a.address}:$port'}.toList();
}
/// This machine's [inviteAddresses] (empty when the interfaces can't be
/// listed).
Future<List<String>> lanInviteAddresses(String port) async {
try {
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
return inviteAddresses([for (final i in interfaces) ...i.addresses], port);
} catch (e) {
debugPrint('list network interfaces failed: $e');
return const [];
}
}
class MembersCard extends StatelessWidget {
/// This machine's invite addresses for a port (tests pass fixed ones).
final Future<List<String>> Function(String port) lanAddresses;
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
const MembersCard({super.key});
@override
Widget build(BuildContext context) {
@@ -481,15 +448,14 @@ class MembersCard extends StatelessWidget {
width: 340,
child: PartyWatch(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final r = coord.serverState.value;
final hosting = coord.hostFile != null;
final address = hosting
? null
: '${session.lastForm.host}:${session.lastForm.port}';
final home = nav.home.value;
final address = home ? null : nav.address.value;
final delivery = coord.status.value.delivery;
final server = session.serverVersion.value;
final server = nav.serverVersion.value;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -500,7 +466,7 @@ class MembersCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
['In the server', ?address].join(' · '),
home ? 'Your server' : ['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (server.isNotEmpty)
@@ -531,23 +497,20 @@ class MembersCard extends StatelessWidget {
),
),
const Divider(),
if (address != null)
if (home)
const YourInvite()
else if (address != null && address.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
),
)
else
_HostInvite(
port: session.lastForm.hostPort,
lanAddresses: lanAddresses,
),
],
);
@@ -571,7 +534,7 @@ class _MemberTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final m = member;
final since = m.goneSince;
final status = m.presence == Presence.gone
@@ -660,76 +623,90 @@ class VersionText extends StatelessWidget {
bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget {
final String port;
final Future<List<String>> Function(String port) lanAddresses;
const _HostInvite({required this.port, required this.lanAddresses});
@override
State<_HostInvite> createState() => _HostInviteState();
}
class _HostInviteState extends State<_HostInvite> {
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
/// Your server in the members card: shared, the invite's address
/// (Copy invite), a hint for friends outside the network and Stop
/// sharing; not shared, Invite (opens it and copies the invite).
class YourInvite extends StatelessWidget {
const YourInvite({super.key});
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return FutureBuilder<List<String>>(
future: _addresses,
builder: (context, snap) {
final list = snap.data ?? const <String>[];
void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('host-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
final graph = AppScope.of(context);
final yours = graph.yourServer;
return SignalBuilder(builder: (context) {
final invite = yours.shared.value;
final busy = yours.busy.value || yours.port.value == null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invite == null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
style: TextStyle(color: muted, fontSize: 12)),
),
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: list.isEmpty ? null : () => copy(list.first),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Invite'),
),
if (snap.connectionState != ConnectionState.done)
const SizedBox.shrink()
else if (list.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text('No network address found', style: TextStyle(color: muted)),
)
else
for (final a in list)
InkWell(
key: ValueKey('invite-$a'),
borderRadius: BorderRadius.circular(8),
onTap: () => copy(a),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
Icon(Icons.copy, size: 14, color: muted),
],
),
),
),
] else ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(invite.address,
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
),
Wrap(
spacing: 4,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite'),
),
TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
child: Text(
'Works for friends on your network. Friends elsewhere need port '
'${widget.port} forwarded on your router, or a server they can '
'all reach (like a VPS).',
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
'forwarded on your router, or a server they can all reach (like a VPS). '
'Stop sharing sends everyone else out; you and Up next stay.',
style: TextStyle(color: muted, fontSize: 12),
),
),
],
),
);
},
);
],
),
);
});
}
}
/// Invite: opens your server to friends if it is not yet and copies the
/// invite ("Invite copied"); a port that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
try {
invite = await graph.yourServer.invite();
} catch (e) {
final port = graph.forms.form.hostPort;
graph.notifier.notify(ActionFailed(
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
return;
}
if (!context.mounted) return;
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
}
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart';
import 'motion.dart';
@@ -13,8 +13,8 @@ export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, Stat
/// The pill's line from the session's [SessionCoordinator.status]
/// (tracked: call inside a builder); "Connecting…" only once it has taken
/// [PlayerStatus.connectGrace] ([slowConnect]).
StatusLine? playerStatusNow({bool slowConnect = false}) {
final line = SessionState.instance.coordinator.status.value.line;
StatusLine? playerStatusNow(SessionCoordinator coord, {bool slowConnect = false}) {
final line = coord.status.value.line;
if (line?.kind == StatusKind.connecting && !slowConnect) return null;
return line;
}
@@ -73,9 +73,9 @@ class _PlayerStatusState extends State<PlayerStatus> {
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
_trackConnecting(coord.status.value.line);
final view = playerStatusNow(slowConnect: _slow);
final view = playerStatusNow(coord, slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
final ready = view?.action == StatusAction.ready;
+9 -8
View File
@@ -10,7 +10,7 @@ import 'package:sync_relay_core/core.dart' show Presence;
import '../jellyfin/accounts.dart';
import '../session/identity.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -74,11 +74,12 @@ class _ProfileCardState extends State<ProfileCard> {
child: SizedBox(
width: 360,
child: SignalBuilder(builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final graph = AppScope.of(context);
final coord = graph.coordinator;
// Alone it is a plain player: no ring, no crown.
final online = graph.navigation.online.value && !coord.alone.value;
final self = online ? coord.selfMember.value : null;
final hosting = online && !session.solo.value && coord.hostFile != null;
final hosting = online && graph.navigation.home.value;
final color = _p.color.value;
final png = _p.picture.value;
final out = self?.presence == Presence.away;
@@ -164,7 +165,7 @@ class _ProfileCardState extends State<ProfileCard> {
runSpacing: 4,
children: [
if (hosting) const Pill('host', Colors.amber),
Pill(online ? 'in a server' : 'offline',
Pill(online ? 'with others' : 'just you',
online ? Colors.greenAccent : Colors.grey),
if (online)
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
@@ -231,7 +232,7 @@ class _ProfileCardState extends State<ProfileCard> {
child: Text(
online
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
: 'Others see your name and picture when you join. Colour is yours only.',
: 'Others see your name and picture when they join you or you join them. Colour is yours only.',
style: small,
),
),
@@ -331,7 +332,7 @@ class _CropPickerState extends State<CropPicker> {
Future<void> _pick() async {
final List<String> files;
try {
files = await SessionState.instance.ports.folders.pickFiles(
files = await AppScope.of(context).ports.folders.pickFiles(
dialogTitle: 'Pick a picture',
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
);
+10 -7
View File
@@ -18,7 +18,7 @@ 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';
import '../app/app_graph.dart';
import '../state/settings.dart';
import '../widgets/app_keys.dart';
import '../widgets/text_focus.dart';
@@ -122,12 +122,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
onLeave: () => widget.onLeave(),
);
SessionCoordinator get _coord => SessionState.instance.coordinator;
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
bool get _full => widget.mode == PlayerMode.full;
@override
void initState() {
super.initState();
// The model reads it in dispose too, when the tree is no longer there.
_model;
_coord.jellyfin.ensureLoaded();
HardwareKeyboard.instance.addHandler(_globalKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
@@ -173,8 +175,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// app-wide ([AppKeys]).
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
// Mini: Esc is the last step out, asking to leave the server.
if (!AppScope.of(context).navigation.online.peek()) return false;
// Mini: Esc is the last step out (a friend's server: asking to leave
// it; yours: the player closes).
if (!_full) {
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
@@ -301,7 +304,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_full) ...[
_blockedCard(),
_actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
_volumeReadout(),
] else
_miniOverlay(),
@@ -641,7 +644,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow()?.short ?? '',
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
@@ -786,7 +789,7 @@ class _MiniSeekBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final playback = SessionState.instance.coordinator.playback;
final playback = AppScope.of(context).coordinator.playback;
return SignalBuilder(builder: (context) {
final d = playback.duration.value;
final p = playback.position.value;
+32 -28
View File
@@ -17,7 +17,7 @@ import '../screens/home/ui.dart' show appName, displayVersion;
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../widgets/app_menu.dart';
@@ -86,7 +86,7 @@ class SettingsTab extends StatelessWidget {
];
case SettingsSection.network:
return [
const HostDefaultsPanel(),
const YourServerPanel(),
const RecentServersPanel(),
SettingsPanel(
title: 'Watching',
@@ -258,16 +258,18 @@ class SettingsTab extends StatelessWidget {
}
}
/// Party & network: what the Host card starts with.
class HostDefaultsPanel extends StatefulWidget {
const HostDefaultsPanel({super.key});
/// Party & network: your server's port and password, used when you
/// Invite (the next time sharing starts).
class YourServerPanel extends StatefulWidget {
const YourServerPanel({super.key});
@override
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
State<YourServerPanel> createState() => _YourServerPanelState();
}
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
final _form = SessionState.instance.lastForm;
class _YourServerPanelState extends State<YourServerPanel> {
late final _forms = AppScope.of(context).forms;
late final _form = _forms.form;
late final _port = TextEditingController(text: _form.hostPort);
late final _secret = TextEditingController(text: _form.hostSecret);
bool _show = false;
@@ -286,22 +288,22 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
await _forms.save();
} catch (e) {
debugPrint('save host defaults failed: $e');
debugPrint('save your server settings failed: $e');
}
}
@override
Widget build(BuildContext context) {
return SettingsPanel(
title: 'Hosting',
title: 'Your server',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingRow(
label: 'Default port',
help: 'Where your server listens when you host; friends dial it',
label: 'Port',
help: 'Where friends reach your server once you Invite (the next time you start sharing)',
control: SizedBox(
width: 120,
child: TextField(
@@ -320,7 +322,8 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
),
SettingRow(
label: 'Password',
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
help: 'Asked of every friend joining your server; blank lets in anyone who reaches the port '
'(the next time you start sharing)',
control: SizedBox(
width: 220,
child: TextField(
@@ -355,16 +358,16 @@ class RecentServersPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final forms = AppScope.of(context).forms;
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'Recent servers',
child: SignalBuilder(builder: (context) {
final list = session.recents.value;
final list = forms.recents.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join and Host offer these in one click; remove the ones you no longer use',
Text('Join offers these in one click; remove the ones you no longer use',
style: TextStyle(color: muted, fontSize: 12)),
if (list.isEmpty)
Padding(
@@ -375,13 +378,13 @@ class RecentServersPanel extends StatelessWidget {
ListTile(
key: ValueKey('recent-${r.key}'),
contentPadding: EdgeInsets.zero,
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
leading: const Icon(Icons.dns_outlined),
title: Text('${r.host}:${r.port}'),
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () => session.forgetServer(r),
onPressed: () => forms.forget(r),
),
),
],
@@ -403,7 +406,7 @@ class YtDlpSettings extends StatefulWidget {
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
late final Future<YtDlp> _y = widget.ytDlp ?? AppScope.of(context).ports.links.ytDlp;
@override
Widget build(BuildContext context) {
@@ -488,7 +491,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
Future<void> _pickFile() async {
final List<String> files;
try {
files = await SessionState.instance.ports.folders.pickFiles(
files = await AppScope.of(context).ports.folders.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
extensions: const ['txt', 'json'],
);
@@ -548,7 +551,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SessionState.instance.ports.shell.dropTarget(
return AppScope.of(context).ports.shell.dropTarget(
onEnter: () => setState(() => _dragging = true),
onExit: () => setState(() => _dragging = false),
onDrop: (paths, _) {
@@ -835,7 +838,8 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
void _preview(BuildContext context, SfxKind kind) =>
unawaited(AppScope.of(context).sfx.preview(kind, readyIndex: 2));
@override
Widget build(BuildContext context) {
@@ -859,7 +863,7 @@ class SoundSettings extends StatelessWidget {
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
onChangeEnd: (_) => _preview(context, SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
@@ -876,7 +880,7 @@ class SoundSettings extends StatelessWidget {
value: overall,
onChanged: (v) {
s.setSoundPreset(v);
_preview(SfxKind.ready);
_preview(context, SfxKind.ready);
},
items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
@@ -894,7 +898,7 @@ class SoundSettings extends StatelessWidget {
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
onPressed: volume == 0 ? null : () => _preview(context, kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
@@ -910,7 +914,7 @@ class SoundSettings extends StatelessWidget {
style: muted,
onChanged: (v) {
s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind);
if (volume > 0) _preview(context, kind);
},
items: [
(null, 'Same as set'),
+3 -3
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/media_marks.dart' show formatClock;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../sync/state_diff.dart' show serverPositionAt;
import 'now_playing_title.dart';
import 'start_banner.dart';
@@ -41,10 +41,10 @@ class _SitOutCardState extends State<SitOutCard> {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final small = widget.compact;
return ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
+5 -5
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'motion.dart';
import 'now_playing_title.dart';
@@ -21,8 +21,8 @@ class StartCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final banner = artImage(now.bannerHash);
final cover = banner == null ? artImage(now.posterHash) : null;
final banner = artImage(context, now.bannerHash);
final cover = banner == null ? artImage(context, now.posterHash) : null;
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
@@ -145,10 +145,10 @@ class _StartBannerState extends State<StartBanner> {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
+4 -4
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -73,7 +73,7 @@ class _PartyToastsState extends State<PartyToasts> {
@override
void initState() {
super.initState();
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
// What was there before this mounted is old news.
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
@@ -127,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) {
widget.onOpenChat?.call();
SessionState.instance.notifier.dismiss(t.id);
AppScope.of(context).notifier.dismiss(t.id);
}
@override
@@ -282,7 +282,7 @@ class _Thumb extends StatelessWidget {
@override
Widget build(BuildContext context) {
final thumbs = SessionState.instance.coordinator.queue.thumbs;
final thumbs = AppScope.of(context).coordinator.queue.thumbs;
return ClipRRect(
key: const ValueKey('toast-thumb'),
borderRadius: BorderRadius.circular(6),
+16 -15
View File
@@ -12,15 +12,16 @@ const appTagline = 'Watch together, in sync';
/// the app name (else under it).
const welcomeWideFrom = 980.0;
/// Top of the Library while not in a server: the app's name and the three
/// ways in, Join a friend first (what most people came for), then Host and
/// Watch solo. None of them needs Jellyfin.
/// Top of the Library for newcomers alone on their own server: the app's
/// name and the three ways in, Join a friend first (what most people came
/// for), then Invite friends and Watch on your own. None of them needs
/// Jellyfin.
class WelcomeBand extends StatelessWidget {
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onSolo;
final VoidCallback onInvite;
final VoidCallback onWatch;
final VoidCallback? onDismiss;
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
const WelcomeBand({super.key, required this.onJoin, required this.onInvite, required this.onWatch, this.onDismiss});
@override
Widget build(BuildContext context) {
@@ -63,23 +64,23 @@ class WelcomeBand extends StatelessWidget {
key: const ValueKey('way-join'),
icon: Icons.login_rounded,
title: 'Join a friend',
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
caption: 'Got an invite from a friend? Join with it. Nothing to set up.',
primary: true,
onTap: onJoin,
),
WayCard(
key: const ValueKey('way-host'),
icon: Icons.podcasts_rounded,
title: 'Host',
caption: 'You have the video. Friends join you and watch it with you.',
onTap: onHost,
key: const ValueKey('way-invite'),
icon: Icons.group_add_outlined,
title: 'Invite friends',
caption: 'You have the video. Copy an invite; friends join you and watch it with you.',
onTap: onInvite,
),
WayCard(
key: const ValueKey('way-solo'),
key: const ValueKey('way-watch'),
icon: Icons.person_outline_rounded,
title: 'Watch solo',
title: 'Watch on your own',
caption: 'Just you: play anything below. Invite friends any time.',
onTap: onSolo,
onTap: onWatch,
),
];
Widget row(List<Widget> cards) => IntrinsicHeight(
@@ -1,4 +1,8 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:hive_ce/hive.dart';
import 'package:signals/signals.dart';
const connectFormBoxName = 'connect_form';
@@ -19,3 +23,165 @@ String readString(Box box, String key, String fallback) {
final v = box.get(key);
return v is String ? v : fallback;
}
const connectKeyHostPort = 'hostPort';
const connectKeyHostSecret = 'hostSecret';
const connectKeyRecents = 'recents';
const maxRecentServers = 6;
/// The connect form: the Join card's address ([host]/[port]/[secret]),
/// your server's port and password when shared ([hostPort]/[hostSecret]),
/// the profile name and the last media picked.
class ConnectForm {
String host;
String port;
String secret;
String hostPort;
String hostSecret;
String name;
String avatarPath;
String mediaPath;
String linkUrl;
ConnectForm({
this.host = '127.0.0.1',
this.port = '8080',
this.secret = '',
this.hostPort = '8080',
this.hostSecret = '',
this.name = '',
this.avatarPath = '',
this.mediaPath = '',
this.linkUrl = '',
});
ConnectForm copy() => ConnectForm(
host: host,
port: port,
secret: secret,
hostPort: hostPort,
hostSecret: hostSecret,
name: name,
avatarPath: avatarPath,
mediaPath: mediaPath,
linkUrl: linkUrl,
);
}
/// A friend's server the user got into, shown as a Recent row on the
/// Join card (one click joins).
class RecentServer {
final String host;
final String port;
final String secret;
const RecentServer({required this.host, required this.port, this.secret = ''});
String get key => '${host.toLowerCase()}:$port';
Map<String, Object> toJson() => {'host': host, 'port': port, 'secret': secret};
/// Null for anything unreadable and for the hosted entries older
/// versions kept (your server has no recents).
static RecentServer? fromJson(Object? j) {
if (j is! Map || j['hosted'] == true) return null;
final host = j['host'], port = j['port'], secret = j['secret'];
if (host is! String || port is! String) return null;
return RecentServer(host: host, port: port, secret: secret is String ? secret : '');
}
}
/// Newest first, one entry per server, capped at [maxRecentServers].
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
s,
...list.where((r) => r.key != s.key),
].take(maxRecentServers).toList();
String encodeRecents(List<RecentServer> list) => jsonEncode([for (final r in list) r.toJson()]);
List<RecentServer> decodeRecents(Object? raw) {
if (raw is! String) return const [];
try {
final list = jsonDecode(raw);
if (list is! List) return const [];
return [
for (final j in list) ?RecentServer.fromJson(j),
].take(maxRecentServers).toList();
} catch (_) {
return const [];
}
}
/// Before recents existed only the last form was stored: a saved remote
/// address becomes the first Recent row.
List<RecentServer> migrateRecents(
{required bool hadForm, required String host, required String port, required String secret}) {
final h = host.trim().toLowerCase();
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') return const [];
return [RecentServer(host: host.trim(), port: port, secret: secret)];
}
/// The connect form and the recent servers, kept in the `connect_form`
/// box. Made by the composition root; the Join card, your server (its
/// port and password), the profile and the library read and write it.
class ConnectStore {
final form = ConnectForm();
final recents = signal<List<RecentServer>>(const []);
Future<void> load() async {
final box = await openConnectFormBox();
form
..host = readString(box, connectKeyHost, '127.0.0.1')
..port = readString(box, connectKeyPort, '8080')
..secret = readString(box, connectKeySecret, '');
// Old installs had one port/password for both modes.
form
..hostPort = readString(box, connectKeyHostPort, form.port)
..hostSecret = readString(box, connectKeyHostSecret, form.secret)
..name = readString(box, connectKeyName, '')
..avatarPath = readString(box, connectKeyAvatarPath, '')
..mediaPath = readString(box, connectKeyMediaPath, '')
..linkUrl = readString(box, connectKeyLinkUrl, '');
recents.value = box.containsKey(connectKeyRecents)
? decodeRecents(box.get(connectKeyRecents))
: migrateRecents(
hadForm: box.containsKey(connectKeyHost), host: form.host, port: form.port, secret: form.secret);
}
Future<void> save() async {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
await box.putAll({
connectKeyHost: form.host,
connectKeyPort: form.port,
connectKeySecret: form.secret,
connectKeyHostPort: form.hostPort,
connectKeyHostSecret: form.hostSecret,
connectKeyName: form.name,
connectKeyAvatarPath: form.avatarPath,
connectKeyMediaPath: form.mediaPath,
connectKeyLinkUrl: form.linkUrl,
connectKeyRecents: encodeRecents(recents.peek()),
});
}
/// [save] whatever happens (a failed join is exactly when the user
/// wants the form back); a failure is only logged.
Future<void> saveQuietly() async {
try {
await save();
} catch (e, st) {
debugPrint('save connect form failed: $e\n$st');
}
}
Future<void> remember(RecentServer server) async {
recents.value = pushRecent(recents.peek(), server);
await saveQuietly();
}
Future<void> forget(RecentServer server) async {
recents.value = [
for (final r in recents.peek())
if (r.key != server.key) r,
];
await saveQuietly();
}
}
+43 -2
View File
@@ -8,7 +8,8 @@ import 'package:sync_relay_server/server.dart';
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
serve(args.$1, events: args.$2);
/// The hosted server, running [serve] on its own isolate. Its log lines
/// This app's own server ("your server"), running [serve] on its own
/// isolate for the whole app run. Its log lines
/// go to [onLog]; if the isolate dies without being asked to stop,
/// [onCrash] gets the cause once.
class ServerIsolate {
@@ -33,7 +34,7 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where
/// [loopbackOnly]: 127.0.0.1 only until [share]. [logDir]: where
/// the server writes its session log (none when null).
static Future<ServerIsolate> start(
AppConfig config, {
@@ -156,6 +157,46 @@ class ServerIsolate {
}
}
/// Opens the server to the network ([SyncServer.share]): a listener on
/// every interface at [port], the password [secret], the Jellyfin relay
/// login [jellyfin]. Returns the bound port; throws a [StateError] with
/// the server's reason (a port in use) when it cannot.
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin,
Duration timeout = const Duration(seconds: 10)}) async {
if (_exited.isCompleted) throw StateError('the server is not running');
final reply = ReceivePort('server share');
try {
_stop.send({
't': 'share',
'port': port,
'secret': secret,
'jellyfin': ?jellyfin?.toMap(),
'reply': reply.sendPort,
});
final msg = await reply.first.timeout(timeout);
if (msg is Map && msg['ok'] == true) return msg['port'] as int;
throw StateError(msg is Map ? '${msg['message']}' : 'the server did not answer');
} on TimeoutException {
throw StateError('the server did not answer');
} finally {
reply.close();
}
}
/// Closes the network listener again ([SyncServer.unshare]): everyone
/// but this app's own member leaves; nothing else changes.
Future<void> unshare({Duration timeout = const Duration(seconds: 5)}) async {
if (_exited.isCompleted) return;
final reply = ReceivePort('server unshare');
try {
_stop.send({'t': 'unshare', 'reply': reply.sendPort});
await reply.first.timeout(timeout);
} catch (_) {
} finally {
reply.close();
}
}
/// Asks the server to close its sockets and exit; kills the isolate if it
/// has not exited within [timeout].
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
@@ -1,543 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
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 '../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/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity;
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
import 'server_isolate.dart';
import 'settings.dart';
enum AppRoute {
home,
session,
}
enum ConnectMode { host, join }
/// Both Home cards prefill from this. [host]/[port]/[secret] belong to
/// the Join card, [hostPort]/[hostSecret] to the Host card.
class ConnectForm {
String host;
String port;
String secret;
String hostPort;
String hostSecret;
String name;
String avatarPath;
String mediaPath;
String linkUrl;
ConnectForm({
this.host = '127.0.0.1',
this.port = '8080',
this.secret = '',
this.hostPort = '8080',
this.hostSecret = '',
this.name = '',
this.avatarPath = '',
this.mediaPath = '',
this.linkUrl = '',
});
ConnectForm copy() => ConnectForm(
host: host,
port: port,
secret: secret,
hostPort: hostPort,
hostSecret: hostSecret,
name: name,
avatarPath: avatarPath,
mediaPath: mediaPath,
linkUrl: linkUrl,
);
}
const connectKeyHostPort = 'hostPort';
const connectKeyHostSecret = 'hostSecret';
const connectKeyRecents = 'recents';
const maxRecentServers = 6;
/// A server the user got into, shown as a Recent chip on Home. Hosted
/// entries refill the Host card; joined ones join in one click.
class RecentServer {
final bool hosted;
final String host;
final String port;
final String secret;
const RecentServer(
{required this.hosted,
required this.host,
required this.port,
this.secret = ''});
String get key => hosted ? 'host:$port' : '${host.toLowerCase()}:$port';
Map<String, Object> toJson() =>
{'hosted': hosted, 'host': host, 'port': port, 'secret': secret};
static RecentServer? fromJson(Object? j) {
if (j is! Map) return null;
final host = j['host'], port = j['port'], secret = j['secret'];
if (host is! String || port is! String) return null;
return RecentServer(
hosted: j['hosted'] == true,
host: host,
port: port,
secret: secret is String ? secret : '',
);
}
}
/// Newest first, one entry per server, capped at [maxRecentServers].
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
s,
...list.where((r) => r.key != s.key),
].take(maxRecentServers).toList();
String encodeRecents(List<RecentServer> list) =>
jsonEncode([for (final r in list) r.toJson()]);
List<RecentServer> decodeRecents(Object? raw) {
if (raw is! String) return const [];
try {
final list = jsonDecode(raw);
if (list is! List) return const [];
return [
for (final j in list) ?RecentServer.fromJson(j),
].take(maxRecentServers).toList();
} catch (_) {
return const [];
}
}
/// Before recents existed only the last form was stored: a saved remote
/// address becomes the first Recent chip.
List<RecentServer> migrateRecents(
{required bool hadForm,
required String host,
required String port,
required String secret}) {
final h = host.trim().toLowerCase();
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') {
return const [];
}
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
}
/// The hosted server died during setup; shown on the Host card.
class ServerCrashed implements Exception {
final String cause;
const ServerCrashed(this.cause);
@override
String toString() => 'server crashed: $cause';
}
/// A fresh per-hosting key: the app's own client sends it in hello to be
/// recognised as the host (`members[].host`).
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
}
class SessionState {
static final SessionState instance = SessionState._();
SessionState._();
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
/// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
);
/// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports = desktopPorts();
/// Every toast, sound, system notification and activity-log line goes
/// through it.
late final Notifier notifier = Notifier(
sfx: sfx,
attention: ports.shell.requestAttention,
system: ports.notifications,
focusWindow: ports.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
);
/// The link to the server; lost/back go to the notifier.
/// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier.
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
late final SessionCoordinator coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
/// In a solo session: this app's own server on 127.0.0.1, only you in
/// it. Hosting from it ([host]) keeps what plays and where.
final solo = signal(false);
/// The joined server's own name (`/health`), '' until known or when it
/// only has the default name.
final serverLabel = signal('');
/// The server's Starsync version: a hosted (or solo) server is this
/// app's own; a joined one's comes from its `/health`; '' until known.
final serverVersion = signal('');
/// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server.
final switching = signal(false);
ConnectParams? _soloParams;
/// The app's yt-dlp folder for the hosted server; '' (the server's own
/// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async {
try {
return (await instance.ports.links.ytDlp).dir;
} catch (_) {
return '';
}
}
ServerIsolate? _server;
String? _setupCrash;
@visibleForTesting
ServerIsolate? get hostedServer => _server;
// Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting.
int _op = 0;
Future<void> loadForm() async {
final box = await openConnectFormBox();
lastForm
..host = readString(box, connectKeyHost, '127.0.0.1')
..port = readString(box, connectKeyPort, '8080')
..secret = readString(box, connectKeySecret, '');
// Old installs had one port/password for both modes.
lastForm
..hostPort = readString(box, connectKeyHostPort, lastForm.port)
..hostSecret = readString(box, connectKeyHostSecret, lastForm.secret)
..name = readString(box, connectKeyName, '')
..avatarPath = readString(box, connectKeyAvatarPath, '')
..mediaPath = readString(box, connectKeyMediaPath, '')
..linkUrl = readString(box, connectKeyLinkUrl, '');
recents.value = box.containsKey(connectKeyRecents)
? decodeRecents(box.get(connectKeyRecents))
: migrateRecents(
hadForm: box.containsKey(connectKeyHost),
host: lastForm.host,
port: lastForm.port,
secret: lastForm.secret);
}
Future<void> saveForm() async {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
await box.putAll({
connectKeyHost: lastForm.host,
connectKeyPort: lastForm.port,
connectKeySecret: lastForm.secret,
connectKeyHostPort: lastForm.hostPort,
connectKeyHostSecret: lastForm.hostSecret,
connectKeyName: lastForm.name,
connectKeyAvatarPath: lastForm.avatarPath,
connectKeyMediaPath: lastForm.mediaPath,
connectKeyLinkUrl: lastForm.linkUrl,
connectKeyRecents: encodeRecents(recents.peek()),
});
}
Future<String> loadAvatar(String path) => encodeAvatar(path);
/// Called by Home after a host/join got through.
Future<void> rememberServer(RecentServer server) async {
recents.value = pushRecent(recents.peek(), server);
await _saveFormQuietly();
}
Future<void> forgetServer(RecentServer server) async {
recents.value = [
for (final r in recents.peek())
if (r.key != server.key) r,
];
await _saveFormQuietly();
}
/// Hosts a server friends can join. From a solo session it keeps the
/// media and position: the solo server goes, the new one gets the same
/// pick, seeked to where it was; if hosting fails, solo comes back.
Future<void> host(ConnectParams params, ConnectForm form) async {
if (!solo.peek()) return _host(params, form: form);
final carry = _invited = _carry();
switching.value = true;
try {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
try {
await _host(params, form: form);
} catch (_) {
// Not hosted (failed or cancelled): back to solo, same media.
try {
await _host(_soloParams ?? params, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
}
rethrow;
}
try {
if (carry != null) await _resume(carry);
} catch (e) {
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
}
} finally {
switching.value = false;
}
}
/// What an Invite from solo carried over, for [cancelInvite].
({PickedMedia pick, double position})? _invited;
/// Invite cancelled after the hosted server already came up: back to
/// solo with the same media, not out of the session.
Future<void> cancelInvite() async {
final carry = _invited;
switching.value = true;
try {
await coordinator.leave();
await stopServer();
await _host(_soloParams!, soloSession: true);
if (carry != null) await _resume(carry);
} catch (e) {
debugPrint('back to solo failed: $e');
await leave();
} finally {
switching.value = false;
}
}
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
/// password, nothing put up yet. Nobody else can reach it.
Future<void> startSolo(ConnectParams params) async {
if (route.peek() == AppRoute.session) return;
_soloParams = params;
await _host(params, soloSession: true);
}
({PickedMedia pick, double position})? _carry() {
final pick = coordinator.lastPick;
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
final pos = coordinator.playback.positionSecs;
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
}
/// Puts [carry] up on the server just joined and seeks to its position
/// once the swap shows in a state.
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
final before = coordinator.serverState.peek()?.mediaId ?? 0;
final error = await coordinator.swapPicked(carry.pick);
if (error != null) throw StateError(error);
if (carry.position < 1) return;
final landed = Completer<void>();
final stop = effect(() {
final r = coordinator.serverState.value;
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
});
try {
await landed.future.timeout(const Duration(seconds: 20));
coordinator.connection.sendSeek(carry.position);
} on TimeoutException {
debugPrint('resume: the media did not come up; not seeking');
} finally {
stop();
}
}
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
final op = ++_op;
if (form != null) _rememberForm(form);
try {
await stopServer();
if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey();
_setupCrash = null;
// A pasted Jellyfin link goes up as a ref like a browser pick.
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (op != _op) throw const ConnectCancelled();
// Guests without an account watch through the server, with the
// host's own sign-in (the hosted item's server, else the default).
await jellyfin.ensureLoaded(validate: false);
final relay = relayCredentialFor(jellyfin, serverId: media?.jellyfin?.serverId);
late final ServerIsolate server;
try {
server = await ServerIsolate.start(
AppConfig(
port: soloSession ? 0 : params.port,
secret: soloSession ? '' : params.secret,
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
),
loopbackOnly: soloSession,
// Its session log (every member's shared diagnostics and its own
// records) goes next to the app's; not for a solo session.
logDir: soloSession ? null : appLogDir,
// Its records join the app's log as `server.<logger>`.
onLog: (line) => relogLine('server', line),
onCrash: (cause) => _onServerCrash(server, cause),
);
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
rethrow;
}
if (op != _op) {
await server.stop();
throw const ConnectCancelled();
}
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
serverVersion.value = appVersion;
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.port,
secret: soloSession ? '' : params.secret,
name: params.name,
avatar: params.avatar,
hostKey: hostKey,
media: media,
));
if (op != _op) throw const ConnectCancelled();
} catch (e) {
// Own join failed or was cancelled: don't leave the port open.
if (identical(_server, server)) await stopServer();
final crash = _setupCrash;
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow;
}
solo.value = soloSession;
coordinator.solo = soloSession;
route.value = AppRoute.session;
} finally {
if (form != null) await _saveFormQuietly();
}
}
/// The server isolate died without being stopped. In a session: show it
/// and leave. During setup the own join fails and [host] reports it.
void _onServerCrash(ServerIsolate server, String cause) {
if (!identical(_server, server)) return;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
if (route.peek() != AppRoute.session) {
_setupCrash = cause;
return;
}
serverCrash.value = message;
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
}
Future<void> join(ConnectParams params, ConnectForm form) async {
final op = ++_op;
_rememberForm(form);
try {
await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session;
serverLabel.value = '';
serverVersion.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
if (op != _op || !h.online) return;
// 'server' is the dedicated server's default --name: not a name.
if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
serverVersion.value = h.version;
}).catchError((Object _) {}));
} finally {
await _saveFormQuietly();
}
}
/// Cancel button during setup: abort whichever step is running (server
/// start, player init, socket dial) without connecting.
Future<void> cancelPending() async {
_op++;
try {
await coordinator.cancelConnect();
} catch (_) {}
// Solo becoming hosted: [host] stops what it started and goes back
// to solo itself.
if (!switching.peek()) await stopServer();
}
/// Saved whatever the outcome: a failed join is exactly when the user
/// wants the form back.
Future<void> _saveFormQuietly() async {
try {
await saveForm();
} catch (e, st) {
debugPrint('save connect form failed: $e\n$st');
}
}
void _rememberForm(ConnectForm form) {
lastForm
..host = form.host
..port = form.port
..secret = form.secret
..hostPort = form.hostPort
..hostSecret = form.hostSecret
..name = form.name
..avatarPath = form.avatarPath
..mediaPath = form.mediaPath
..linkUrl = form.linkUrl;
}
Future<void> stopServer() async {
coordinator.hostFile = null;
final server = _server;
_server = null;
await server?.stop();
}
/// Out of the session (Leave, the app closing). [bye] false: Update
/// and restart, the restarted app takes the member back within its grace.
Future<void> leave({bool bye = true}) async {
await coordinator.leave(bye: bye);
await stopServer();
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
serverVersion.value = '';
route.value = AppRoute.home;
}
}
/// The sound set [kind] plays from: its own pick in Settings, else the
/// overall one.
SoundPreset soundPresetFor(SfxKind kind) {
final s = AppSettings.instance;
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
}
+3 -3
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../shell/mark_problem.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'text_focus.dart';
/// Tab and Shift+Tab never move focus, and Space never presses the
@@ -61,13 +61,13 @@ class _AppKeysState extends State<AppKeys> {
return true;
}
if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
if (!AppScope.of(context).navigation.online.peek()) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false;
if (textInputFocused()) return false;
final keys = HardwareKeyboard.instance;
if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false;
if (key == LogicalKeyboardKey.space) {
(widget.onToggleReady ?? SessionState.instance.coordinator.toggleReady)();
(widget.onToggleReady ?? AppScope.of(context).coordinator.toggleReady)();
return true;
}
final toggles = AppKeys._chatToggles;
+20 -11
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
import '../state/session_state.dart';
import '../app/app_graph.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening
// a page in the browser. One wording and one confirmation (a small toast)
@@ -21,27 +21,36 @@ abstract final class ReuseText {
@visibleForTesting
void Function(String text)? debugReuseToast;
void _notify(AppEvent event) {
void Function(AppEvent event) _notifier(BuildContext context) {
final debug = debugReuseToast;
if (debug != null) return debug(noticeFor(event).toast ?? '');
SessionState.instance.coordinator.notify(event);
if (debug != null) return (event) => debug(noticeFor(event).toast ?? '');
return AppScope.of(context).notifier.notify;
}
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
/// its text, e.g. "Link copied").
Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
Future<void> copyToClipboard(BuildContext context, String text, {String done = 'Copied'}) async {
final notify = _notifier(context);
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (_) {
_notify(const CopyFailed());
notify(const CopyFailed());
return;
}
_notify(Copied(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 SessionState.instance.ports.urls.open(url)) return;
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
Future<void> openLink(BuildContext context, String url) async {
final urls = AppScope.of(context).ports.urls;
final notify = _notifier(context);
if (await urls.open(url)) return;
try {
await Clipboard.setData(ClipboardData(text: url));
} catch (_) {
notify(const CopyFailed());
return;
}
notify(const Copied("Couldn't open a browser — link copied"));
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
class WindowButtons extends StatefulWidget {
final FocusNode? focusNode;
@@ -18,7 +18,7 @@ class WindowButtons extends StatefulWidget {
}
class _WindowButtonsState extends State<WindowButtons> {
final _shell = SessionState.instance.ports.shell;
late final _shell = AppScope.of(context).ports.shell;
bool _maximized = false;
late final void Function() _stopWatch;
@@ -53,8 +53,9 @@ class _WindowButtonsState extends State<WindowButtons> {
Future<void> _close() async {
try {
await SessionState.instance.coordinator.leave();
await SessionState.instance.stopServer();
final graph = AppScope.of(context);
await graph.navigation.leave();
await graph.yourServer.stop();
} catch (_) {}
await _shell.requestClose();
}
@@ -133,7 +134,7 @@ class WindowDragArea extends StatelessWidget {
@override
Widget build(BuildContext context) {
final shell = SessionState.instance.ports.shell;
final shell = AppScope.of(context).ports.shell;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (_) => shell.startDragging(),
+14 -8
View File
@@ -3,17 +3,23 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/widgets/app_keys.dart';
import 'package:starsync/src/widgets/text_focus.dart';
import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
void main() {
final session = SessionState.instance;
final session = _graph;
final coord = session.coordinator;
tearDown(() {
session.route.value = AppRoute.home;
session.navigation.place.value = Place.none;
coord.serverState.value = null;
});
@@ -24,7 +30,7 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var readies = 0;
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
shortcuts: appShortcuts,
home: AppKeys(
@@ -36,7 +42,7 @@ void main() {
videoBuilder: (context, controls) => const SizedBox(),
),
),
));
)));
coord.serverState.value = StateBroadcast(
eventId: 1,
transport: 'held',
@@ -45,7 +51,7 @@ void main() {
mediaId: 0,
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)],
);
session.route.value = AppRoute.session;
session.navigation.place.value = Place.friend;
await tester.pumpAndSettle();
final panel = find.byKey(const ValueKey('chat-panel'));
@@ -67,11 +73,11 @@ void main() {
});
testWidgets('Ctrl+Shift+B opens Mark a problem; Esc closes it unmarked', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
shortcuts: appShortcuts,
home: const AppKeys(child: Scaffold(body: SizedBox())),
));
)));
final card = find.byKey(const ValueKey('mark-problem-card'));
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+10 -6
View File
@@ -7,9 +7,13 @@ import 'package:starsync/src/controls/model/controls_model.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/session_controls.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The controls model and its layout spec: which controls the bar has, in
// which order, what goes to ⚙; and both skins drawing the same order.
@@ -64,7 +68,7 @@ void main() {
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
expect(all.first, ControlAction.chapters);
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)),
expect(menuActions(const ControlsSnapshot(alone: true), none), isNot(contains(ControlAction.markAllReady)),
reason: 'nobody to mark ready');
});
@@ -74,7 +78,7 @@ void main() {
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
'Pause for everyone (Space)');
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)');
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(alone: true)), 'Play (Space)');
});
test('episode buttons: the label and the keys', () {
@@ -98,7 +102,7 @@ void main() {
group('both skins draw the model', () {
final s = AppSettings.instance;
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
tearDown(() {
s.controlsSkin.value = ControlsSkin.standard;
@@ -131,13 +135,13 @@ void main() {
shown.dispose();
hold.dispose();
});
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
backgroundColor: Colors.black,
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
),
));
)));
await tester.pump();
return model;
}
+18 -9
View File
@@ -18,9 +18,13 @@ import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/session/notifier.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/sync/sync_client.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<void> _poll(
@@ -376,22 +380,27 @@ void main() {
}
});
test('cancelling a host setup releases the port', timeout: const Timeout(Duration(minutes: 1)), () async {
test('stop sharing releases the port; stopping your server frees its own', timeout: const Timeout(Duration(minutes: 1)),
() async {
Hive.init('$_tmp/hive_coord_test');
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = probe.port;
await probe.close();
final session = SessionState.instance;
final yours = _graph.yourServer;
_graph.forms.form
..hostPort = '$port'
..hostSecret = '';
try {
final pending = session.host(ConnectParams(host: '', port: port, secret: '', name: 'host'), ConnectForm());
await session.cancelPending();
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
expect(session.route.peek(), isNot(AppRoute.session));
final own = await yours.start(name: 'me');
expect((await yours.invite()).port, port);
await yours.stopSharing();
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close();
await yours.stop();
final again = await ServerSocket.bind(InternetAddress.loopbackIPv4, own);
await again.close();
} finally {
await session.coordinator.leave(silent: true);
await session.stopServer();
await yours.stop();
}
});
+7 -2
View File
@@ -7,6 +7,11 @@ import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/shell/delivery_line.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, host: host, rate: rate);
@@ -134,7 +139,7 @@ void main() {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]);
final d = deliveryOf(r, selfId: 'm2')!;
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: Center(
@@ -147,7 +152,7 @@ void main() {
),
),
),
));
)));
await tester.pumpAndSettle();
expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget);
expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget);
+11 -6
View File
@@ -13,6 +13,11 @@ import 'package:starsync/src/files/thumbnails.dart';
import 'package:starsync/src/files/ui/file_tiles.dart';
import 'package:starsync/src/files/ui/files_page.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The Files page over a real (tiny) folder tree: the scan runs in-line
// instead of in an isolate and thumbnails come from a fake loader, so
// nothing here decodes video.
@@ -46,7 +51,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
Future<void> pumpPage(
WidgetTester tester, {
Size size = const Size(1280, 800),
bool solo = false,
bool alone = false,
bool relayed = false,
Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails,
@@ -55,10 +60,10 @@ Future<void> pumpPage(
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(
body: FilesPage(
solo: solo,
alone: alone,
relayed: relayed,
onPlay: onPlay ?? (_) async => null,
thumbnails: thumbnails ?? fakeThumb,
@@ -67,7 +72,7 @@ Future<void> pumpPage(
pickFolder: pickFolder,
),
),
));
)));
await frames(tester);
}
@@ -242,7 +247,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(
body: FilesPage(
relayed: false,
@@ -253,7 +258,7 @@ void main() {
store: store,
),
),
));
)));
await frames(tester);
await openFolder(tester, 'anime');
await openSeries(tester);
+111 -46
View File
@@ -1,7 +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;
import 'dart:io' show Platform, SocketException;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -12,13 +10,22 @@ import 'package:starsync/src/screens/home/health_probe.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/home/update_indicator.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/library_link.dart' show soloPlayHint;
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/shell/library_link.dart' show alonePlayHint;
import 'package:starsync/src/state/connect_prefs.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/session/connection.dart' show ConnClosed;
import 'package:starsync/src/session/navigation.dart' show JoinFailed, Place;
import 'package:starsync/src/session/session_coordinator.dart' show ConnectParams;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
RecentServer join(String host, String port, [String secret = '']) =>
RecentServer(hosted: false, host: host, port: port, secret: secret);
RecentServer(host: host, port: port, secret: secret);
void main() {
group('recent servers', () {
@@ -35,21 +42,12 @@ void main() {
expect(list.where((r) => r.host == '10.0.0.4').length, 1);
});
test('hosted entries are one per port, separate from joins', () {
var list = <RecentServer>[];
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080'));
list = pushRecent(list, join('127.0.0.1', '8080'));
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080', secret: 'x'));
expect(list.length, 2);
expect(list.first.secret, 'x');
});
test('round-trips and tolerates junk', () {
final list = [join('a.example', '9000', 's'), const RecentServer(hosted: true, host: '', port: '8080')];
test('round-trips and tolerates junk; hosted entries of older versions are dropped', () {
final list = [join('a.example', '9000', 's'), join('b.example', '8080')];
final back = decodeRecents(encodeRecents(list));
expect(back.map((r) => r.key), list.map((r) => r.key));
expect(back.first.secret, 's');
expect(back.last.hosted, isTrue);
expect(decodeRecents('[{"hosted":true,"host":"","port":"8080"},{"host":"h","port":"1"}]').single.host, 'h');
expect(decodeRecents('not json'), isEmpty);
expect(decodeRecents(42), isEmpty);
expect(decodeRecents('[{"host":1},{"host":"h","port":"1"}]').single.host, 'h');
@@ -79,8 +77,15 @@ void main() {
});
test('health parse: online, junk and protocol mismatch', () {
final ok = ServerHealth.parse(jsonEncode(
{'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2, 'version': '1.3.0'}));
final ok = ServerHealth.parse(jsonEncode({
'ok': true,
'name': 'Den',
'proto': RelayConstants.protoMax,
'protoMin': RelayConstants.protoMin,
'protoMax': RelayConstants.protoMax,
'viewers': 2,
'version': '1.3.0'
}));
expect(ok.online, isTrue);
expect(ok.name, 'Den');
expect(ok.version, '1.3.0');
@@ -93,6 +98,73 @@ void main() {
expect(healthUri('::1', '8080').toString(), 'http://[::1]:8080/health');
});
group('version gate (/health)', () {
String body({required int min, required int max, String version = '', String name = ''}) =>
jsonEncode({'ok': true, 'name': name, 'proto': max, 'protoMin': min, 'protoMax': max, 'version': version});
const own = RelayConstants.protoMin;
test('older, newer, compatible', () {
final older = ServerHealth.parse(body(min: own - 2, max: own - 1, version: '1.2.1', name: "Bob's server"));
expect(older.fit, VersionFit.theyUpdate);
expect(versionGateText(older), "Bob's server runs Starsync 1.2.1 — they need to update");
final newer = ServerHealth.parse(body(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 2, version: '2.0.0'));
expect(newer.fit, VersionFit.youUpdate);
expect(versionGateText(newer), 'The server runs Starsync 2.0.0 — you need to update');
final overlap = ServerHealth.parse(body(min: own - 1, max: RelayConstants.protoMax + 3));
expect(overlap.fit, VersionFit.ok, reason: 'ranges that meet are fine');
// A proto 15 server says only `proto`.
final old = ServerHealth.parse('{"ok":true,"proto":15,"name":"server"}');
expect(old.fit, VersionFit.theyUpdate);
expect(versionGateText(old), 'The server runs an older Starsync — they need to update');
expect(ServerHealth.parse('{"ok":true}').fit, isNull, reason: 'unknown: let the hello decide');
});
test('a join stops at the gate, still at home, before connecting', () async {
final graph = testGraph(healthGet: (uri) async => body(min: own - 2, max: own - 1, version: '1.2.1'));
await expectLater(
graph.navigation.join(const ConnectParams(host: 'old.example', port: 8080, secret: '', name: 'Me')),
throwsA(isA<VersionGateRefused>()
.having((e) => e.youUpdate, 'youUpdate', isFalse)
.having((e) => e.message, 'message', contains('they need to update'))));
expect(graph.connection.state.peek(), isA<ConnClosed>(), reason: 'never dialled');
final newer = testGraph(
healthGet: (uri) async => body(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 1));
await expectLater(newer.navigation.join(const ConnectParams(host: 'new.example', port: 8080, secret: '', name: 'Me')),
throwsA(isA<VersionGateRefused>().having((e) => e.youUpdate, 'youUpdate', isTrue)));
final offline = testGraph(healthGet: (uri) async => throw const SocketException('refused'));
await expectLater(offline.navigation.join(const ConnectParams(host: 'gone.example', port: 8080, secret: '', name: 'Me')),
throwsA(isA<JoinFailed>()));
});
testWidgets('the Join card says who has to update and offers the updater when it is you', (tester) async {
final graph = testGraph(
healthGet: (uri) async => body(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 1, version: '2.0.0'));
graph.forms.form.name = 'Me';
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(scoped(
graph,
MaterialApp(
theme: ThemeData.dark(),
home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false,
windowControls: false,
healthGet: graph.navigation.healthGet,
),
)));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('strip-join')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('join-address')), 'new.example');
await tester.tap(find.widgetWithText(FilledButton, 'Join').last);
await tester.pumpAndSettle();
expect(find.textContaining('you need to update'), findsOneWidget);
expect(find.byKey(const ValueKey('join-update')), findsOneWidget);
});
});
test('pasting sorts links and paths into the right source', () {
final c = MediaPickerController();
expect(c.source, MediaKind.jellyfin);
@@ -118,7 +190,7 @@ void main() {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'),
@@ -126,7 +198,7 @@ void main() {
windowControls: false,
healthGet: health ?? fakeHealth(const {}),
),
));
)));
await tester.pump();
}
@@ -136,8 +208,8 @@ void main() {
}
setUp(() {
SessionState.instance.recents.value = const [];
SessionState.instance.route.value = AppRoute.home;
_graph.forms.recents.value = const [];
_graph.navigation.place.value = Place.none;
});
testWidgets('tabs and settings pages switch the body', (tester) async {
@@ -178,7 +250,7 @@ void main() {
});
testWidgets('Enter in the join card submits; errors show under it', (tester) async {
SessionState.instance.lastForm.name = '';
_graph.forms.form.name = '';
await pumpShell(tester, const Size(1280, 720));
await openCard(tester, 'strip-join');
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example');
@@ -201,11 +273,10 @@ void main() {
});
testWidgets('recent rows show live status from /health', (tester) async {
SessionState.instance.recents.value = [
_graph.forms.recents.value = [
join('alpha.example', '8080'),
join('beta.example', '9000', 'pw'),
join('gamma.example', '7000'),
const RecentServer(hosted: true, host: '', port: '8181'),
];
await pumpShell(tester, const Size(1280, 900),
health: fakeHealth({
@@ -226,8 +297,6 @@ void main() {
expect(inRow('beta.example:9000', find.text('○ offline')), findsOneWidget);
expect(inRow('beta.example:9000', find.text('beta.example')), findsOneWidget);
expect(inRow('gamma.example:7000', find.text('needs update')), findsOneWidget);
// Hosted entries live on the Host card, not in the join list.
expect(find.byKey(const ValueKey('recent-host:8181')), findsNothing);
// x forgets.
await tester.tap(inRow('beta.example:9000', find.byTooltip('Forget')));
await tester.pump();
@@ -242,13 +311,15 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
});
testWidgets('library offline: welcome band, sources that need nothing, Jellyfin optional', (tester) async {
testWidgets('library at home: welcome band, sources that need nothing, Jellyfin optional', (tester) async {
await pumpShell(tester, const Size(1280, 900));
// The three ways in, Join first; none needs Jellyfin.
expect(find.byKey(const ValueKey('welcome-band')), findsOneWidget);
expect(find.text('Watch together, in sync'), findsOneWidget);
final join = tester.getTopLeft(find.byKey(const ValueKey('way-join')));
expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-solo'))).dx));
expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-invite'))).dx));
expect(find.text('Watch on your own'), findsOneWidget);
expect(find.text('Host'), findsNothing, reason: 'no Host: your server is always there');
// No linked server: Files and Link first, Jellyfin as an optional card.
expect(find.text('Pick something to watch'), findsOneWidget);
expect(find.text('Add a Jellyfin server'), findsNothing);
@@ -262,25 +333,19 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Watch from a link'), findsOneWidget);
// Alone: Play, no relay modes.
expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text(alonePlayHint), findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('host without a name errors in the host card', (tester) async {
SessionState.instance.lastForm.name = '';
SessionState.instance.recents.value = [const RecentServer(hosted: true, host: '', port: '8181', secret: 's')];
testWidgets('pasting an invite into the address fills the port', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await openCard(tester, 'strip-host');
expect(find.text('No password — anyone can join'), findsOneWidget);
expect(find.text('You pick what to watch afterwards, in the library.'), findsOneWidget);
// A hosted port refills the fields.
await tester.tap(find.byKey(const ValueKey('recent-host:8181')));
await openCard(tester, 'strip-join');
await tester.enterText(find.byKey(const ValueKey('join-address')),
'Join me on Starsync: 192.168.1.20:9123 (ask me for the password) · Starsync 1.3.0');
await tester.pump();
expect(find.text('No password — anyone can join'), findsNothing);
await tester.tap(find.widgetWithText(FilledButton, 'Host').last);
await tester.pumpAndSettle();
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
expect(tester.widget<TextField>(find.byKey(const ValueKey('join-address'))).controller!.text, '192.168.1.20');
expect(tester.widget<TextField>(find.byKey(const ValueKey('join-port'))).controller!.text, '9123');
});
testWidgets('narrow window: strip, cards and library fit', (tester) async {
@@ -310,8 +375,8 @@ void main() {
executablePath: '/nowhere/watch_together',
restart: (_, _) async {});
c.status.value = s;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: Column(children: [UpdateBar(controller: c)]))));
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(body: Column(children: [UpdateBar(controller: c)])))));
return c;
}
+13 -8
View File
@@ -12,9 +12,14 @@ import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:starsync/src/jellyfin/picker_source.dart';
import 'package:starsync/src/state/settings.dart';
import 'test_graph.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
Future<void> setSize(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -55,7 +60,7 @@ Future<(Picks, JellyfinAccounts, FakeJellyfin, BrowseController)> pumpBrowser(
final controller = BrowseController(accounts, now: () => fixtureNow);
addTearDown(controller.dispose);
final picks = Picks();
await tester.pumpWidget(app(StatefulBuilder(
await tester.pumpWidget(scoped(_graph, app(StatefulBuilder(
builder: (context, setState) => JellyfinBrowser(
accounts: accounts,
controller: controller,
@@ -63,7 +68,7 @@ Future<(Picks, JellyfinAccounts, FakeJellyfin, BrowseController)> pumpBrowser(
onSelect: (s) => setState(() => picks.selected.add(s)),
onPutUp: picks.putUp.add,
),
)));
))));
await settle(tester);
return (picks, accounts, fake, controller);
}
@@ -154,8 +159,8 @@ void main() {
addTearDown(controller.dispose);
AppSettings.instance.hideReReleases.value = true;
addTearDown(() => AppSettings.instance.hideReReleases.value = true);
await tester.pumpWidget(app(JellyfinBrowser(
accounts: accounts, controller: controller, onSelect: (_) {}, onPutUp: (_) {})));
await tester.pumpWidget(scoped(_graph, app(JellyfinBrowser(
accounts: accounts, controller: controller, onSelect: (_) {}, onPutUp: (_) {}))));
await settle(tester);
expect(find.text('Fresh Show'), findsWidgets);
expect(find.text('S1E1'), findsWidgets);
@@ -298,9 +303,9 @@ void main() {
final accounts = JellyfinAccounts(
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test');
await accounts.ensureLoaded(validate: false);
await tester.pumpWidget(app(SingleChildScrollView(
await tester.pumpWidget(scoped(_graph, app(SingleChildScrollView(
child: JellyfinAccountsPanel(
accounts: accounts, pollInterval: const Duration(milliseconds: 100)))));
accounts: accounts, pollInterval: const Duration(milliseconds: 100))))));
await tester.tap(find.byKey(const ValueKey('jf-add-server')));
await tester.pump();
await tester.enterText(find.byKey(const ValueKey('jf-address')), 'example.invalid');
@@ -351,8 +356,8 @@ void main() {
await setSize(tester, const Size(1200, 900));
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
await accounts.ensureLoaded(validate: false);
await tester.pumpWidget(app(JellyfinPickerSource(
accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {})));
await tester.pumpWidget(scoped(_graph, app(JellyfinPickerSource(
accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {}))));
await tester.pump();
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
expect(find.text('Open Integrations'), findsOneWidget);
+13 -8
View File
@@ -21,6 +21,11 @@ import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
const episodeId = 'b18826be994070a79f9b52bc0f66f052'; // Black Summoner S1E6
const nextEpisodeId = '74c50bdbf83e8fef231dd7710058e197'; // S1E7
const seriesId = 'e9aec9c14a1fcab775111844112b4cf7';
@@ -485,7 +490,7 @@ void main() {
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
seasonId: season2Id),
]));
await tester.pumpWidget(app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {})));
await tester.pumpWidget(scoped(_graph, app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}))));
await settle(tester);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
@@ -506,18 +511,18 @@ void main() {
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0;
await tester.pumpWidget(app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++)));
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
await tester.tap(find.byKey(const ValueKey('play-next')));
expect(play, 1);
await tester.pumpWidget(app(EndOfEpisodeCard(
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next,
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
onPlayNow: () => play++,
onCancel: () => cancel++)));
onCancel: () => cancel++))));
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
expect(cancel, 1);
@@ -525,7 +530,7 @@ void main() {
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
var pressed = 0;
await tester.pumpWidget(app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++))));
await tester.pumpWidget(scoped(_graph, app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++)))));
double opacity() => tester.widget<AnimatedOpacity>(find.descendant(
of: find.byType(NextEpisodePill), matching: find.byType(AnimatedOpacity))).opacity;
expect(opacity(), lessThan(0.5));
@@ -541,10 +546,10 @@ void main() {
testWidgets('blocked card offers to link an account', (tester) async {
var link = 0;
await tester.pumpWidget(app(JellyfinBlockedCard(
await tester.pumpWidget(scoped(_graph, app(JellyfinBlockedCard(
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
onLinkAccount: () => link++,
onRetry: () {})));
onRetry: () {}))));
expect(find.text('No Jellyfin account here for Example Server, and nobody is passing it on'),
findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
+1 -1
View File
@@ -226,7 +226,7 @@ void main() {
final refused = await previewLink('https://cdn.example/b.mp4',
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
expect(await previewLink('not a link'), isNull);
expect(await previewLink('not a link', probe: (url) async => throw StateError('no probe')), isNull);
});
test('ago text', () {
+21 -18
View File
@@ -1,5 +1,3 @@
// 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';
@@ -16,6 +14,11 @@ import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id;
import 'jellyfin_browser_test.dart' show fixtureNow, settle;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
const bsSeries = 'e9aec9c14a1fcab775111844112b4cf7';
const bsEpisode = 'b18826be994070a79f9b52bc0f66f052';
const bsSeason = 'fb0b77cfda15445f9319ed9d753dba23';
@@ -50,13 +53,13 @@ FakeJellyfin libraryFake() {
}
class FilesStub extends StatelessWidget {
final bool solo, relayed;
final bool alone, relayed;
final Future<String?> Function(String path) onPlay;
const FilesStub({super.key, required this.solo, required this.relayed, required this.onPlay});
const FilesStub({super.key, required this.alone, required this.relayed, required this.onPlay});
@override
Widget build(BuildContext context) => Column(children: [
Text('files solo=$solo relayed=$relayed'),
Text('files alone=$alone relayed=$relayed'),
TextButton(
key: const ValueKey('stub-play'),
onPressed: () => onPlay('/v/a.mkv'),
@@ -77,7 +80,7 @@ class Harness {
Future<Harness> pumpLibrary(
WidgetTester tester,
Size size, {
bool solo = false,
bool alone = false,
bool isHost = false,
MediaKind source = MediaKind.jellyfin,
Harness? reuse,
@@ -97,7 +100,7 @@ Future<Harness> pumpLibrary(
addTearDown(c.dispose);
h = Harness(c, accounts, fake);
}
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: LibraryTab(
@@ -105,7 +108,7 @@ Future<Harness> pumpLibrary(
key: UniqueKey(),
controller: h.controller,
accounts: h.accounts,
solo: solo,
alone: alone,
isHost: isHost,
jellyfinStart: start,
recentLinks: h.recents,
@@ -115,15 +118,15 @@ Future<Harness> pumpLibrary(
: url.contains('bilibili')
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
filesBuilder: (context, {required alone, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(alone: alone, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
h.put.add(m);
return null;
},
),
),
));
)));
await settle(tester, 10);
return h;
}
@@ -156,7 +159,7 @@ void main() {
await tester.tap(find.text('Files'));
await settle(tester, 10);
expect(find.text('files solo=false relayed=true'), findsOneWidget);
expect(find.text('files alone=false relayed=true'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]);
@@ -251,8 +254,8 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('solo: buttons say Play and put up; a link goes direct, no relay modes', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
testWidgets('nobody else in the server (D9): buttons say Play and put up; a link goes direct, no relay modes', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), alone: true);
await tester.tap(arifuretaCard());
await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up'));
@@ -267,7 +270,7 @@ void main() {
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10);
expect(find.text('How the others get it'), findsNothing);
expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text(alonePlayHint), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
@@ -324,9 +327,9 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
testWidgets('your server: files stream from here; now-playing file passes through', (tester) async {
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
expect(find.text('files solo=false relayed=false'), findsOneWidget);
expect(find.text('files alone=false relayed=false'), findsOneWidget);
});
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
@@ -344,7 +347,7 @@ void main() {
expect(c.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin());
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true);
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, alone: true);
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
+31 -26
View File
@@ -12,6 +12,11 @@ import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/widgets/app_menu.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The Minimal skin's pieces and the shared controls frame (never the
// session screen or a media_kit `Video`).
@@ -81,8 +86,8 @@ void main() {
group('timeline', () {
testWidgets('played part, elapsed left, remaining right, cache label', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {}))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
expect(bar.width, 800);
expect(bar.height, MinimalTimelineSpec.size);
@@ -98,16 +103,16 @@ void main() {
});
testWidgets('the total instead of the time left', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), remaining: false, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(
position: m(6), duration: m(24), remaining: false, onSeek: (_) {}))));
expect(painter(tester).right, '24:00');
});
testWidgets('a press on the right-hand time switches it, no seek', (tester) async {
final seeks = <Duration>[];
var toggled = 0;
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), onSeek: seeks.add, onToggleTime: () => toggled++)));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(
position: m(6), duration: m(24), onSeek: seeks.add, onToggleTime: () => toggled++))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
await tester.tapAt(Offset(bar.right - 20, bar.center.dy));
expect(toggled, 1);
@@ -115,12 +120,12 @@ void main() {
});
testWidgets('a mark at each chapter start', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))));
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
});
testWidgets('hover: chapter title over the time, above the band', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final mouse = await mouseAt(tester, Offset.zero);
// 1:51 of 24:00: inside "Opening".
@@ -143,7 +148,7 @@ void main() {
testWidgets('a press on a hovered chapter mark jumps to its chapter', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final at = Offset(bar.left + bar.width * 2 / 24 + 3, bar.top + 2);
final mouse = await mouseAt(tester, at);
@@ -159,8 +164,8 @@ void main() {
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
expect(bar.height, MinimalTimeline.collapsedHeight);
final p = painter(tester);
@@ -177,7 +182,7 @@ void main() {
testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
await tester.pumpWidget(scoped(_graph, host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final g0 = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
expect(seeks, [m(6)], reason: 'the press seeks');
@@ -220,7 +225,7 @@ void main() {
bool up(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible;
testWidgets('hide after the delay, mouse brings them back', (tester) async {
await tester.pumpWidget(frame());
await tester.pumpWidget(scoped(_graph, frame()));
expect(up(tester), isTrue);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
@@ -237,7 +242,7 @@ void main() {
});
testWidgets('a long drag on the timeline keeps the controls up', (tester) async {
await tester.pumpWidget(frame());
await tester.pumpWidget(scoped(_graph, frame()));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final g = await tester.startGesture(Offset(bar.left + 100, bar.center.dy), kind: PointerDeviceKind.mouse);
for (var i = 0; i < 10; i++) {
@@ -254,7 +259,7 @@ void main() {
});
testWidgets('an open app menu keeps the controls up', (tester) async {
await tester.pumpWidget(frame(
await tester.pumpWidget(scoped(_graph, frame(
child: Builder(
builder: (context) => TextButton(
key: const ValueKey('open'),
@@ -262,7 +267,7 @@ void main() {
child: const Text('open'),
),
),
));
)));
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
expect(find.text('One'), findsOneWidget);
@@ -277,13 +282,13 @@ void main() {
testWidgets('double-click on the picture, not on the bars', (tester) async {
var doubles = 0;
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: ControlsFrame(
onDoubleClick: () => doubles++,
barBottom: 100,
builder: (context, visible) => const SizedBox.expand(),
),
));
)));
Future<void> twice(Offset at) async {
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(milliseconds: 100));
@@ -300,10 +305,10 @@ void main() {
group('buttons', () {
testWidgets('count badge in the accent; tooltip on the app glass', (tester) async {
await tester.pumpWidget(host(const Row(children: [
await tester.pumpWidget(scoped(_graph, host(const Row(children: [
MinimalButton(key: ValueKey('subs'), icon: Icons.subtitles_outlined, tooltip: 'Subtitles', badge: 2, onPressed: null),
MinimalButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
])));
]))));
expect(
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byKey(const ValueKey('minimal-badge'))),
findsOneWidget);
@@ -320,7 +325,7 @@ void main() {
final got = <double>[];
var volume = 80.0;
var before = 80.0;
await tester.pumpWidget(host(StatefulBuilder(
await tester.pumpWidget(scoped(_graph, host(StatefulBuilder(
builder: (context, setState) => Row(children: [
VolumeControl(
volume: volume,
@@ -337,7 +342,7 @@ void main() {
MinimalButton(key: const ValueKey('speaker'), icon: icon, tooltip: tip, onPressed: onPressed),
),
]),
)));
))));
await tester.tap(find.byKey(const ValueKey('speaker')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('speaker')));
@@ -361,7 +366,7 @@ void main() {
final steps = <int>[];
final list = ScrollController();
addTearDown(list.dispose);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: VolumeWheel(
onStep: steps.add,
child: Stack(children: [
@@ -375,7 +380,7 @@ void main() {
),
]),
),
));
)));
Future<void> wheel(Offset at, Offset delta) =>
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
await wheel(const Offset(100, 400), const Offset(0, -60));
@@ -407,7 +412,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SettingsTab(
@@ -416,7 +421,7 @@ void main() {
updates: UpdateController(currentVersion: '0.0.0-dev'),
),
),
));
)));
final design = find.byKey(const ValueKey('player-design'));
expect(find.text('Player design'), findsOneWidget);
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.standard);
+16 -11
View File
@@ -8,6 +8,11 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/session/player_controls.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The control-bar pieces alone (never the session screen or a `Video`):
// the Standard seek bar, the skip pill and the shared track / chapter
// lists.
@@ -38,8 +43,8 @@ Widget host(Widget child, {double width = 960}) => MaterialApp(
void main() {
group('seek bar', () {
testWidgets('a mark at each chapter start but the first, placed by time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))));
expect(find.byKey(const ValueKey('chapter-mark-0')), findsNothing, reason: 'starts at 0');
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
for (final c in chapters.skip(1)) {
@@ -51,16 +56,16 @@ void main() {
});
testWidgets('no marks without a duration or chapters', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: Duration.zero, duration: Duration.zero, chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(
position: Duration.zero, duration: Duration.zero, chapters: chapters, onSeek: (_) {}))));
expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing);
await tester.pumpWidget(host(ChapterSeekBar(position: Duration.zero, duration: m(24), onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(position: Duration.zero, duration: m(24), onSeek: (_) {}))));
expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing);
});
testWidgets('hover shows the chapter title above the time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
@@ -84,7 +89,7 @@ void main() {
});
testWidgets('no chapters: the hover shows only the time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(position: m(1), duration: m(24), onSeek: (_) {})));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(position: m(1), duration: m(24), onSeek: (_) {}))));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
@@ -97,8 +102,8 @@ void main() {
testWidgets('drag seeks on every move and on release', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
await tester.pumpWidget(scoped(_graph, host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy),
kind: PointerDeviceKind.mouse);
@@ -120,7 +125,7 @@ void main() {
(SkipKind.ending, 'Skip ED'),
(SkipKind.fixed, 'Skip 1:30'),
]) {
await tester.pumpWidget(host(Align(child: SkipPill(offer: SkipOffer(kind, m(2)), onPressed: () => pressed++))));
await tester.pumpWidget(scoped(_graph, host(Align(child: SkipPill(offer: SkipOffer(kind, m(2)), onPressed: () => pressed++)))));
expect(find.text(label), findsOneWidget);
}
double opacity() => tester.widget<AnimatedOpacity>(
+79 -42
View File
@@ -1,5 +1,3 @@
// 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';
@@ -15,10 +13,18 @@ import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/shell/avatar.dart';
import 'package:starsync/src/shell/party_strip.dart';
import 'package:starsync/src/shell/settings_tab.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/session/invite.dart';
import 'package:starsync/src/session/navigation.dart' show Place;
import 'package:starsync/src/state/connect_prefs.dart' show RecentServer;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The player settings card (both skins, never a media_kit `Video`), the
// Settings pages, the host flag in the party UI and invite addresses.
@@ -39,7 +45,7 @@ Future<void> pick(WidgetTester tester, String key, String item) async {
void main() {
final s = AppSettings.instance;
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
tearDown(() {
s.controlsSkin.value = ControlsSkin.standard;
@@ -54,8 +60,8 @@ void main() {
coord.playback.tracks.value = const [];
coord.serverState.value = null;
coord.hostFile = null;
SessionState.instance.route.value = AppRoute.home;
SessionState.instance.recents.value = const [];
_graph.navigation.place.value = Place.none;
_graph.forms.recents.value = const [];
});
Widget opener() => Builder(
@@ -73,7 +79,7 @@ void main() {
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
];
await tester.pumpWidget(app(Align(alignment: Alignment.bottomRight, child: opener())));
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
await tester.tap(find.byKey(const ValueKey('open-settings')));
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('player-settings-card'));
@@ -113,10 +119,10 @@ void main() {
testWidgets('keeps the controls up while open', (tester) async {
bigWindow(tester);
await tester.pumpWidget(app(ControlsFrame(
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
hideAfter: const Duration(seconds: 1),
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
)));
))));
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
await tester.tap(find.byKey(const ValueKey('open-settings')));
await tester.pumpAndSettle();
@@ -135,12 +141,12 @@ void main() {
Future<void> pumpSettings(WidgetTester tester, SettingsSection section,
{Future<AnimeFlowMe> Function()? me, Size size = const Size(1280, 1400)}) async {
bigWindow(tester, size);
await tester.pumpWidget(app(SettingsTab(
await tester.pumpWidget(scoped(_graph, app(SettingsTab(
section: section,
onSection: (_) {},
updates: UpdateController(currentVersion: '0.0.0-dev'),
animeFlowMe: me ?? () async => throw const AnimeFlowException(401, AnimeFlowErrorCode.missingToken, 'x'),
)));
))));
await tester.pumpAndSettle();
}
@@ -196,26 +202,27 @@ void main() {
expect(s.hiddenControls.value, isEmpty);
});
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
SessionState.instance.recents.value = const [
RecentServer(hosted: false, host: 'vps.example', port: '8080', secret: 'pw'),
RecentServer(hosted: true, host: '127.0.0.1', port: '9000'),
testWidgets('Party & network: your server\'s port and password, recent servers with remove', (tester) async {
_graph.forms.recents.value = const [
RecentServer(host: 'vps.example', port: '8080', secret: 'pw'),
RecentServer(host: 'friend.example', port: '9000'),
];
await pumpSettings(tester, SettingsSection.network);
expect(find.text('Default port'), findsOneWidget);
expect(find.text('Your server'), findsOneWidget);
expect(find.text('Port'), findsOneWidget);
expect(find.text('vps.example:8080'), findsOneWidget);
expect(find.text('Hosted on port 9000'), findsOneWidget);
expect(find.text('friend.example:9000'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('host-port')), '70000');
await tester.pump();
expect(find.text('A number from 1 to 65535'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('host-port')), '9100');
await tester.pump();
expect(SessionState.instance.lastForm.hostPort, '9100');
expect(_graph.forms.form.hostPort, '9100');
await tester.tap(find.descendant(
of: find.byKey(const ValueKey('recent-vps.example:8080')), matching: find.byTooltip('Remove')));
await tester.pumpAndSettle();
expect(find.text('vps.example:8080'), findsNothing);
SessionState.instance.lastForm.hostPort = '8080';
_graph.forms.form.hostPort = '8080';
});
testWidgets('Integrations: AnimeFlow says who you are connected as, or why not', (tester) async {
@@ -224,7 +231,7 @@ void main() {
userId: 'u1', name: 'foxyz', scopes: {AnimeFlowScope.view, AnimeFlowScope.request}));
expect(find.text('connected as foxyz · can view, request'), findsOneWidget);
// A fresh tree: the card keeps its first answer otherwise.
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(scoped(_graph, const SizedBox()));
await pumpSettings(tester, SettingsSection.integrations,
me: () async => throw const AnimeFlowException(
401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow'));
@@ -239,23 +246,43 @@ void main() {
final chip = tester.widget<ChoiceChip>(find.byKey(const ValueKey('settings-network')));
expect(chip.selected, isTrue);
expect((chip.avatar! as Icon).icon, Icons.hub_outlined);
expect(find.text('Default port'), findsOneWidget);
expect(find.text('Port'), findsOneWidget);
});
});
group('party', () {
test('invite addresses: LAN IPv4 with the port, private first, no loopback', () {
test('invite addresses: home network first; no proxy fake-IP, link-local, loopback or container bridge', () {
({String name, InternetAddress address}) at(String name, String ip) => (name: name, address: InternetAddress(ip));
final list = inviteAddresses([
InternetAddress('127.0.0.1'),
InternetAddress('100.64.1.2'),
InternetAddress('192.168.1.20'),
InternetAddress('169.254.3.4'),
InternetAddress('::1'),
InternetAddress('10.0.0.5'),
InternetAddress('192.168.1.20'),
], '8080');
expect(list, ['192.168.1.20:8080', '10.0.0.5:8080', '100.64.1.2:8080']);
expect(inviteAddresses(const [], '8080'), isEmpty);
at('lo', '127.0.0.1'),
at('Meta', '198.18.0.1'), // a TUN proxy's fake-IP range (198.18.0.0/15)
at('utun', '198.19.255.2'),
at('tailscale0', '100.64.1.2'),
at('docker0', '172.17.0.1'),
at('br-3f2a', '172.18.0.1'),
at('virbr0', '192.168.122.1'),
at('eth1', '10.0.0.5'),
at('wlan0', '169.254.3.4'),
at('eth0', '192.168.1.20'),
at('eth0', '192.168.1.20'),
at('eth0', '::1'),
at('wan', '203.0.113.7'),
]);
expect(list, ['192.168.1.20', '10.0.0.5', '100.64.1.2', '203.0.113.7']);
expect(inviteAddresses(const []), isEmpty);
expect(isFakeIp(InternetAddress('198.19.1.1').rawAddress), isTrue);
expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse);
});
test('an invite: the address, whether to ask for a password, the version; pasted back', () {
const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0');
expect(invite.address, '192.168.1.20:8080');
expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0');
expect(invite.fp, isNull, reason: 'reserved for pinned certificates');
expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080'));
expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000'));
expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080'));
expect(addressIn('just a host'), isNull);
});
void online(List<MemberView> members) {
@@ -267,7 +294,7 @@ void main() {
mediaId: 0,
members: members,
);
SessionState.instance.route.value = AppRoute.session;
_graph.navigation.place.value = Place.friend;
}
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
@@ -279,7 +306,7 @@ void main() {
expect(avatars['Hana']!.host, isTrue);
expect(avatars['Bob']!.host, isFalse);
bigWindow(tester);
await tester.pumpWidget(app(const Center(child: MembersCard())));
await tester.pumpWidget(scoped(_graph, app(const Center(child: MembersCard()))));
await tester.pumpAndSettle();
expect(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.text('host')),
findsOneWidget);
@@ -291,18 +318,28 @@ void main() {
expect(find.text('Sit out'), findsOneWidget);
});
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
testWidgets('your server, not shared: only you, Invite; shared: the invite and Stop sharing', (tester) async {
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0)]);
coord.hostFile = (_) async => null;
SessionState.instance.lastForm.hostPort = '8080';
_graph.navigation.place.value = Place.yours;
final yours = _graph.yourServer;
yours.port.value = 40000; // as if it runs
addTearDown(() {
yours.port.value = null;
yours.shared.value = null;
});
bigWindow(tester);
await tester.pumpWidget(app(Center(
child: MembersCard(lanAddresses: (port) async => ['192.168.1.20:$port', '10.0.0.5:$port']),
)));
await tester.pumpWidget(scoped(_graph, app(const Center(child: MembersCard()))));
await tester.pumpAndSettle();
expect(find.text('Your server'), findsOneWidget);
expect(find.textContaining('Only you can reach it'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Invite'), findsOneWidget);
expect(find.byKey(const ValueKey('stop-sharing')), findsNothing);
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0');
await tester.pumpAndSettle();
expect(find.text('Copy invite address'), findsOneWidget);
expect(find.text('192.168.1.20:8080'), findsOneWidget);
expect(find.text('10.0.0.5:8080'), findsOneWidget);
expect(find.text('Copy invite'), findsOneWidget);
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
expect(find.textContaining('port 8080 forwarded'), findsOneWidget);
expect(find.textContaining('VPS'), findsOneWidget);
});
+12 -8
View File
@@ -14,7 +14,11 @@ import 'package:starsync/src/session/now_playing.dart';
import 'package:starsync/src/shell/now_playing_title.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/shell/start_banner.dart';
import 'package:starsync/src/state/session_state.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The one computed status (each input → text and level), the player's
// title block, start card and status pill (never a media_kit `Video`).
@@ -78,7 +82,7 @@ StatusLine? line(StateBroadcast? state, {MemberView? self, ConnState conn = cons
st(state, self: self, conn: conn, playerError: playerError).line;
void main() {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
tearDown(() {
debugArtBytes = null;
@@ -265,7 +269,7 @@ void main() {
const now = NowPlaying(title: 'Frieren', episode: 'S1 E4 · The Land Where Souls Rest', posterHash: 'p');
testWidgets('title, episode and a placeholder cover without art', (tester) async {
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
await tester.pumpWidget(scoped(_graph, host(const NowPlayingBlock(now: now))));
expect(find.text('Frieren'), findsOneWidget);
expect(find.text('S1 E4 · The Land Where Souls Rest'), findsOneWidget);
expect(find.byIcon(Icons.movie_outlined), findsOneWidget);
@@ -276,16 +280,16 @@ void main() {
testWidgets('the cover image once its bytes are here', (tester) async {
debugArtBytes = (hash) => hash == 'p' ? png : null;
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
await tester.pumpWidget(scoped(_graph, host(const NowPlayingBlock(now: now))));
final image = tester.widget<Image>(find.byType(Image));
expect((image.image as MemoryImage).bytes, png);
expect(image.gaplessPlayback, isTrue);
});
testWidgets('no cover, no episode: just the title; long titles ellipsize', (tester) async {
await tester.pumpWidget(host(
await tester.pumpWidget(scoped(_graph, host(
NowPlayingBlock(now: NowPlaying(title: 'A very long title ' * 10), cover: false, compact: true),
width: 200));
width: 200)));
expect(find.byType(ArtCover), findsNothing);
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
expect(tester.takeException(), isNull);
@@ -293,7 +297,7 @@ void main() {
});
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner())));
await tester.pumpWidget(scoped(_graph, const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner()))));
final card = find.byKey(const ValueKey('start-card-title'));
expect(card, findsNothing);
coord.serverState.value = held([mv('a', 'Alex')]);
@@ -332,7 +336,7 @@ void main() {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)));
await tester.pumpWidget(scoped(_graph, MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child))));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
+7 -2
View File
@@ -6,12 +6,17 @@ import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/shell/library_link.dart' show LinkPreview;
import 'package:starsync/src/widgets/text_focus.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
void main() {
testWidgets('Up next popover: holds the keys, Ctrl+V adds with a preview row, + Add link opens a field',
(tester) async {
final coord = SessionCoordinator();
var clip = (files: const <String>[], text: 'look https://example.com/v' as String?);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: Center(
@@ -23,7 +28,7 @@ void main() {
),
),
),
));
)));
await tester.pump();
expect(textInputFocused(), isTrue, reason: 'the player keys rest while it is open');
expect(find.byKey(const ValueKey('queue-add-field')), findsNothing);
+12 -10
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 3 (Your server): "solo: the button says
// Play"; the rest stays.
import 'dart:convert';
import 'dart:io';
@@ -16,6 +14,11 @@ import 'package:starsync/src/screens/home/picked_media.dart';
import 'schedule_fake_server.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
/// Serves [snap] without any network; [animeFlow] is a real client (over
/// the fake server) for the page's actions.
class FakeRepo extends ScheduleRepository {
@@ -234,7 +237,7 @@ void main() {
WidgetTester tester, {
Size size = const Size(2560, 1080),
ScheduleSnapshot Function()? snap,
bool solo = false,
bool alone = false,
JellyfinAccounts? accounts,
}) async {
toasts.clear();
@@ -243,12 +246,12 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final repo = FakeRepo(snap ?? snapshot, animeFlow: flow);
await tester.pumpWidget(
await tester.pumpWidget(scoped(_graph,
MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SchedulePage(
solo: solo,
alone: alone,
repository: repo,
accounts: accounts,
onToast: toasts.add,
@@ -258,8 +261,7 @@ void main() {
},
),
),
),
);
)));
await tester.pump();
await tester.pump();
await io(tester, rounds: 4);
@@ -269,7 +271,7 @@ void main() {
/// Unmounts the page and lets the HttpClient's idle keep-alive timers
/// (made in the fake clock) run out.
Future<void> done(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(scoped(_graph, const SizedBox()));
await tester.pump(const Duration(seconds: 20));
}
@@ -482,8 +484,8 @@ void main() {
await done(tester);
});
testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true);
testWidgets('nobody else in the server (D9): the button says Play and works', (tester) async {
await pumpPage(tester, alone: true);
expect(find.text('Put it up'), findsNothing);
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
expect(b.onPressed, isNotNull);
+172 -143
View File
@@ -1,5 +1,5 @@
// restructure: rewrite in step 3 (Your server): one hub with listeners
// attached, nothing restarts, no solo mode.
// Your server: one hub for the app's life, a network listener attached
// by Invite and detached by Stop sharing, nothing restarts.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -9,11 +9,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/server_isolate.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:sync_relay_server/server.dart' show SyncServer;
import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart';
Future<void> _poll(
FutureOr<bool> Function() cond,
@@ -33,11 +37,14 @@ class _Raw {
final WebSocket ws;
final msgs = <Map<String, Object?>>[];
_Raw(this.ws) {
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
onDone: () => done.complete(ws.closeCode), onError: (_) {});
}
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
final done = Completer<int?>();
static Future<_Raw> join(int port, String name, {String secret = '', String host = '127.0.0.1'}) async {
final r = _Raw(await WebSocket.connect('ws://$host:$port/sync'));
r.ws.add(jsonEncode(HelloMessage(secret: secret, user: HelloUser(name: name)).toJson()));
await r.next('welcome');
return r;
@@ -66,6 +73,23 @@ Future<int> _freePort() async {
return port;
}
/// This machine's first LAN address (the invite's); loopback when none.
Future<String> _lanHost() async {
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
for (final a in i.addresses) {
if (!a.isLoopback) return a.address;
}
}
return '127.0.0.1';
}
List<Map> _members(Map<String, Object?> state) => (state['members'] as List).cast<Map>();
void _hive(String name) {
MediaKit.ensureInitialized();
Hive.init('${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/$name');
}
void main() {
test('server isolate: logs, raw join, crash reported, stop frees port',
timeout: const Timeout(Duration(minutes: 1)), () async {
@@ -108,153 +132,158 @@ void main() {
}
});
test('host path: isolate server, host sat out and nudging, crash leaves the session',
test('your server: Invite attaches the network listener and Stop sharing detaches it; '
'members and Up next never reset; a crash brings a new one home',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
Hive.init(
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_isolate_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
try {
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
await session.coordinator.playback.player.setVolume(0);
friend = await _Raw.join(port, 'friend', secret: 'pw');
final state = await friend.next('state',
where: (m) => (m['members'] as List).any((x) => (x as Map)['name'] == 'host'));
final hostId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id'];
// Anyone may sit the host out; its player unloads, the server serves on.
friend.ws.add(jsonEncode(PresenceIntent(target: '$hostId', state: Presence.away).toJson()));
await _poll(() => session.coordinator.selfAway.peek(), 'host sat out');
// The host nudges the friend: only the friend hears it.
final friendId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.nudgeMember('$friendId');
expect((await friend.next('nudge'))['name'], 'host');
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
expect(session.serverCrash.peek(), startsWith('server crashed'));
expect(session.hostedServer, isNull);
// The killed isolate's socket stays bound in-process; hosting again on
// the same port must still work.
session.serverCrash.value = null;
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
final again = await _Raw.join(port, 'again', secret: 'pw');
await again.ws.close();
await session.leave();
expect(session.hostedServer, isNull);
} finally {
await friend?.ws.close();
await session.coordinator.leave(silent: true);
await session.stopServer();
}
});
test('host path: a Jellyfin pick goes up as a ref with the host relay; File swap',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
_hive('hive_your_server_test');
final graph = testGraph();
final coord = graph.coordinator;
final yours = graph.yourServer;
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Hive.init('$tmp/hive_isolate_jf_test');
const sid = 'abcdefabcdefabcdefabcdefabcdef12';
final accounts = JellyfinAccounts.instance;
await accounts.ensureLoaded(validate: false);
// An unreachable Jellyfin: only the wire and the relay flag matter here.
await accounts.link(
baseUrl: 'http://127.0.0.1:9',
server: const JellyfinServerInfo(id: sid, name: 'J', version: '10.11.11'),
auth: const JellyfinAuthResult(
accessToken: 'HOSTTOKEN', user: JellyfinUser(id: 'u', name: 'u'), serverId: sid));
final session = SessionState.instance;
final file = File('$tmp/your server file.mkv')..writeAsBytesSync(List.filled(4096, 7));
final port = await _freePort();
_Raw? friend;
final file = File('$tmp/host file swap.mkv')..writeAsBytesSync(List.filled(4096, 7));
try {
await session.host(
ConnectParams(
host: '',
port: port,
secret: '',
name: 'host',
media: const HelloMedia(
name: 'Pilot',
size: 0,
duration: 0,
jellyfin: JellyfinRef(serverId: sid, itemId: 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1', title: 'Pilot'))),
ConnectForm());
await session.coordinator.playback.player.setVolume(0);
friend = await _Raw.join(port, 'friend');
final state = await friend.next('state', where: (m) => m['media'] != null);
final media = state['media'] as Map;
expect(media['jellyfin'], isA<Map>());
expect(media.containsKey('url'), isFalse);
expect(state['relay'], isTrue, reason: "the host's own sign-in relays for guests");
expect(jsonEncode(state), isNot(contains('HOSTTOKEN')));
expect(await session.coordinator.swapFile(file.path), isNull);
final swapped = await friend.next('state',
where: (m) => (m['media'] as Map?)?['name'] == 'host file swap.mkv');
expect((swapped['media'] as Map)['jellyfin'], isNull);
expect(swapped['relay'], isNull);
expect(await session.coordinator.swapFile('$tmp/missing.mkv'), contains('not found'));
} finally {
await friend?.ws.close();
await session.coordinator.leave(silent: true);
await session.stopServer();
await accounts.signOut(sid);
if (file.existsSync()) file.deleteSync();
}
});
test('solo: loopback-only server; inviting friends hosts the same media',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Hive.init('$tmp/hive_isolate_solo_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
final file = File('$tmp/solo carry.mkv')..writeAsBytesSync(List.filled(4096, 7));
session.route.value = AppRoute.home; // the tests above leave it set
try {
await session.startSolo(const ConnectParams(host: '127.0.0.1', port: 0, secret: '', name: 'me'));
expect(session.route.peek(), AppRoute.session);
expect(session.solo.peek(), isTrue);
await session.coordinator.playback.player.setVolume(0);
final soloPort = session.hostedServer!.port;
// Reachable on loopback only: a LAN address of this machine is refused.
final lan = [
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4))
for (final a in i.addresses)
if (!a.isLoopback) a,
];
if (lan.isNotEmpty) {
await expectLater(Socket.connect(lan.first, soloPort, timeout: const Duration(seconds: 3)),
expect(await graph.navigation.goHome(), isTrue);
expect(graph.navigation.place.peek(), Place.yours);
await coord.playback.player.setVolume(0);
final ownPort = yours.port.peek()!;
final me = coord.connection.memberId;
expect(me, isNotEmpty);
// Only this app can reach it: a LAN address of this machine is refused.
final lan = await _lanHost();
if (lan != '127.0.0.1') {
await expectLater(Socket.connect(lan, ownPort, timeout: const Duration(seconds: 3)),
throwsA(isA<SocketException>()));
}
expect(await session.coordinator.swapPicked(FileMedia(file.path)), isNull);
await _poll(() => session.coordinator.serverState.peek()?.media?['name'] == 'solo carry.mkv', 'solo media');
expect(coord.alone.peek(), isTrue);
expect(await coord.swapPicked(FileMedia(file.path)), isNull);
await _poll(() => coord.serverState.peek()?.media?['name'] == 'your server file.mkv', 'media up');
await session.host(ConnectParams(host: '', port: port, secret: 'pw', name: 'me'), ConnectForm());
expect(session.solo.peek(), isFalse);
expect(session.route.peek(), AppRoute.session);
friend = await _Raw.join(port, 'friend', secret: 'pw');
await friend.next('state', where: (m) => (m['media'] as Map?)?['name'] == 'solo carry.mkv');
graph.forms.form
..hostPort = '$port'
..hostSecret = 'pw';
final invite = await yours.invite();
expect(invite.port, port);
expect(invite.password, isTrue);
expect(yours.shared.peek(), invite);
expect(identical(await yours.invite(), invite), isTrue, reason: 'already shared: the same invite');
friend = await _Raw.join(port, 'friend', secret: 'pw', host: lan);
final state = await friend.next('state', where: (m) => _members(m).length == 2 && m['media'] != null);
expect(_members(state).firstWhere((m) => m['id'] == me)['host'], isTrue);
friend.ws.add(jsonEncode({
't': 'queue',
'op': 'add',
'item': {
'media': {'name': 'b', 'size': 0, 'duration': 0, 'url': 'http://example.com/b.mp4'},
'how': 'direct',
'title': 'B',
},
}));
await _poll(() => coord.queue.items.peek().length == 1, 'queued by the friend');
expect(coord.alone.peek(), isFalse);
// Anyone may sit the host out; its player unloads, the server serves on.
friend.ws.add(jsonEncode(PresenceIntent(target: me, state: Presence.away).toJson()));
await _poll(() => coord.selfAway.peek(), 'host sat out');
final friendId = _members(state).firstWhere((m) => m['name'] == 'friend')['id'];
coord.nudgeMember('$friendId');
expect((await friend.next('nudge'))['name'], isNotEmpty);
coord.joinBack();
await yours.stopSharing();
expect(await friend.done.future.timeout(const Duration(seconds: 5)), CloseCodes.closed,
reason: 'told: their app does not retry');
await _poll(() => coord.alone.peek(), 'alone again');
expect(coord.connection.memberId, me, reason: 'the same member, never reconnected');
expect(coord.restarts.peek(), 0);
expect(coord.queue.items.peek(), hasLength(1), reason: 'Up next stays');
expect(coord.serverState.peek()?.media?['name'], 'your server file.mkv');
expect(yours.port.peek(), ownPort, reason: 'nothing restarted');
final freed = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await freed.close();
// A crash: said, and home again on a new server.
yours.server!.isolate.kill(priority: Isolate.immediate);
await _poll(() => yours.crash.peek() != null, 'crash reported');
await _poll(() => graph.navigation.place.peek() == Place.yours && yours.port.peek() != null, 'home again');
expect(yours.crash.peek(), startsWith('server crashed'));
} finally {
await friend?.ws.close();
await session.leave();
await graph.navigation.leave();
await yours.stop();
if (file.existsSync()) file.deleteSync();
}
});
test('a friend\'s server and back: yours keeps what plays and Up next', timeout: const Timeout(Duration(minutes: 2)),
() async {
_hive('hive_your_server_switch_test');
final graph = testGraph();
final coord = graph.coordinator;
final yours = graph.yourServer;
final theirs = SyncServer(const AppConfig(port: 0, secret: '', name: "Bob's server"));
await theirs.start();
try {
expect(await graph.navigation.goHome(), isTrue);
await coord.playback.player.setVolume(0);
final ownPort = yours.port.peek();
coord.connection.sendSwap(const HelloMedia(name: 'mine', size: 0, duration: 0, url: 'http://127.0.0.1:9/mine.mp4'));
expect(await coord.queue.add(const LinkMedia('http://127.0.0.1:9/next.mp4', relay: LinkRelay.direct, anyway: true)), isNull);
await _poll(() => coord.serverState.peek()?.media?['name'] == 'mine' && coord.queue.items.peek().length == 1,
'media and Up next on your server');
await graph.navigation.join(ConnectParams(host: '127.0.0.1', port: theirs.boundPort, secret: '', name: 'me'));
expect(graph.navigation.place.peek(), Place.friend);
expect(graph.navigation.serverLabel.peek(), "Bob's server");
expect(coord.hostFile, isNull, reason: 'not your server');
await _poll(() => theirs.hub.engine.members.length == 1, 'in their server');
expect(yours.port.peek(), ownPort, reason: 'yours stays up, quiet');
expect(graph.forms.recents.peek().first.port, '${theirs.boundPort}');
expect(await graph.navigation.leaveFriend(), isTrue);
expect(graph.navigation.place.peek(), Place.yours);
await _poll(() => coord.serverState.peek()?.media?['name'] == 'mine', 'your media again');
expect(coord.queue.items.peek(), hasLength(1));
expect(yours.port.peek(), ownPort);
await _poll(() => theirs.hub.engine.members.isEmpty, 'left theirs with bye');
} finally {
await graph.navigation.leave();
await yours.stop();
await theirs.stop();
}
});
test('a friend stops sharing: told, and you are back home on yours', timeout: const Timeout(Duration(minutes: 2)),
() async {
_hive('hive_your_server_closed_test');
final me = testGraph();
final bob = testGraph();
final port = await _freePort();
try {
expect(await bob.navigation.goHome(), isTrue);
await bob.coordinator.playback.player.setVolume(0);
bob.forms.form
..hostPort = '$port'
..hostSecret = '';
final invite = await bob.yourServer.invite();
expect(await me.navigation.goHome(), isTrue);
await me.coordinator.playback.player.setVolume(0);
final mine = me.yourServer.port.peek();
await me.navigation.join(ConnectParams(host: '127.0.0.1', port: invite.port, secret: '', name: 'me'));
expect(me.navigation.place.peek(), Place.friend);
await _poll(() => !bob.coordinator.alone.peek(), 'bob sees me');
await bob.yourServer.stopSharing();
await _poll(() => me.navigation.place.peek() == Place.yours, 'back home');
expect(me.yourServer.port.peek(), mine, reason: 'the same server as before');
await _poll(() => me.notifier.toasts.peek().any((t) => t.text.contains('stopped sharing')), 'told why');
await _poll(() => bob.coordinator.alone.peek(), 'bob alone again');
} finally {
await me.navigation.leave();
await bob.navigation.leave();
await me.yourServer.stop();
await bob.yourServer.stop();
}
});
}
+70 -49
View File
@@ -1,5 +1,3 @@
// restructure: rewrite 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';
@@ -19,10 +17,17 @@ import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/shell/settings_tab.dart' show SettingsTab;
import 'package:starsync/src/shell/toasts.dart';
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/session/invite.dart' show Invite;
import 'package:starsync/src/session/navigation.dart' show Place;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
/// Stands in for media_kit's `Video` (which hangs flutter_test) and
/// counts how often one is created.
class FakeVideo extends StatefulWidget {
@@ -55,7 +60,7 @@ MemberView member(String id, String name, {bool ready = false, bool gone = false
presence: gone ? Presence.gone : Presence.watching);
void main() {
final session = SessionState.instance;
final session = _graph;
final coord = session.coordinator;
void online(List<MemberView> members, {bool media = true}) {
@@ -68,16 +73,16 @@ void main() {
media: media ? {'name': 'Movie.mkv', 'size': 1, 'duration': 0} : null,
members: members,
);
session.route.value = AppRoute.session;
session.navigation.place.value = Place.friend;
}
setUp(() {
FakeVideo.created = 0;
session.recents.value = const [];
session.forms.recents.value = const [];
});
tearDown(() {
session.route.value = AppRoute.home;
session.navigation.place.value = Place.none;
coord.serverState.value = null;
coord.playback.alive.value = false;
session.notifier.clear();
@@ -87,7 +92,7 @@ void main() {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: AppShell(
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
@@ -97,7 +102,7 @@ void main() {
// A real probe (network, yt-dlp) never answers on the test clock.
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
),
));
)));
await tester.pumpAndSettle();
}
@@ -148,14 +153,53 @@ void main() {
});
group('party strip', () {
testWidgets('offline: Join and Host, no party', (tester) async {
testWidgets('home, alone: a plain player\'s strip, Join a friend and Invite (once your server runs)',
(tester) async {
await pumpShell(tester, const Size(1280, 800));
expect(find.byKey(const ValueKey('strip-join')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-host')), findsOneWidget);
expect(find.text('Offline'), findsOneWidget);
expect(find.byKey(const ValueKey('strip-host')), findsNothing, reason: 'no Host: your server is always there');
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-leave')), findsNothing);
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
expect(tester.getSize(find.byType(PartyStrip)).height, partyStripHeight);
ButtonStyleButton invite() => tester.widget<ButtonStyleButton>(find.byKey(const ValueKey('strip-invite')));
expect(invite().onPressed, isNull, reason: 'your server is not up yet');
final yours = session.yourServer;
yours.port.value = 40000;
addTearDown(() {
yours.port.value = null;
yours.shared.value = null;
});
// On your own server with only you in it: still the plain strip.
session.navigation.place.value = Place.yours;
coord.serverState.value = StateBroadcast(
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0,
members: [member('', 'Me')]);
await tester.pumpAndSettle();
expect(invite().onPressed, isNotNull);
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
expect(find.byKey(const ValueKey('sharing-chip')), findsNothing);
// Shared, nobody came yet: the chip says so (and opens the invite).
yours.shared.value = const Invite(host: '192.168.1.20', port: 8080);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('sharing-chip')), findsOneWidget);
expect(find.text('port 8080'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('sharing-chip')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget);
await tester.tapAt(const Offset(40, 700));
await tester.pumpAndSettle();
// A friend came: the party, with Invite and no Leave (it is yours).
coord.serverState.value = StateBroadcast(
eventId: 2, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0,
members: [member('', 'Me'), member('b', 'Bob')]);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('party-avatars')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-invite')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-leave')), findsNothing);
expect(find.text('Your server'), findsOneWidget);
});
testWidgets('online: avatars, server and readiness, chat, Leave', (tester) async {
@@ -179,30 +223,7 @@ void main() {
expect(find.text('Leave server?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(session.route.value, AppRoute.session);
});
testWidgets('solo: just you, Invite friends opens the host card, Stop asks nothing', (tester) async {
await pumpShell(tester, const Size(1280, 800));
session.solo.value = true;
addTearDown(() => session.solo.value = false);
online([member('me', 'Me')], media: false);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('solo-chip')), findsOneWidget);
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
expect(find.byKey(const ValueKey('welcome-band')), findsNothing);
await tester.tap(find.byKey(const ValueKey('strip-invite')));
await tester.pumpAndSettle();
expect(find.text('Invite friends'), findsWidgets);
expect(find.text('Start hosting'), findsOneWidget);
await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('strip-leave')));
await tester.pumpAndSettle();
expect(find.text('Leave server?'), findsNothing);
expect(session.route.value, AppRoute.home);
expect(session.solo.value, isFalse);
expect(session.navigation.place.value, Place.friend);
});
testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
@@ -221,8 +242,8 @@ void main() {
group('popovers', () {
testWidgets('members card opens from the avatars and closes outside', (tester) async {
await pumpShell(tester, const Size(1280, 800));
session.serverVersion.value = '1.3.0-dev.1';
addTearDown(() => session.serverVersion.value = '');
session.navigation.serverVersion.value = '1.3.0-dev.1';
addTearDown(() => session.navigation.serverVersion.value = '');
online([member('a', 'Alex', ready: true, app: '1.3.0-dev.1'), ...party.skip(1)], media: false);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('avatar-stack')));
@@ -255,7 +276,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('strip-profile')));
await tester.pumpAndSettle();
expect(find.byType(ProfileCard), findsOneWidget);
expect(find.text('offline'), findsOneWidget);
expect(find.text('just you'), findsOneWidget);
await tester.tap(find.text('About'));
await tester.pumpAndSettle();
expect(find.byType(ProfileCard), findsNothing);
@@ -263,7 +284,7 @@ void main() {
});
testWidgets('showPopover fades and scales in, Esc closes it', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Builder(
builder: (context) => Center(
child: TextButton(
@@ -272,7 +293,7 @@ void main() {
),
),
),
));
)));
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(easeTime ~/ 2);
@@ -303,7 +324,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('handoff-card')), findsOneWidget);
expect(find.text('Open in Link'), findsOneWidget); // offline
expect(find.text('Open in Link'), findsOneWidget); // alone
await tester.tap(find.byKey(const ValueKey('handoff-openLink')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('handoff-card')), findsNothing);
@@ -318,15 +339,15 @@ void main() {
final before = p.push;
p.push = ({String? name, String? avatar}) => pushed.add(name);
addTearDown(() => p.push = before);
session.lastForm.name = 'Old';
session.forms.form.name = 'Old';
p.name.value = 'Old';
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(body: Center(child: ProfileCard(onOpenSettings: (_) {}))),
));
)));
await tester.enterText(find.byKey(const ValueKey('profile-name')), 'Neo');
await tester.pump();
expect(p.name.value, 'Neo');
expect(session.lastForm.name, 'Neo');
expect(session.forms.form.name, 'Neo');
// Typing settles before it is sent, once.
expect(pushed, isEmpty);
await tester.pump(const Duration(milliseconds: 700));
@@ -391,7 +412,7 @@ void main() {
// Never rebuilt from scratch on the way.
expect(FakeVideo.created, 1);
// Offline: no player at all.
session.route.value = AppRoute.home;
session.navigation.place.value = Place.none;
await tester.pumpAndSettle();
expect(videos, findsNothing);
expect(tester.takeException(), isNull);
@@ -439,7 +460,7 @@ void main() {
});
testWidgets("toasts slide in and leave when the Notifier's time is up", (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts())));
await tester.pumpWidget(scoped(_graph, const MaterialApp(home: Scaffold(body: PartyToasts()))));
session.notifier.notify(const Copied('Shaders off'));
await tester.pump();
await tester.pump();
@@ -484,7 +505,7 @@ void main() {
final down = tester.getRect(find.byType(ToastCard));
expect(down.bottom, closeTo(body.bottom - AppShellState.toastInset, 1));
expect(down.bottom, greaterThan(up.bottom));
session.route.value = AppRoute.home;
session.navigation.place.value = Place.none;
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
});
+47
View File
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// The app's own `.instance` singletons and how often `lib` reaches for
/// each. Systems get their dependencies through the composition root
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
/// Lower a number when a use goes; never raise one or add a class.
const baseline = {
'AppSettings': 55,
'JellyfinAccounts': 10,
'UpdateController': 8,
'Profile': 8,
'FolderStore': 6,
'AppYtDlp': 4,
'RecentLinks': 3,
};
void main() {
test('no new .instance singletons, and no more uses of the old ones', () {
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.dart'))
.toList();
final sources = [for (final f in files) f.readAsStringSync()];
// The app's own classes (framework singletons like
// `WidgetsBinding.instance` are not counted).
final classes = {
for (final s in sources)
for (final m in RegExp(r'^(?:abstract |final |base )*class (\w+)', multiLine: true).allMatches(s)) m.group(1)!,
};
final counts = <String, int>{};
for (final s in sources) {
for (final m in RegExp(r'\b([A-Z]\w*)\.instance\b').allMatches(s)) {
final name = m.group(1)!;
if (classes.contains(name)) counts[name] = (counts[name] ?? 0) + 1;
}
}
final grown = {
for (final e in counts.entries)
if (e.value > (baseline[e.key] ?? 0)) e.key: '${e.value} (baseline ${baseline[e.key] ?? 'none'})',
};
expect(grown, isEmpty,
reason: 'pass the system in through AppGraph / AppScope instead of a new `.instance` use or singleton');
});
}
+14
View File
@@ -0,0 +1,14 @@
// Test support (not a test): the app's composition root for tests.
import 'package:flutter/widgets.dart';
import 'package:starsync/src/app/app_graph.dart';
import 'package:starsync/src/screens/home/health_probe.dart';
import 'package:starsync/src/session/identity.dart' show ClientIdentity;
/// One graph per test file, like the app's one per run: nothing starts
/// until a test calls `navigation.start()` (or `goHome`), so widget tests
/// see the app before your server is up.
AppGraph testGraph({HealthGetter healthGet = defaultHealthGet}) =>
AppGraph.build(identity: ClientIdentity.ephemeral(), healthGet: healthGet);
/// [child] under [graph]'s [AppScope].
Widget scoped(AppGraph graph, Widget child) => AppScope(graph: graph, child: child);
+9 -4
View File
@@ -2,20 +2,25 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/widgets/text_focus.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
void main() {
// Regression: the old FocusNode-parent walk never met EditableText, so
// session shortcuts (Space, arrows, F) fired while typing in chat.
testWidgets('text field focus is detected, buttons are not', (tester) async {
final field = FocusNode();
final button = FocusNode();
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(
body: Column(children: [
TextField(focusNode: field),
TextButton(focusNode: button, onPressed: () {}, child: const Text('b')),
]),
),
));
)));
expect(textInputFocused(), isFalse);
field.requestFocus();
@@ -33,9 +38,9 @@ void main() {
testWidgets('focus under a KeyboardClaim counts as typing', (tester) async {
final claimed = FocusNode();
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: KeyboardClaim(child: Focus(focusNode: claimed, child: const SizedBox())),
));
)));
expect(textInputFocused(), isFalse);
claimed.requestFocus();
await tester.pump();
+9 -5
View File
@@ -6,16 +6,20 @@ import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The wheel anywhere on the full player flashes the volume readout, in
// both skins, windowed and fullscreen, with the controls up or hidden.
// The picture is a black box with the session's controls on it (never a
// media_kit `Video`).
void main() {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
final s = AppSettings.instance;
// A real player for the volume steps to go to. Never disposed: the
@@ -46,7 +50,7 @@ void main() {
for (final fullscreen in [false, true]) {
final what = '${skin.name}, ${fullscreen ? 'fullscreen' : 'windowed'}';
s.controlsSkin.value = skin;
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(scoped(_graph, MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SessionPlayer(
@@ -68,7 +72,7 @@ void main() {
]),
),
),
));
)));
await tester.pump();
expect(opacity(), 0, reason: '$what: nothing before a step');
@@ -91,7 +95,7 @@ void main() {
expect(opacity(), 0, reason: '$what: gone after the second');
}
}
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(scoped(_graph, const SizedBox()));
await tester.pump(const Duration(seconds: 5));
});
}
+177 -104
View File
@@ -9,7 +9,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `starsync-server` |
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | |
@@ -23,40 +23,80 @@ WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
One entrypoint: `serve(config, events:)` in `packages/server`. `config` is
an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
`ready` (with the port and a `stop` SendPort), `error` (start failed), and
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
all sockets and returns, and the isolate exits. `{'t':'file', 'path', 'reply'}`
on the same port makes a local file the hosted one and answers its name and
size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
`swap` with that name and size, which the server matches to the file.
`SyncServer` (bound to all IPv4 interfaces, or 127.0.0.1 with an extra
`'loopbackOnly': true` key) and reports on the `events` port: `ready`
(with the port and a control SendPort), `error` (start failed), and every
log record as `log`. On the control port: `{'t':'stop'}` closes all
sockets and returns, and the isolate exits; `{'t':'file', 'path',
'reply'}` makes a local file the hosted one and answers its name and size
(`ServerIsolate.setFile`: a File put up here then sends a `swap` with that
name and size, which the server matches to the file); `{'t':'share',
'port', 'secret', 'jellyfin', 'reply'}` and `{'t':'unshare', 'reply'}`
attach and detach the network listener (below).
- **Hosting from the app**: `SessionState.host()` spawns an isolate running
`serve` (`ServerIsolate`), waits for `ready`, then joins it over
`127.0.0.1` like any other client. Its log records go to the app's log
as `server.<logger>` (see "Logging"). The isolate runs with `errorsAreFatal: false`: an
uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; the
shell says so in a dialog (during setup the host attempt fails with it).
Stop (leave, close, cancel during setup, a failed own join)
sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Solo session** (watching alone): the same hosting path, so nothing
about solo is a second code path or on the wire. Putting something up
while offline (`AppShellState._putUp`) calls `SessionState.startSolo`:
a `ServerIsolate` with `loopbackOnly` (an extra `'loopbackOnly': true`
key next to the `AppConfig` map; `SyncServer` binds 127.0.0.1 instead
of every IPv4 interface), port 0 (a free one), no password, a host key;
the app joins it and `SessionState.solo` is set. Everything a session
has works (Jellyfin progress, next episode, skip pills, the player); a
link goes up direct (no relay mode). Invite friends opens the Host card,
whose submit (`SessionState.host` while solo) remembers the
coordinator's `lastPick` (the last `swapLink` / `swapJellyfin` /
`swapFile` of this connection) and position, leaves and stops the solo
server, hosts on the card's port and password, swaps the same pick and
seeks once a state shows it (`switching` is set meanwhile; the shell
closes the player and reopens it as it was). If hosting fails or is
cancelled, solo starts again with the same media. Stop leaves without
asking.
- **Your server** (`session/your_server.dart`, `YourServer`): every app
runs one server for its whole life, started when the app starts
(`Navigation.start`) as a `ServerIsolate` running `serve`: port 0 (a
free one), loopback only, no password, a random `hostKey` per app run,
named "<your name>'s server", its session log in the app's logs folder
and its log records in the app's log as `server.<logger>` (see
"Logging"). Nobody else can reach it. The isolate runs with
`errorsAreFatal: false`: an uncaught async error is logged and the
server keeps going. If the isolate dies anyway, `YourServer.crash` is
set (the shell says so in a dialog) and, unless you are in a friend's
server, the navigation starts a new one and goes home to it.
- **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a
second listener on every IPv4 interface at the port from Settings →
Party & network (default 8080) to the running server (Relic serves the
same routes on both), sets the password and the Jellyfin relay login
(the default account's, so guests without an account watch Jellyfin
items through the server); `unshare` closes it. Nothing restarts for
either: members on the loopback listener keep their sockets, the media
and Up next stay. Unsharing sends everyone but the host out: each
leaves at once (feed `leave`, reason `closed`) and its socket closes
with 4016, so its app does not retry (see "Connection"); the password
goes back to none. A port another program holds fails the share with
its reason; nothing else changes.
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
server if it is not yet and returns an `Invite`: the host, the port, the
name, whether a password is needed (never the password), the version
and an `fp` field reserved for a pinned certificate (`wss://` with a
self-signed one, later; always null). The host is the best of
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
proxies like Clash hand out) address and no container or VM bridge
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
other (public) address. No public-address lookup: the members card says
that friends elsewhere need the port forwarded or a server they can all
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
`addressIn` reads the address back out of a pasted invite (the Join
card's address field). `stopSharing` unshares.
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
`none` (starting, or between two servers), `yours` or `friend`. The app
starts at home: your server, joined over 127.0.0.1 with the host key
(the `hostKey` stands in for the password, so a password set later never
locks this app out), `hostFile` set to `YourServer.setFile`. A join
first reads the friend's `/health` (the version gate, below); an
unreachable server stops there, still at home. Then this app leaves
your server (`bye`) and connects to theirs; a join that fails or is
cancelled goes home again. Leaving theirs (Leave), or theirs closing to
friends (`ConnRefused` `closed`: "The host stopped sharing their
server"), goes home. Your server stays up all the while: what plays
there and Up next wait for you, and whoever you invited stays in it.
A joined server's name and version (`serverLabel`, `serverVersion`)
come from its `/health`; yours is this app's. The Join card's recents
are the friends' servers that got in.
- **Version gate**: `/health` gives `protoMin`, `protoMax` and `version`
(a proto 15 server only `proto`, used for both). `versionFit` against
this build's range: ranges that meet are fine; a server whose newest is
older than this app's oldest "runs Starsync 1.2.1 — they need to
update", one whose oldest is newer "— you need to update", and then the
Join card offers "Check for the update" (the updater, see "Updater").
Nothing is dialled for either. A server that says nothing about its
version is left to the hello's refusal (see "Connection").
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `starsync-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
@@ -65,6 +105,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
(`--upgrade` / `--check-update` run the [updater](#updater) instead);
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
- **App close**: after the players and the `bye` (see "Desktop UI",
closing), your server stops (`stop`, then killed after 3 s).
`SyncServer` runs a 1 s heartbeat (`ServerHub.heartbeat`): closed-socket
sweep, the engine's sweep (silence, joining, gone notices, grace),
@@ -72,16 +114,34 @@ relay source state, warm relay, rates, state + feed broadcast. There is
one session, id `default`.
Passwords: the default secret is empty, which means an open server (a
warning is logged). Otherwise one shared secret, compared at hello.
warning is logged). Otherwise one shared secret, compared at hello; your
server's is the one from Settings, set when it is shared.
Host: when hosting, the app generates a random `hostKey` per hosting, puts
it in the server config and sends it in its own `hello`. The member whose
key matches is the host (`members[].host`: the crown, art rights). The
address a connection comes from plays no part (so a reverse proxy or tunnel
on the same machine does not make everyone the host). The dedicated server
has no key, so it has no host. Nobody removes anyone: members set each
other away ("Presence" below), the host included, since the hosted
server and its `/m/` file serving do not need the host's player.
Host: the app generates a random `hostKey` per app run, puts it in your
server's config and sends it in its own `hello`. The member whose key
matches is the host (`members[].host`: the crown, art rights) and needs no
password. The address a connection comes from plays no part (so a reverse
proxy or tunnel on the same machine does not make everyone the host). The
dedicated server has no key, so it has no host. Nobody removes anyone:
members set each other away ("Presence" below), the host included, since
your server and its `/m/` file serving do not need the host's player;
only stopping sharing sends guests out.
## Composition root
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
(`AppGraph.build`) and constructs and disposes the app's systems: the
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
`state/connect_prefs.dart`), `YourServer` and `Navigation`; it wires them
through their constructors (and gives `Profile` the form and the push) and
has no logic or signals of its own. Widgets reach it through one
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in
`initState`); `shutDownApp` gets it from `main` (`closeAppWith`). Tests
build their own (`test/test_graph.dart`). The app's remaining `.instance`
singletons (settings, Jellyfin accounts, the updater, the profile, Files'
folders, yt-dlp, recent links) may only lose uses: `singletons_test`
fails when one gains a use or a new one appears.
## Sync model
@@ -252,7 +312,7 @@ Loosely modeled on Syncplay, with a different readiness design.
it back `in` (without touching the ready flag), but only from an away
it set itself: a manual one (own or someone else's) stays. Never when
alone (`isAlone` in core: no other member connected, `in`, `joining` or
`away`) and never in a solo session. The window changes come from
`away`). The window changes come from
`ShellPort.watchActivity` (focused, unfocused, minimised, hidden); the
timings are `AutoSitOutTimes`.
- **Nudge**: `nudge` names a member; the server passes it to that member
@@ -272,7 +332,7 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
## Connection
`Connection` (`session/connection.dart`) is the link to the server, made
in the composition root (`SessionState.connection`) and passed to the
in the composition root (`AppGraph`) and passed to the
`SessionCoordinator`. It owns the `SyncClient` socket, the first connect
(`begin` + `dial`), reconnects (`delayFor`: 1 s, doubling per failed
attempt up to 15 s, each ±20 % jitter), the epoch (`invalidate`,
@@ -302,8 +362,9 @@ dial), `ConnOnline` (welcome received, frames arrive), `ConnUnstable`
socket closed or errored, or nothing for `silenceLimit`, 9 s: the socket
is dropped, the next attempt scheduled), `ConnReconnecting(attempt)` (an
attempt dialling, and after it failed until the next one),
`ConnRefused(code, message)` (`version`, `secret`, or empty for a first
dial that failed otherwise; nothing retries) and `ConnReplaced` (`err`
`ConnRefused(code, message)` (`version`, `secret`, `closed` for close
4016, the host stopped sharing their server, or empty for a first dial
that failed otherwise; nothing retries) and `ConnReplaced` (`err`
`replaced`, close 4013 or 4014: another window of this install holds the
member; nothing retries, "Retry" takes it back). The silence and the
lost sound are measured on `Connection.now` (injectable, UTC wall clock
@@ -595,8 +656,9 @@ For viewers without an account on the item's Jellyfin
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
isolate boundary in the `serve()` map). The hosting app fills it with its
own app-obtained token and that token's device fields; the dedicated server
isolate boundary in the `serve()` map). The app sends it when your server
is shared (`share`): its own app-obtained token and that token's device
fields; the dedicated server
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
token off the command line) or a `jellyfin:` map in `--config`. An empty
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
@@ -631,7 +693,7 @@ What gets relayed (`SessionCoordinator.swapPicked`):
| Put up | Others get it |
|---|---|
| File, by the host | `/m` from the hosting app (no relay) |
| File, on your own server | `/m` from your server (no relay) |
| File, by anyone else | relayed from that member's computer |
| Link, "Everyone fetches it" | each app fetches it |
| Link, "Server fetches it" | the server reads it once into the buffer; the link is never broadcast; refused on an open server (it would fetch any address it can reach for anyone) |
@@ -855,7 +917,7 @@ all three link modes. Nothing is bundled or installed by hand.
it. `version`, `lastError`, `busy` and `changes` feed Settings → Player
→ yt-dlp ("Update now" forces the check). Every failure is non-fatal.
Folders: the app's is `<app support>/yt-dlp` (`AppYtDlp`, warmed up at
start), shared with its hosted server; the dedicated server's is
start), shared with your server; the dedicated server's is
`--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else
`yt-dlp` next to the binary if writable, else
`~/.cache/watch-together/yt-dlp`.
@@ -874,7 +936,7 @@ all three link modes. Nothing is bundled or installed by hand.
youtube.com and bilibili's CDNs under bilibili.com). The app: Settings
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
dropped, kept whole: every site in it; "Remove cookies"; the shell's
own drop target is off on Settings so only this box takes the file); its hosted server shares the folder,
own drop target is off on Settings so only this box takes the file); your server shares the folder,
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
re-set before each external link when it changed, after
@@ -1316,7 +1378,7 @@ click on a chat toast); cleared on connect.
**Ports.** UI and session code reach the platform through interfaces
(`platform/ports/`), made once in the composition root
(`SessionState.ports`, `desktopPorts()` in `platform/desktop/`) and
(`AppGraph.ports`, `desktopPorts()` in `platform/desktop/`) and
handed on from there (the coordinator gets its `Notifier`, `Connection` and
`LinkResolver` as constructor arguments):
- `ShellPort` (`DesktopShell`): window start (remembered bounds, the
@@ -1355,8 +1417,9 @@ caches.
## Desktop UI
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
(online = `SessionState.route` is `session`). The window comes back as it was
One window, `AppShell` (`lib/src/shell/`), wherever the app is
(`Navigation.place`: your server, a friend's, none while it moves). The
window comes back as it was
(`platform/desktop/window_memory.dart`, settings key `windowBounds`, saved on
resize/move/maximize and close): its normal size and maximized state
everywhere, its position where an app may place itself (Windows, X11;
@@ -1396,27 +1459,34 @@ panel) and chat messages (Copy message, and a link in it).
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow; the version in the Settings tab's tooltip, at
the bottom of every Settings page and in About), then offline a grey dot,
"Offline", Join a friend and Host (connect cards; icon-less "Join" and
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
(the Host card, titled "Invite friends", "Start hosting") and Stop (no
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
`Status.party` ("ready n/m": who the ready counter counts,
`countsForReady` in `session/party.dart`, on `PresencePolicy`, so not
away, gone or errored; "connecting…", "reconnecting…"; hidden under
1100 px), chat with an unread badge, and Leave (asks first). The stack
opens the members card (the server's version under its title: a hosted
server is this app's, a joined one's from `/health.version`; the source
the bottom of every Settings page and in About), then the party. Alone
on your server (D9, `SessionCoordinator.alone`: nobody else connected)
it is a plain player's: no ring on your avatar, no avatars or chat, Join
a friend (the Join card; "Join" under 1100 px) and Invite (disabled until
your server runs), plus a "Sharing · port 8080" chip while it is shared
(it opens the members card). With others there, or in a friend's server:
`AvatarStack` (host first, then ready; spreads on hover; 5 shown, 3
under 1100 px), the server name ("Your server", else the joined one's
`/health` name, else its address) and `Status.party` ("ready n/m": who
the ready counter counts, `countsForReady` in `session/party.dart`, on
`PresencePolicy`, so not away, gone or errored; "connecting…",
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
Invite on yours or Leave on a friend's (asks first, then you are home).
Invite (`inviteFriends`) shares your server if it is not yet and copies
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
is a notice pointing at Settings. The stack
opens the members card (the server's version under its title: yours is
this app's, a joined one's from `/health.version`; the source
line, then per member status, route and buffer dot (see "Relay",
Stats), their Starsync version (`members[].app`, amber when it is not
this app's; also in the avatar's hover), host / relay pills; "joining…", "sitting out", or for a `gone` member
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
address": the dialled address when joined; hosting, `inviteAddresses`
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
ranges first) with the Host port, and a note that friends outside the
LAN need a forwarded port or a reachable server). Empty strip space is the window
out (anyone, the host too), your own row Sit out / Join back; at the
bottom, on yours: not shared, "Only you can reach it" and Invite;
shared, the invite's address, Copy invite, Stop sharing and a note
that friends outside the LAN need a forwarded port or a reachable
server; on a friend's, "Copy invite address" (the dialled address)).
Empty strip space is the window
drag area; the window buttons sit at its end.
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size):
@@ -1428,16 +1498,18 @@ panel) and chat messages (Copy message, and a link in it).
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
Colours are local: yours is picked in the profile, others' come from
their name.
- **Connect cards** (`connect_cards.dart`): Join lists recent servers as
one-click rows that poll `GET /health` every 10 s while the card is open
(`health_probe.dart`: online, name, viewer count, "needs update" when
`proto` differs from ours), then address, port and password. Host takes
a port and a password (hosted ports as chips) and puts nothing up: what
to watch is picked in the library afterwards. Submit state (busy,
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
attempt and its error outlive the card; a card closes itself once
in a server that is not solo. Join says that a friend gives the address
and that no account or Jellyfin is needed.
- **Join card** (`connect_cards.dart`): recent servers as one-click rows
that poll `GET /health` every 10 s while the card is open
(`health_probe.dart`: online, name, viewer count, "needs update" with
the version gate's sentence as its hint), then address, port and
password; an invite pasted into the address fills the port too
(`addressIn`). Submit state (busy, cancel, the error) is `ConnectFlow`,
owned by the shell so an attempt and its error outlive the card; the
version gate's refusal says who has to update and, when it is you,
offers "Check for the update". The card closes itself once in the
friend's server. It says that a friend gives the invite and the
password, that no account or Jellyfin is needed and that your own
server stays as it is.
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`:
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
@@ -1446,13 +1518,13 @@ panel) and chat messages (Copy message, and a link in it).
the `connect_form` box (an old picture path is shrunk once and kept as
bytes). Online, a name change (once typing settles) or picture change
goes out through `Profile.push` (`coordinator.updateProfile`).
- **Welcome** (`welcome.dart`): offline the Library opens with
`WelcomeBand` over the source switch, only for newcomers (no Jellyfin
account, no recent server, not dismissed with "Don't show again";
sizes away when online): the app
- **Welcome** (`welcome.dart`): alone on your server the Library opens
with `WelcomeBand` over the source switch, only for newcomers (no
Jellyfin account, no recent server, not dismissed with "Don't show
again"; sizes away once others are there): the app
icon, Starsync and "Watch together, in sync", then three `WayCard`s,
Join a friend first (filled), Host, Watch solo (opens Files, or
Jellyfin when linked); beside the name from 940 px, under it below,
Join a friend first (filled), Invite friends, Watch on your own (opens
Files, or Jellyfin when linked); beside the name from 940 px, under it below,
stacked under 520 px. With no Jellyfin server linked the Jellyfin source
is `NoJellyfinPanel`: Files and Link cards ("No account needed") and an
optional Jellyfin card whose "Link a server" opens the sign-in in place.
@@ -1490,9 +1562,10 @@ panel) and chat messages (Copy message, and a link in it).
an isolate, videos grouped by series; series start collapsed, a header
click opens one and open ones stay open for the app session
(`showFilesFolder` opens a folder from outside). With nobody else
watching (`solo` on the Library, Files and Schedule: offline or solo)
the buttons say Play, Link has no relay modes and Files no relay notice;
offline a put-up starts a solo session. Schedule (`schedule/`) is a week of day columns built
in the server (`alone` on the Library, Files and Schedule, D9) the
buttons say Play, Link has no relay modes and a link goes up direct,
Files has no relay notice; a put-up before your server is up waits for
it. Schedule (`schedule/`) is a week of day columns built
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
memory and 1 h on disk, stale data shown on 429 or failure) matched
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
@@ -1530,7 +1603,8 @@ panel) and chat messages (Copy message, and a link in it).
Settings (`settings_tab.dart`) has a left nav with icons
(icon chips under 760 px) over pages of rounded Material cards
(`SettingsPanel`), one help line under every setting: profile, party &
network (Host card port and password in the connect form, recent
network (your server's port and password, used the next time it is
shared, in the connect form; recent
servers), player (the shared rows below, the controls' choices, custom
shader files),
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
@@ -1544,7 +1618,7 @@ panel) and chat messages (Copy message, and a link in it).
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
an image or nothing; videos win over folders, folders over images.
`HandoffCard` opens as a popover at the pointer: a video goes up through
the shell's put-up (Play when alone; offline that starts solo) or adds
the shell's put-up (Play when alone) or adds
its folder to Files; a folder is added to Files and opened; a link fills
Library → Link (with others the relay mode is picked there). In a
server, videos and links also get Add to queue / Play next. Images are ignored.
@@ -1767,9 +1841,9 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The library
remembers the last pick in the `jellyfin` box and puts it up as a ref
(`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay
credential (`relayCredentialFor`: the account of the
item's server, else the default one, not expired; its token with
(`putUpJellyfinMedia`, `swapPicked`); your server gets the relay
credential when it is shared (`relayCredentialFor`: the default
account, not expired; its token with
exactly `JellyfinAccounts.clientInfo`'s device fields).
- Browser location: every navigation saves the route stack per server
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
@@ -1802,7 +1876,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
play is always first and fullscreen last. `menuActions` is the ⚙ menu
(`ControlAction`, fixed order): chapters, the hidden track lists,
Skip 1:30, upscaling shader, the hidden chat, change video, copy link
(when `currentLink` has one), reload, mark everyone ready (not solo),
(when `currentLink` has one), reload, mark everyone ready (not alone),
mark a problem, player settings, leave. Icons, tooltips (with keys) and
the clock text (`clockText`) are defined there too, once.
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
@@ -1887,10 +1961,10 @@ and the sound engine (500 ms), side by side; on Windows 300 ms more,
since media_kit_video frees a video output on a thread of its own after
dispose returns, and an output still up when the engine stops blocks the
plugin's teardown (`~VideoOutput` waits on a texture callback that never
comes). Then `SessionState.leave`
comes). Then `Navigation.leave`
(Jellyfin stop report, last diagnostics, then `bye`, flushed: the app
waits up to 600 ms for the server to close the socket, so others see
"left" at once) and the hosted server's stop, 3 s in all. Update and
"left" at once) and your server's stop (`YourServer.stop`), 3 s in all. Update and
restart leaves without `bye` (`shutDownApp(restart: true)`): the member
stays `gone`, and the restarted app, with the same client id, takes it
back within the grace when it joins again; it then closes Hive (the
@@ -1919,15 +1993,14 @@ dedicated server (`package:logging` loggers named by area: `relay`,
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
(Linux `~/.local/share/dev.foxyz.starsync/logs`; with a
profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
"Desktop UI"); its hosted server's records arrive over the isolate's
"Desktop UI"); your server's records arrive over the isolate's
`events` port and go in the same file as `server.<logger>` (`relogLine`). The dedicated server
writes `server-YYYY-MM-DD.log` in `--log-dir`, else `logs` next to
`--config`, else `logs` next to the binary (systemd:
`$STATE_DIRECTORY/logs`), else `~/.cache/watch-together/logs`; it
prints the folder at start. Both also print every line to stdout.
The session log (below) sits next to them: the hosting app's server
writes it in the app's logs folder (not for a solo session), the
dedicated one in its log folder.
The session log (below) sits next to them: your server writes it in
the app's logs folder, the dedicated one in its log folder.
- **Files** (`LogFiles`): one per day, the next `.1.log`, `.2.log` once a
file passes 10 MiB; files older than 7 days, then the oldest beyond
100 MiB in all (the app's or server's files and the session log's
@@ -1974,7 +2047,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
`packages/core/lib/src/diag_share.dart`): one shared log per server so
a problem can be read afterwards from every side. While in a session
the app (`DiagUploader`, owned by the `Connection`) keeps its own
records (not its hosted server's `server.*`, which the server logs
records (not your server's `server.*`, which the server logs
itself), redacted, and sends them every 10 s from the connection's 1 s
heartbeat and when it leaves (before its token goes, at most 1.5 s), as
gzip batches to `POST /l/<sid>/<token>` (PROTOCOL
+35 -22
View File
@@ -86,10 +86,14 @@ Three suites, run from each package's folder:
on loopback: auth and version refusal, identity, takeover and grace
(`presence_test` on a fake clock), media and relay byte serving with
source drops, Jellyfin relay against a fake, queue, session log,
isolate `serve`.
isolate `serve`, sharing (`share_test`: a listener attached and detached
under the same members and Up next, the host key standing in for the
password).
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
shader list, sound engine with a fake backend) and the real-mpv `*_e2e`
shader list, sound engine with a fake backend, invite addresses, the
version gate), `singletons_test` (the `.instance` count, see
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
tests plus `coord_presence`. The whole suite takes a couple of minutes;
run it only before a restructure step lands, otherwise only the files
related to what you changed.
@@ -155,10 +159,10 @@ in [ROADMAP.md](ROADMAP.md)):
|---|---|---|
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (host sat out through `presence`), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) |
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) |
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
A test file whose first line is `// restructure: rewrite in step N` pins
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
@@ -223,19 +227,22 @@ Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
updater skips the check when absent. Workflows live in `.gitea/workflows`
(the forge reads `.gitea` before `.github`).
## Debugging the hosted server
## Debugging your server
When hosting, the server runs on its own isolate (`ServerIsolate`,
`apps/desktop/lib/src/state/server_isolate.dart`), in the same process.
Breakpoints in `packages/server` work under `flutter run`: the debugger
attaches to spawned isolates too (it shows as `server`). Its log lines
carry `server.<logger>` (e.g. `server.relay`). Hot reload reloads the server isolate's
code too, but its state (members, clock, sockets) survives until hosting
restarts; changes to startup code (`SyncServer.start`, routes) need a new
Host. Hot restart kills the server isolate (flutter_tools kills every
non-UI isolate first), so a `[server] server crashed: the server stopped
unexpectedly` line just before it is expected. Without the app:
`dart run packages/server/bin/server.dart`.
The app's own server runs on its own isolate (`ServerIsolate`,
`apps/desktop/lib/src/state/server_isolate.dart`, owned by `YourServer`)
in the same process, from start to close. Breakpoints in
`packages/server` work under `flutter run`: the debugger attaches to
spawned isolates too (it shows as `server`). Its log lines carry
`server.<logger>` (e.g. `server.relay`). Hot reload reloads the server
isolate's code too, but its state (members, clock, sockets) survives;
changes to startup code (`SyncServer.start`, `share`, routes) need a
restart of the app. Hot restart kills the server isolate (flutter_tools
kills every non-UI isolate first), so a `[server] server crashed: the
server stopped unexpectedly` line just before it is expected. Its
loopback port changes every run (the log's `listening on` line has it);
`STARSYNC_PROFILE` gives a second window its own server. Without the
app: `dart run packages/server/bin/server.dart`.
## Logs
@@ -243,7 +250,7 @@ After a session, read the log files rather than reproducing
("Logging" in [ARCHITECTURE.md](ARCHITECTURE.md) has what goes in them):
the app's are `~/.local/share/dev.foxyz.starsync/logs/app-*.log`
on Linux (on Windows `%APPDATA%\foxyz\Starsync\logs`; Settings → About → "Open logs
folder" finds it anywhere), with the hosted server's lines
folder" finds it anywhere), with your server's lines
in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy
diagnostics".
@@ -285,8 +292,8 @@ is off), not software rendering.
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
- A killed isolate (`Isolate.kill`) does not close its listening socket:
the port stays bound in the process until exit. `SyncServer` binds with
`shared: true` so a new hosted server in the same process can take it
over; a normal stop closes it.
`shared: true` (both listeners) so a new one in the same process can
take it over; a normal stop closes it.
- Relic server-side `onDone` does not fire for dead peers. Presence is the
`isClosed` sweep + `onError` forget + `lastSeen` timeouts.
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
@@ -318,9 +325,15 @@ is off), not software rendering.
raw pointer observer (`DoubleClickListener`).
- The window is frameless: the party strip is the only drag area and holds
the window buttons.
- Signal-backed singletons (`Profile`, `SessionState`) live across tests;
never cache a `Future` in them (each test runs in its own zone, and a
Future from an earlier test never completes in the next).
- Signal-backed singletons (`Profile`, `AppSettings`) and a test file's
`AppGraph` live across tests; never cache a `Future` in them (each test
runs in its own zone, and a Future from an earlier test never completes
in the next).
- Widgets read the systems from `AppScope`: a widget test pumps its tree
under one (`scoped(graph, ...)` from `test/test_graph.dart`; one
`testGraph()` per file). A `State` that needs a system in `dispose`
reads it before (`late final` touched in `initState`): the scope cannot
be looked up from a deactivated element.
- Chat arrives twice from the server (see PROTOCOL.md); the client drops the
textless feed copy.
- Tab traversal is disabled in the session player on purpose; Tab toggles
+10 -8
View File
@@ -40,18 +40,20 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
## Ways to run
- **Host from the app**: the app you click Host in also runs the server.
Simplest. The server lives as long as that app.
- **Your server**: every app runs its own server, quietly, from start to
close, reachable only from that computer. You are on it when you are
not in a friend's, so watching alone works like a session (progress,
next episode). Invite opens it to friends on your network (a second
listener on the same server: nothing restarts, the video and Up next
carry on) and copies an invite; Stop sharing closes it again.
- **Dedicated server**: `starsync-server`, a separate program in the
release tarball, for an always-on box. See the README.
Friends join either way with Join a friend in the strip at the top of the
window; guests need no Jellyfin or account.
- **Watch solo**: put something up without joining or hosting and the
app hosts a private server reachable only from this computer, so
watching alone works like a session (progress, next episode). Invite
friends turns it into a normal hosted server, video and position kept.
window; guests need no Jellyfin or account. Before connecting, the app
checks the server's version and says who has to update when they do not
match. Joining a friend leaves your server (it stays up); leaving theirs
brings you back.
## One session, start to finish
+16 -7
View File
@@ -28,7 +28,9 @@ yet; the restructure step named next to them does.
`name`, `proto` (= `protoMax`), `protoMin`, `protoMax`, `version` (the
server's app version, e.g. `1.3.0`), `tls` (**reserved**: always false;
a server with TLS would serve the same endpoints over `wss://` /
`https://`), `viewers`, `bytesServed`, `uptimeSecs`.
`https://`), `viewers`, `bytesServed`, `uptimeSecs`. The app reads it
before joining and does not dial a server whose range does not meet its
own (the version gate, see ARCHITECTURE "Server lifecycle").
- `GET /m/<sid>/<token>/<name>` serves the host's file (Range table in
ARCHITECTURE.md). `GET /j/<sid>/<token>/<itemId>` relays the current
Jellyfin media and Up next's first one (see "Jellyfin media" below).
@@ -80,6 +82,7 @@ yet; the restructure step named next to them does.
| 4013 | `/sync` | `err` `replaced`: another connection of the same client holds the member |
| 4014 | `/sync` | refused: `err` `stale` |
| 4015 | `/sync` | silent for 15 s: the member is `gone` |
| 4016 | `/sync` | the hosting app stopped sharing its server: the member has left (`leave`, reason `closed`); the client does not reconnect |
## Client to server
@@ -114,12 +117,15 @@ Field notes:
and refuses with `err` `version` when the ranges do not meet.
`hello.media` is ignored; a proto 16 client still sends `{}` so a proto
15 server, which requires it, answers with its own `proto mismatch`.
- `hello.secret`: the server password; a server with an empty secret is open.
- `hello.secret`: the server password; a server with an empty secret is
open. The password may change while members are in (the hosting app
sets it when it opens its server to friends).
- `hello.hostKey` (optional, omitted when empty): the hosting app
generates a random key per hosting, gives it to its server and sends it
generates a random key per run, gives it to its server and sends it
from its own client. The member whose key equals the server's is the host
(`members[].host`). A server without a key (the dedicated server) has no
host. Where the connection comes from does not matter.
(`members[].host`), and its hello needs no `secret`. A server without a
key (the dedicated server) has no host. Where the connection comes from
does not matter.
- `hello.client` (optional): a random id per install, 16 to 64
characters of `[A-Za-z0-9_-]` (the app uses 22 characters of
base64url), else `err` `bad`. Private: never broadcast, never logged.
@@ -184,7 +190,10 @@ Field notes:
feed line. Unknown targets and no-op changes are ignored.
- `bye`: the member leaves now (the Leave button, the app closing): feed
`leave` (reason `bye`), its token stops working, the socket closes
(1000). Anything else that ends a socket keeps the member (`gone`).
(1000). Anything else that ends a socket keeps the member (`gone`),
except the hosting app closing its server to friends: everyone but the
host leaves at once (feed `leave`, reason `closed`) and the socket
closes with 4016.
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
to `target` only (no feed). At most 3 per second and 20 per minute per
sender; the rest, nudges to oneself and to unknown targets are dropped
@@ -284,7 +293,7 @@ Field notes:
server's own lines; omitted when none): match people by id, not by
name. `action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `join` (a new member only), `leave` (for
good: `reason` `bye` or `grace`), `gone` (10 s after the member went
good: `reason` `bye`, `grace` or `closed`), `gone` (10 s after the member went
`gone`; a quicker return says nothing), `return` (back within the grace
after a `gone` line), `away` (`target` the member set away, absent when
the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
+17 -14
View File
@@ -44,10 +44,6 @@ Problems are numbered; delete a line when its fix ships.
the player instead of being one media pipeline.
- P18 Bilibili "no video formats" with browser cookies set; the hint
wording misleads (D11).
- P22 Friends on different versions (D8). (P20 version visible and P21
update band: fixed in 1.2.1-dev.3; check the band in real use.)
- P23 Solo mode you enter and leave; going hosted loses the queue (D9).
- P24 The invite address list is unhelpful (shows the proxy's 198.18.x).
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
Jellyfin items everyone has an account for.
@@ -75,11 +71,6 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
3. Your server: one hub, listeners attached and detached without members
reconnecting (old isolate path kept until that's proven); Invite as a
value (filtered addresses, version gate from `/health.version`, an `fp`
field reserved for pinned certificates later); `SessionState` is
deleted here. Fixes P22 P23 P24; D9.
4. Library + Media identity: L1 local grouping (kinds, folder hints,
gaps, merge across folders) and L2 match (AniList/TVDB, art, names,
manual pin, cache); `MediaRef` in swap/queue media. Fixes P34.
@@ -96,11 +87,11 @@ integrations (Jellyfin played/resume, AniList list).
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
update for 1.2.x) and update the VPS dedicated server in the same
window; check a 1.2.x app gets a clear "update the app" message.
Composition root: an `AppGraph` built in `main.dart` only constructs and
disposes systems (no logic, no signals); systems get dependencies through
constructors, widgets through one `AppScope`; a test fails if the count
of `.instance` singletons grows (today: SessionState 94, AppSettings 51,
JellyfinAccounts 10, Profile 7).
Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 55,
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
AppYtDlp 4, RecentLinks 3).
Not in the restructure: P25 (ops).
@@ -130,6 +121,18 @@ restarted app, but the app starts on Home (it does not rejoin by itself)
and does not remember what it was passing on, so a relay it served ends
with its grace unless it is put up again; worth doing only if updates
ever happen mid-session (D8 says never during playback).
Left of step 3 (your server): a human check of Invite (Linux and, at
1.3.0, Windows' firewall prompt, now on Invite rather than at start;
that the invite skips the proxy's 198.18.x here) and of the update band
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
members card only says friends elsewhere need the port forwarded. Your
server's `/health` name is the profile name at start (a rename shows
after a restart). Guests' Jellyfin relay uses the default account (no
longer the put-up item's server). Joining a friend while others are on
your server leaves them there without you, unwarned; a join refused
after dialling (wrong password) costs your own member there (`bye`, back
as a new member). Your server writes its session log even when you are
alone.
Left of step 2: system notifications need a human check (Linux: the
notification shows and a click brings the window up, also on Wayland,
where focus may be refused without an activation token; Windows: toasts
+3
View File
@@ -667,6 +667,9 @@ abstract class CloseCodes {
static const stale = 4014;
/// `/sync`: no frame for the silence limit; the member is `gone`.
static const silent = 4015;
/// `/sync`: the hosting app stopped sharing its server; the member has
/// left (feed `leave`, reason `closed`) and nothing reconnects.
static const closed = 4016;
/// `/sync`: the member left (`bye`, or grace over).
static const left = 1000;
}
+28 -2
View File
@@ -17,9 +17,14 @@ import 'sync_server.dart';
/// completes once it is down. `{'t':'file','path':p,'reply':SendPort}` makes
/// a file on this machine the hosted file (the hosting app's in-session file
/// swap; a following `swap` with its name and size puts it up) and answers
/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a
/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`.
/// `{'t':'share','port':p,'secret':s,'jellyfin':map?,'reply':SendPort}`
/// opens the running server to the network ([SyncServer.share]) and
/// answers `{'ok':true,'port':p}` or `{'ok':false,'message':m}`;
/// `{'t':'unshare','reply':SendPort}` closes it again
/// ([SyncServer.unshare]) and answers `{'ok':true}`. Without [events] a
/// start failure throws. `'loopbackOnly': true` in [config] (next to the
/// [AppConfig] keys) binds 127.0.0.1 only: the app's solo session;
/// [AppConfig] keys) binds 127.0.0.1 only: the hosting app's own server;
/// `'logDir'` is the folder of the session log ([SyncServer.logDir]).
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
StreamSubscription<pkg_logging.LogRecord>? logs;
@@ -50,6 +55,27 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
await for (final msg in control) {
if (msg is! Map) continue;
if (msg['t'] == 'stop') break;
if (msg['t'] == 'share' && msg['reply'] is SendPort) {
final reply = msg['reply'] as SendPort;
try {
final port = await server.share(
port: (msg['port'] as num?)?.toInt() ?? 0,
secret: (msg['secret'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(msg['jellyfin']));
reply.send({'ok': true, 'port': port});
} catch (e) {
reply.send({'ok': false, 'message': '$e'});
}
continue;
}
if (msg['t'] == 'unshare' && msg['reply'] is SendPort) {
final reply = msg['reply'] as SendPort;
try {
await server.unshare();
} catch (_) {}
reply.send({'ok': true});
continue;
}
if (msg['t'] == 'file' && msg['path'] is String && msg['reply'] is SendPort) {
final reply = msg['reply'] as SendPort;
try {
+21 -1
View File
@@ -487,6 +487,24 @@ class ServerHub {
changed();
}
/// The hosting app stopped sharing: every member but the host leaves
/// now (feed `leave`, reason `closed`), its socket closed with
/// [CloseCodes.closed] so its app does not reconnect. Returns how many
/// left.
int closeGuests() {
final guests = [
for (final m in engine.members.values)
if (!m.host) m.token,
];
for (final token in guests) {
final ws = sockets.remove(token);
if (ws != null) unawaited(closeWith(ws, CloseCodes.closed, 'the server stopped sharing'));
engine.applyLeave(token, reason: 'closed');
}
if (guests.isNotEmpty) changed();
return guests.length;
}
/// `bye`: [token]'s member leaves now.
void bye(String token) {
engine.applyLeave(token, reason: 'bye');
@@ -953,7 +971,9 @@ void mountSyncRoute(
return;
}
final session = hub.session;
if (session.secret.isNotEmpty && hello.secret != session.secret) {
// The hosting app's own client (its host key) needs no password: the
// password may change while it is connected ([SyncServer.share]).
if (session.secret.isNotEmpty && hello.secret != session.secret && !hub.isHostKey(hello.hostKey)) {
await refuse(const ErrorFrame(code: ErrCode.secret, message: 'wrong secret', forType: 'hello'),
CloseCodes.secret, '${cleanMemberName(hello.user.name)}: wrong secret');
return;
+3 -1
View File
@@ -56,7 +56,9 @@ class SessionMedia {
class Session {
final String id;
final String secret;
/// The server password; '' is an open server. The hosting app changes it
/// when it starts sharing ([SyncServer.share]).
String secret;
final Map<String, ViewerToken> tokens = {};
SessionMedia? media;
final _random = Random.secure();
+69 -2
View File
@@ -53,10 +53,14 @@ class SyncServer {
/// Upstream connections of the Jellyfin relay; created on first use.
HttpClient? _relayClient;
/// Listen on 127.0.0.1 only (the app's solo session), not on every
/// IPv4 interface.
/// Listen on 127.0.0.1 only (the hosting app's own server until it
/// shares it, see [share]), not on every IPv4 interface.
final bool loopbackOnly;
/// The second listener [share] attaches: every IPv4 interface, its own
/// port; null while not shared.
RelicServer? _shared;
/// The folder of the session log (`session-YYYY-MM-DD.log`, every
/// member's diagnostics and the server's records merged); null: none.
final String? logDir;
@@ -79,6 +83,9 @@ class SyncServer {
bool get isRunning => _server != null;
int get boundPort => _server?.port ?? config.port;
/// The port friends dial while shared ([share]); null otherwise.
int? get sharedPort => _shared?.port;
Session get session => sessions.getOrCreate('default', config.secret);
Future<bool> setLocalFile(String path) async {
@@ -205,8 +212,68 @@ class SyncServer {
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}');
}
/// Opens the running server to the network: a second listener on
/// every IPv4 interface at [port] (0: a free one), the password
/// [secret] ('' : open) and the Jellyfin relay login [jellyfin]. Members
/// already in (on the first listener) stay connected; nothing restarts.
/// Shared already: the listener moves to [port] when it differs. Returns
/// the bound port; throws when it cannot bind.
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin}) async {
if (_server == null) throw StateError('the server is not running');
final was = _shared;
if (was != null && (port == 0 || port == was.port)) {
_applyShareSettings(secret, jellyfin);
await _resolveJellyfinServerId();
return was.port;
}
if (was != null) {
_shared = null;
await was.close(force: true);
}
final listener = RelicServer(() => IOAdapter.bind(InternetAddress.anyIPv4, port: port, shared: true));
try {
await listener.mountAndStart(_app.asHandler);
} catch (e) {
try {
await listener.close(force: true);
} catch (_) {}
rethrow;
}
_shared = listener;
_applyShareSettings(secret, jellyfin);
await _resolveJellyfinServerId();
if (secret.isEmpty) _log.warning('shared without a password: anyone who can reach port ${listener.port} can join');
_log.info('server "${config.name}" shared on port ${listener.port}');
return listener.port;
}
void _applyShareSettings(String secret, JellyfinCredential? jellyfin) {
session.secret = secret;
if (jellyfin != null) hub.jellyfin = jellyfin;
}
/// Closes the network listener [share] opened: everyone but the host
/// leaves ([ServerHub.closeGuests]), the password goes; the host's own
/// member, the media and Up next stay as they are.
Future<void> unshare() async {
final listener = _shared;
if (listener == null) return;
_shared = null;
final left = hub.closeGuests();
session.secret = '';
await listener.close(force: true);
_log.info('server "${config.name}" no longer shared${left == 0 ? '' : ' ($left left)'}');
}
Future<void> stop() async {
if (_server == null) return;
final shared = _shared;
_shared = null;
if (shared != null) {
try {
await shared.close(force: true);
} catch (_) {}
}
_heartbeat?.cancel();
_heartbeat = null;
_relayClient?.close(force: true);
+138
View File
@@ -0,0 +1,138 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
/// A bare client: the wire only.
class _Raw {
final WebSocket ws;
final msgs = <Map<String, Object?>>[];
final done = Completer<int?>();
_Raw(this.ws) {
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
onDone: () => done.complete(ws.closeCode), onError: (_) {});
}
static Future<_Raw> join(String host, int port, String name,
{String secret = '', String hostKey = '', String client = ''}) async {
final r = _Raw(await WebSocket.connect('ws://$host:$port/sync'));
r.ws.add(jsonEncode(HelloMessage(
secret: secret, user: HelloUser(name: name), hostKey: hostKey, client: client, seq: 1)
.toJson()));
await r.next('welcome');
return r;
}
Future<Map<String, Object?>> next(String type, {bool Function(Map<String, Object?>)? where}) async {
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
for (final m in msgs) {
if (m['t'] == type && (where == null || where(m))) {
msgs.remove(m);
return m;
}
}
if (done.isCompleted) throw StateError('closed (${ws.closeCode}) before $type');
await Future.delayed(const Duration(milliseconds: 50));
}
fail('timed out waiting for $type');
}
}
Future<void> _poll(bool Function() cond, String what) async {
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
if (cond()) return;
await Future.delayed(const Duration(milliseconds: 50));
}
fail('timed out: $what');
}
/// This machine's first non-loopback IPv4 address, if it has one.
Future<InternetAddress?> _lanAddress() async {
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
for (final a in i.addresses) {
if (!a.isLoopback) return a;
}
}
return null;
}
void main() {
test('share and unshare: one hub, the host and Up next stay, guests are told', () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'own', hostKey: 'HK'), loopbackOnly: true);
await server.start();
_Raw? host, guest;
try {
host = await _Raw.join('127.0.0.1', server.boundPort, 'me', hostKey: 'HK', client: 'hostclient00000000000a');
await host.next('state');
final hostId = server.hub.engine.members.values.single.id;
host.ws.add(jsonEncode({
't': 'queue',
'op': 'add',
'item': {
'media': {'name': 'a', 'size': 0, 'duration': 0, 'url': 'http://example.com/a.mp4'},
'how': 'direct',
'title': 'A',
},
}));
await _poll(() => server.hub.engine.queue.items.length == 1, 'queued');
final lan = await _lanAddress();
if (lan != null) {
await expectLater(Socket.connect(lan, server.boundPort, timeout: const Duration(seconds: 3)),
throwsA(isA<SocketException>()),
reason: 'not shared: loopback only');
}
final port = await server.share(port: 0, secret: 'pw');
expect(server.sharedPort, port);
// A guest without the password is refused; with it, joins.
await expectLater(_Raw.join('127.0.0.1', port, 'nopw'), throwsA(anything));
guest = await _Raw.join(lan?.address ?? '127.0.0.1', port, 'guest', secret: 'pw', client: 'guestclient0000000000a');
await _poll(() => server.hub.engine.members.length == 2, 'guest in');
expect(server.hub.engine.members.values.map((m) => m.id), contains(hostId));
// The host's own reconnect needs no password (its key).
expect(host.done.isCompleted, isFalse, reason: 'sharing does not touch the host');
await server.unshare();
expect(server.sharedPort, isNull);
expect(await guest.done.future.timeout(const Duration(seconds: 5)), CloseCodes.closed);
await _poll(() => server.hub.engine.members.length == 1, 'guest left');
expect(server.hub.engine.members.values.single.id, hostId, reason: 'the same member, never reconnected');
expect(server.hub.engine.queue.items, hasLength(1), reason: 'Up next stays');
expect(host.done.isCompleted, isFalse);
expect(server.session.secret, '');
final leave = await host.next('feed', where: (m) => m['action'] == 'leave');
expect(leave['reason'], 'closed');
await expectLater(Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 3)),
throwsA(isA<SocketException>()),
reason: 'the listener is gone');
// Shared again: same hub, the queue still there.
final again = await server.share(port: port, secret: '');
expect(again, port);
final g2 = await _Raw.join('127.0.0.1', port, 'guest2', client: 'guestclient0000000000b');
final st = await g2.next('state');
expect((st['queue'] as List), hasLength(1));
await g2.ws.close();
} finally {
await host?.ws.close();
await guest?.ws.close();
await server.stop();
}
});
test('the host key stands in for the password', () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'own', hostKey: 'HK'));
await server.start();
try {
final host = await _Raw.join('127.0.0.1', server.boundPort, 'me', hostKey: 'HK');
await host.ws.close();
await expectLater(_Raw.join('127.0.0.1', server.boundPort, 'x', hostKey: 'nope'), throwsA(anything));
} finally {
await server.stop();
}
});
}