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