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
+2993
-2071
No files matched your search
@@ -1,4 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
const connectFormBoxName = 'connect_form';
|
||||
|
||||
@@ -19,3 +23,165 @@ String readString(Box box, String key, String fallback) {
|
||||
final v = box.get(key);
|
||||
return v is String ? v : fallback;
|
||||
}
|
||||
|
||||
const connectKeyHostPort = 'hostPort';
|
||||
const connectKeyHostSecret = 'hostSecret';
|
||||
const connectKeyRecents = 'recents';
|
||||
const maxRecentServers = 6;
|
||||
|
||||
/// The connect form: the Join card's address ([host]/[port]/[secret]),
|
||||
/// your server's port and password when shared ([hostPort]/[hostSecret]),
|
||||
/// the profile name and the last media picked.
|
||||
class ConnectForm {
|
||||
String host;
|
||||
String port;
|
||||
String secret;
|
||||
String hostPort;
|
||||
String hostSecret;
|
||||
String name;
|
||||
String avatarPath;
|
||||
String mediaPath;
|
||||
String linkUrl;
|
||||
ConnectForm({
|
||||
this.host = '127.0.0.1',
|
||||
this.port = '8080',
|
||||
this.secret = '',
|
||||
this.hostPort = '8080',
|
||||
this.hostSecret = '',
|
||||
this.name = '',
|
||||
this.avatarPath = '',
|
||||
this.mediaPath = '',
|
||||
this.linkUrl = '',
|
||||
});
|
||||
|
||||
ConnectForm copy() => ConnectForm(
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
hostPort: hostPort,
|
||||
hostSecret: hostSecret,
|
||||
name: name,
|
||||
avatarPath: avatarPath,
|
||||
mediaPath: mediaPath,
|
||||
linkUrl: linkUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// A friend's server the user got into, shown as a Recent row on the
|
||||
/// Join card (one click joins).
|
||||
class RecentServer {
|
||||
final String host;
|
||||
final String port;
|
||||
final String secret;
|
||||
const RecentServer({required this.host, required this.port, this.secret = ''});
|
||||
|
||||
String get key => '${host.toLowerCase()}:$port';
|
||||
|
||||
Map<String, Object> toJson() => {'host': host, 'port': port, 'secret': secret};
|
||||
|
||||
/// Null for anything unreadable and for the hosted entries older
|
||||
/// versions kept (your server has no recents).
|
||||
static RecentServer? fromJson(Object? j) {
|
||||
if (j is! Map || j['hosted'] == true) return null;
|
||||
final host = j['host'], port = j['port'], secret = j['secret'];
|
||||
if (host is! String || port is! String) return null;
|
||||
return RecentServer(host: host, port: port, secret: secret is String ? secret : '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest first, one entry per server, capped at [maxRecentServers].
|
||||
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
|
||||
s,
|
||||
...list.where((r) => r.key != s.key),
|
||||
].take(maxRecentServers).toList();
|
||||
|
||||
String encodeRecents(List<RecentServer> list) => jsonEncode([for (final r in list) r.toJson()]);
|
||||
|
||||
List<RecentServer> decodeRecents(Object? raw) {
|
||||
if (raw is! String) return const [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return const [];
|
||||
return [
|
||||
for (final j in list) ?RecentServer.fromJson(j),
|
||||
].take(maxRecentServers).toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Before recents existed only the last form was stored: a saved remote
|
||||
/// address becomes the first Recent row.
|
||||
List<RecentServer> migrateRecents(
|
||||
{required bool hadForm, required String host, required String port, required String secret}) {
|
||||
final h = host.trim().toLowerCase();
|
||||
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') return const [];
|
||||
return [RecentServer(host: host.trim(), port: port, secret: secret)];
|
||||
}
|
||||
|
||||
/// The connect form and the recent servers, kept in the `connect_form`
|
||||
/// box. Made by the composition root; the Join card, your server (its
|
||||
/// port and password), the profile and the library read and write it.
|
||||
class ConnectStore {
|
||||
final form = ConnectForm();
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
|
||||
Future<void> load() async {
|
||||
final box = await openConnectFormBox();
|
||||
form
|
||||
..host = readString(box, connectKeyHost, '127.0.0.1')
|
||||
..port = readString(box, connectKeyPort, '8080')
|
||||
..secret = readString(box, connectKeySecret, '');
|
||||
// Old installs had one port/password for both modes.
|
||||
form
|
||||
..hostPort = readString(box, connectKeyHostPort, form.port)
|
||||
..hostSecret = readString(box, connectKeyHostSecret, form.secret)
|
||||
..name = readString(box, connectKeyName, '')
|
||||
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
||||
..mediaPath = readString(box, connectKeyMediaPath, '')
|
||||
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
||||
recents.value = box.containsKey(connectKeyRecents)
|
||||
? decodeRecents(box.get(connectKeyRecents))
|
||||
: migrateRecents(
|
||||
hadForm: box.containsKey(connectKeyHost), host: form.host, port: form.port, secret: form.secret);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
await box.putAll({
|
||||
connectKeyHost: form.host,
|
||||
connectKeyPort: form.port,
|
||||
connectKeySecret: form.secret,
|
||||
connectKeyHostPort: form.hostPort,
|
||||
connectKeyHostSecret: form.hostSecret,
|
||||
connectKeyName: form.name,
|
||||
connectKeyAvatarPath: form.avatarPath,
|
||||
connectKeyMediaPath: form.mediaPath,
|
||||
connectKeyLinkUrl: form.linkUrl,
|
||||
connectKeyRecents: encodeRecents(recents.peek()),
|
||||
});
|
||||
}
|
||||
|
||||
/// [save] whatever happens (a failed join is exactly when the user
|
||||
/// wants the form back); a failure is only logged.
|
||||
Future<void> saveQuietly() async {
|
||||
try {
|
||||
await save();
|
||||
} catch (e, st) {
|
||||
debugPrint('save connect form failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remember(RecentServer server) async {
|
||||
recents.value = pushRecent(recents.peek(), server);
|
||||
await saveQuietly();
|
||||
}
|
||||
|
||||
Future<void> forget(RecentServer server) async {
|
||||
recents.value = [
|
||||
for (final r in recents.peek())
|
||||
if (r.key != server.key) r,
|
||||
];
|
||||
await saveQuietly();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import 'package:sync_relay_server/server.dart';
|
||||
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
|
||||
serve(args.$1, events: args.$2);
|
||||
|
||||
/// The hosted server, running [serve] on its own isolate. Its log lines
|
||||
/// This app's own server ("your server"), running [serve] on its own
|
||||
/// isolate for the whole app run. Its log lines
|
||||
/// go to [onLog]; if the isolate dies without being asked to stop,
|
||||
/// [onCrash] gets the cause once.
|
||||
class ServerIsolate {
|
||||
@@ -33,7 +34,7 @@ class ServerIsolate {
|
||||
|
||||
/// Spawns the server and waits until it listens. Throws a
|
||||
/// [StateError] with the server's message if it cannot start.
|
||||
/// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where
|
||||
/// [loopbackOnly]: 127.0.0.1 only until [share]. [logDir]: where
|
||||
/// the server writes its session log (none when null).
|
||||
static Future<ServerIsolate> start(
|
||||
AppConfig config, {
|
||||
@@ -156,6 +157,46 @@ class ServerIsolate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the server to the network ([SyncServer.share]): a listener on
|
||||
/// every interface at [port], the password [secret], the Jellyfin relay
|
||||
/// login [jellyfin]. Returns the bound port; throws a [StateError] with
|
||||
/// the server's reason (a port in use) when it cannot.
|
||||
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin,
|
||||
Duration timeout = const Duration(seconds: 10)}) async {
|
||||
if (_exited.isCompleted) throw StateError('the server is not running');
|
||||
final reply = ReceivePort('server share');
|
||||
try {
|
||||
_stop.send({
|
||||
't': 'share',
|
||||
'port': port,
|
||||
'secret': secret,
|
||||
'jellyfin': ?jellyfin?.toMap(),
|
||||
'reply': reply.sendPort,
|
||||
});
|
||||
final msg = await reply.first.timeout(timeout);
|
||||
if (msg is Map && msg['ok'] == true) return msg['port'] as int;
|
||||
throw StateError(msg is Map ? '${msg['message']}' : 'the server did not answer');
|
||||
} on TimeoutException {
|
||||
throw StateError('the server did not answer');
|
||||
} finally {
|
||||
reply.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the network listener again ([SyncServer.unshare]): everyone
|
||||
/// but this app's own member leaves; nothing else changes.
|
||||
Future<void> unshare({Duration timeout = const Duration(seconds: 5)}) async {
|
||||
if (_exited.isCompleted) return;
|
||||
final reply = ReceivePort('server unshare');
|
||||
try {
|
||||
_stop.send({'t': 'unshare', 'reply': reply.sendPort});
|
||||
await reply.first.timeout(timeout);
|
||||
} catch (_) {
|
||||
} finally {
|
||||
reply.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the server to close its sockets and exit; kills the isolate if it
|
||||
/// has not exited within [timeout].
|
||||
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../platform/desktop/desktop_ports.dart';
|
||||
import '../platform/ports/ports.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity;
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
import 'server_isolate.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
enum AppRoute {
|
||||
home,
|
||||
session,
|
||||
}
|
||||
|
||||
enum ConnectMode { host, join }
|
||||
|
||||
/// Both Home cards prefill from this. [host]/[port]/[secret] belong to
|
||||
/// the Join card, [hostPort]/[hostSecret] to the Host card.
|
||||
class ConnectForm {
|
||||
String host;
|
||||
String port;
|
||||
String secret;
|
||||
String hostPort;
|
||||
String hostSecret;
|
||||
String name;
|
||||
String avatarPath;
|
||||
String mediaPath;
|
||||
String linkUrl;
|
||||
ConnectForm({
|
||||
this.host = '127.0.0.1',
|
||||
this.port = '8080',
|
||||
this.secret = '',
|
||||
this.hostPort = '8080',
|
||||
this.hostSecret = '',
|
||||
this.name = '',
|
||||
this.avatarPath = '',
|
||||
this.mediaPath = '',
|
||||
this.linkUrl = '',
|
||||
});
|
||||
|
||||
ConnectForm copy() => ConnectForm(
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
hostPort: hostPort,
|
||||
hostSecret: hostSecret,
|
||||
name: name,
|
||||
avatarPath: avatarPath,
|
||||
mediaPath: mediaPath,
|
||||
linkUrl: linkUrl,
|
||||
);
|
||||
}
|
||||
|
||||
const connectKeyHostPort = 'hostPort';
|
||||
const connectKeyHostSecret = 'hostSecret';
|
||||
const connectKeyRecents = 'recents';
|
||||
const maxRecentServers = 6;
|
||||
|
||||
/// A server the user got into, shown as a Recent chip on Home. Hosted
|
||||
/// entries refill the Host card; joined ones join in one click.
|
||||
class RecentServer {
|
||||
final bool hosted;
|
||||
final String host;
|
||||
final String port;
|
||||
final String secret;
|
||||
const RecentServer(
|
||||
{required this.hosted,
|
||||
required this.host,
|
||||
required this.port,
|
||||
this.secret = ''});
|
||||
|
||||
String get key => hosted ? 'host:$port' : '${host.toLowerCase()}:$port';
|
||||
|
||||
Map<String, Object> toJson() =>
|
||||
{'hosted': hosted, 'host': host, 'port': port, 'secret': secret};
|
||||
|
||||
static RecentServer? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final host = j['host'], port = j['port'], secret = j['secret'];
|
||||
if (host is! String || port is! String) return null;
|
||||
return RecentServer(
|
||||
hosted: j['hosted'] == true,
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret is String ? secret : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest first, one entry per server, capped at [maxRecentServers].
|
||||
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
|
||||
s,
|
||||
...list.where((r) => r.key != s.key),
|
||||
].take(maxRecentServers).toList();
|
||||
|
||||
String encodeRecents(List<RecentServer> list) =>
|
||||
jsonEncode([for (final r in list) r.toJson()]);
|
||||
|
||||
List<RecentServer> decodeRecents(Object? raw) {
|
||||
if (raw is! String) return const [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return const [];
|
||||
return [
|
||||
for (final j in list) ?RecentServer.fromJson(j),
|
||||
].take(maxRecentServers).toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Before recents existed only the last form was stored: a saved remote
|
||||
/// address becomes the first Recent chip.
|
||||
List<RecentServer> migrateRecents(
|
||||
{required bool hadForm,
|
||||
required String host,
|
||||
required String port,
|
||||
required String secret}) {
|
||||
final h = host.trim().toLowerCase();
|
||||
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') {
|
||||
return const [];
|
||||
}
|
||||
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
||||
}
|
||||
|
||||
/// The hosted server died during setup; shown on the Host card.
|
||||
class ServerCrashed implements Exception {
|
||||
final String cause;
|
||||
const ServerCrashed(this.cause);
|
||||
@override
|
||||
String toString() => 'server crashed: $cause';
|
||||
}
|
||||
|
||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||
/// recognised as the host (`members[].host`).
|
||||
String newHostKey() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||
}
|
||||
|
||||
class SessionState {
|
||||
static final SessionState instance = SessionState._();
|
||||
SessionState._();
|
||||
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||
final SynthSfx sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||
final Ports ports = desktopPorts();
|
||||
/// Every toast, sound, system notification and activity-log line goes
|
||||
/// through it.
|
||||
late final Notifier notifier = Notifier(
|
||||
sfx: sfx,
|
||||
attention: ports.shell.requestAttention,
|
||||
system: ports.notifications,
|
||||
focusWindow: ports.shell.focus,
|
||||
prefs: () => AppSettings.instance.notifyPrefs,
|
||||
);
|
||||
/// The link to the server; lost/back go to the notifier.
|
||||
/// The link to the server, with this install's stored identity
|
||||
/// (`hello.client` / `seq`); lost/back go to the notifier.
|
||||
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
|
||||
late final SessionCoordinator coordinator =
|
||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
|
||||
final lastForm = ConnectForm();
|
||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||
/// Set when the hosted server died mid-session; Home shows it on the
|
||||
/// Host card and clears it.
|
||||
final serverCrash = signal<String?>(null);
|
||||
/// In a solo session: this app's own server on 127.0.0.1, only you in
|
||||
/// it. Hosting from it ([host]) keeps what plays and where.
|
||||
final solo = signal(false);
|
||||
/// The joined server's own name (`/health`), '' until known or when it
|
||||
/// only has the default name.
|
||||
final serverLabel = signal('');
|
||||
/// The server's Starsync version: a hosted (or solo) server is this
|
||||
/// app's own; a joined one's comes from its `/health`; '' until known.
|
||||
final serverVersion = signal('');
|
||||
/// A solo session is becoming a hosted one: the player has nothing open
|
||||
/// until the media is up again on the new server.
|
||||
final switching = signal(false);
|
||||
ConnectParams? _soloParams;
|
||||
|
||||
/// The app's yt-dlp folder for the hosted server; '' (the server's own
|
||||
/// default) when the app-support folder is not available.
|
||||
static Future<String> _sharedYtDlpDir() async {
|
||||
try {
|
||||
return (await instance.ports.links.ytDlp).dir;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
ServerIsolate? _server;
|
||||
String? _setupCrash;
|
||||
|
||||
@visibleForTesting
|
||||
ServerIsolate? get hostedServer => _server;
|
||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||
// next step and unwinds instead of connecting.
|
||||
int _op = 0;
|
||||
|
||||
Future<void> loadForm() async {
|
||||
final box = await openConnectFormBox();
|
||||
lastForm
|
||||
..host = readString(box, connectKeyHost, '127.0.0.1')
|
||||
..port = readString(box, connectKeyPort, '8080')
|
||||
..secret = readString(box, connectKeySecret, '');
|
||||
// Old installs had one port/password for both modes.
|
||||
lastForm
|
||||
..hostPort = readString(box, connectKeyHostPort, lastForm.port)
|
||||
..hostSecret = readString(box, connectKeyHostSecret, lastForm.secret)
|
||||
..name = readString(box, connectKeyName, '')
|
||||
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
||||
..mediaPath = readString(box, connectKeyMediaPath, '')
|
||||
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
||||
recents.value = box.containsKey(connectKeyRecents)
|
||||
? decodeRecents(box.get(connectKeyRecents))
|
||||
: migrateRecents(
|
||||
hadForm: box.containsKey(connectKeyHost),
|
||||
host: lastForm.host,
|
||||
port: lastForm.port,
|
||||
secret: lastForm.secret);
|
||||
}
|
||||
|
||||
Future<void> saveForm() async {
|
||||
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
|
||||
await box.putAll({
|
||||
connectKeyHost: lastForm.host,
|
||||
connectKeyPort: lastForm.port,
|
||||
connectKeySecret: lastForm.secret,
|
||||
connectKeyHostPort: lastForm.hostPort,
|
||||
connectKeyHostSecret: lastForm.hostSecret,
|
||||
connectKeyName: lastForm.name,
|
||||
connectKeyAvatarPath: lastForm.avatarPath,
|
||||
connectKeyMediaPath: lastForm.mediaPath,
|
||||
connectKeyLinkUrl: lastForm.linkUrl,
|
||||
connectKeyRecents: encodeRecents(recents.peek()),
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> loadAvatar(String path) => encodeAvatar(path);
|
||||
|
||||
/// Called by Home after a host/join got through.
|
||||
Future<void> rememberServer(RecentServer server) async {
|
||||
recents.value = pushRecent(recents.peek(), server);
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
|
||||
Future<void> forgetServer(RecentServer server) async {
|
||||
recents.value = [
|
||||
for (final r in recents.peek())
|
||||
if (r.key != server.key) r,
|
||||
];
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
|
||||
/// Hosts a server friends can join. From a solo session it keeps the
|
||||
/// media and position: the solo server goes, the new one gets the same
|
||||
/// pick, seeked to where it was; if hosting fails, solo comes back.
|
||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
||||
if (!solo.peek()) return _host(params, form: form);
|
||||
final carry = _invited = _carry();
|
||||
switching.value = true;
|
||||
try {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
try {
|
||||
await _host(params, form: form);
|
||||
} catch (_) {
|
||||
// Not hosted (failed or cancelled): back to solo, same media.
|
||||
try {
|
||||
await _host(_soloParams ?? params, soloSession: true);
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
debugPrint('back to solo failed: $e');
|
||||
await leave();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
||||
}
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Invite from solo carried over, for [cancelInvite].
|
||||
({PickedMedia pick, double position})? _invited;
|
||||
|
||||
/// Invite cancelled after the hosted server already came up: back to
|
||||
/// solo with the same media, not out of the session.
|
||||
Future<void> cancelInvite() async {
|
||||
final carry = _invited;
|
||||
switching.value = true;
|
||||
try {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
await _host(_soloParams!, soloSession: true);
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
debugPrint('back to solo failed: $e');
|
||||
await leave();
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
|
||||
/// password, nothing put up yet. Nobody else can reach it.
|
||||
Future<void> startSolo(ConnectParams params) async {
|
||||
if (route.peek() == AppRoute.session) return;
|
||||
_soloParams = params;
|
||||
await _host(params, soloSession: true);
|
||||
}
|
||||
|
||||
({PickedMedia pick, double position})? _carry() {
|
||||
final pick = coordinator.lastPick;
|
||||
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
|
||||
final pos = coordinator.playback.positionSecs;
|
||||
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
|
||||
}
|
||||
|
||||
/// Puts [carry] up on the server just joined and seeks to its position
|
||||
/// once the swap shows in a state.
|
||||
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
|
||||
final before = coordinator.serverState.peek()?.mediaId ?? 0;
|
||||
final error = await coordinator.swapPicked(carry.pick);
|
||||
if (error != null) throw StateError(error);
|
||||
if (carry.position < 1) return;
|
||||
final landed = Completer<void>();
|
||||
final stop = effect(() {
|
||||
final r = coordinator.serverState.value;
|
||||
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
|
||||
});
|
||||
try {
|
||||
await landed.future.timeout(const Duration(seconds: 20));
|
||||
coordinator.connection.sendSeek(carry.position);
|
||||
} on TimeoutException {
|
||||
debugPrint('resume: the media did not come up; not seeking');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
|
||||
final op = ++_op;
|
||||
if (form != null) _rememberForm(form);
|
||||
try {
|
||||
await stopServer();
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
final hostKey = newHostKey();
|
||||
_setupCrash = null;
|
||||
// A pasted Jellyfin link goes up as a ref like a browser pick.
|
||||
final media = params.media ??
|
||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
// Guests without an account watch through the server, with the
|
||||
// host's own sign-in (the hosted item's server, else the default).
|
||||
await jellyfin.ensureLoaded(validate: false);
|
||||
final relay = relayCredentialFor(jellyfin, serverId: media?.jellyfin?.serverId);
|
||||
late final ServerIsolate server;
|
||||
try {
|
||||
server = await ServerIsolate.start(
|
||||
AppConfig(
|
||||
port: soloSession ? 0 : params.port,
|
||||
secret: soloSession ? '' : params.secret,
|
||||
name: 'server',
|
||||
mediaPath: params.mediaPath,
|
||||
hostKey: hostKey,
|
||||
jellyfin: relay,
|
||||
ytDlpDir: await _sharedYtDlpDir(),
|
||||
),
|
||||
loopbackOnly: soloSession,
|
||||
// Its session log (every member's shared diagnostics and its own
|
||||
// records) goes next to the app's; not for a solo session.
|
||||
logDir: soloSession ? null : appLogDir,
|
||||
// Its records join the app's log as `server.<logger>`.
|
||||
onLog: (line) => relogLine('server', line),
|
||||
onCrash: (cause) => _onServerCrash(server, cause),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
rethrow;
|
||||
}
|
||||
if (op != _op) {
|
||||
await server.stop();
|
||||
throw const ConnectCancelled();
|
||||
}
|
||||
_server = server;
|
||||
coordinator.hostFile = (path) async =>
|
||||
identical(_server, server) ? server.setFile(path) : null;
|
||||
serverVersion.value = appVersion;
|
||||
try {
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
secret: soloSession ? '' : params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
hostKey: hostKey,
|
||||
media: media,
|
||||
));
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
} catch (e) {
|
||||
// Own join failed or was cancelled: don't leave the port open.
|
||||
if (identical(_server, server)) await stopServer();
|
||||
final crash = _setupCrash;
|
||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||
rethrow;
|
||||
}
|
||||
solo.value = soloSession;
|
||||
coordinator.solo = soloSession;
|
||||
route.value = AppRoute.session;
|
||||
} finally {
|
||||
if (form != null) await _saveFormQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
/// The server isolate died without being stopped. In a session: show it
|
||||
/// and leave. During setup the own join fails and [host] reports it.
|
||||
void _onServerCrash(ServerIsolate server, String cause) {
|
||||
if (!identical(_server, server)) return;
|
||||
final message = 'server crashed: $cause';
|
||||
debugPrint('[server] $message');
|
||||
if (route.peek() != AppRoute.session) {
|
||||
_setupCrash = cause;
|
||||
return;
|
||||
}
|
||||
serverCrash.value = message;
|
||||
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
|
||||
}
|
||||
|
||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
try {
|
||||
await coordinator.connect(params);
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
route.value = AppRoute.session;
|
||||
serverLabel.value = '';
|
||||
serverVersion.value = '';
|
||||
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
|
||||
if (op != _op || !h.online) return;
|
||||
// 'server' is the dedicated server's default --name: not a name.
|
||||
if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
|
||||
serverVersion.value = h.version;
|
||||
}).catchError((Object _) {}));
|
||||
} finally {
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel button during setup: abort whichever step is running (server
|
||||
/// start, player init, socket dial) without connecting.
|
||||
Future<void> cancelPending() async {
|
||||
_op++;
|
||||
try {
|
||||
await coordinator.cancelConnect();
|
||||
} catch (_) {}
|
||||
// Solo becoming hosted: [host] stops what it started and goes back
|
||||
// to solo itself.
|
||||
if (!switching.peek()) await stopServer();
|
||||
}
|
||||
|
||||
/// Saved whatever the outcome: a failed join is exactly when the user
|
||||
/// wants the form back.
|
||||
Future<void> _saveFormQuietly() async {
|
||||
try {
|
||||
await saveForm();
|
||||
} catch (e, st) {
|
||||
debugPrint('save connect form failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
void _rememberForm(ConnectForm form) {
|
||||
lastForm
|
||||
..host = form.host
|
||||
..port = form.port
|
||||
..secret = form.secret
|
||||
..hostPort = form.hostPort
|
||||
..hostSecret = form.hostSecret
|
||||
..name = form.name
|
||||
..avatarPath = form.avatarPath
|
||||
..mediaPath = form.mediaPath
|
||||
..linkUrl = form.linkUrl;
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
coordinator.hostFile = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
}
|
||||
|
||||
/// Out of the session (Leave, the app closing). [bye] false: Update
|
||||
/// and restart, the restarted app takes the member back within its grace.
|
||||
Future<void> leave({bool bye = true}) async {
|
||||
await coordinator.leave(bye: bye);
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
serverLabel.value = '';
|
||||
serverVersion.value = '';
|
||||
route.value = AppRoute.home;
|
||||
}
|
||||
}
|
||||
|
||||
/// The sound set [kind] plays from: its own pick in Settings, else the
|
||||
/// overall one.
|
||||
SoundPreset soundPresetFor(SfxKind kind) {
|
||||
final s = AppSettings.instance;
|
||||
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
|
||||
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
|
||||
}
|
||||
Reference in new issue
Block a user