Restructure step 3: your server, Invite, version gate, AppGraph
- 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:
1 parent
629be0d9ed
commit
4d00b7455e
86 files changed
+2972
-2050
No files matched your search
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 ---")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,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!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
if (friend)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const ValueKey('strip-leave'),
|
key: const ValueKey('strip-leave'),
|
||||||
onPressed: onLeave,
|
onPressed: onLeave,
|
||||||
child: const Text('Leave'),
|
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,52 +283,76 @@ 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(
|
|
||||||
key: const ValueKey('solo-chip'),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
onTap: () => showMembersCard(context),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (busy)
|
Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
|
||||||
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
else
|
|
||||||
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
|
const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
if (!compact && !busy) ...[
|
if (!compact) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
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: [
|
||||||
|
if (invite == null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
|
||||||
|
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
|
||||||
|
style: TextStyle(color: muted, fontSize: 12)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
key: const ValueKey('copy-invite'),
|
||||||
|
onPressed: busy ? null : () => inviteFriends(context),
|
||||||
|
icon: const Icon(Icons.group_add_outlined, size: 16),
|
||||||
|
label: const Text('Invite'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text(invite.address,
|
||||||
|
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: list.isEmpty ? null : () => copy(list.first),
|
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite 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'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('stop-sharing'),
|
||||||
|
onPressed: busy ? null : () => yours.stopSharing(),
|
||||||
|
child: const Text('Stop sharing'),
|
||||||
),
|
),
|
||||||
if (snap.connectionState != ConnectionState.done)
|
|
||||||
const SizedBox.shrink()
|
|
||||||
else if (list.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Text('No network address found', style: TextStyle(color: muted)),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
for (final a in list)
|
|
||||||
InkWell(
|
|
||||||
key: ValueKey('invite-$a'),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
onTap: () => copy(a),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
|
|
||||||
Icon(Icons.copy, size: 14, color: muted),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
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;
|
||||||
|
|||||||
@@ -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'],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 +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,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user