Files
sync_replay_project/apps/desktop/lib/src/state/session_state.dart
T

518 lines
17 KiB
Dart

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 '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
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 []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService 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,
);
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
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('');
/// 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 AppYtDlp.instance.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.showToast("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.sync.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 {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
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,
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
);
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
status.value = HealthStatus(
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
rethrow;
}
if (op != _op) {
await server.stop();
throw const ConnectCancelled();
}
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
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;
}
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
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 = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
// 'server' is the dedicated server's default --name: not a name.
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
}).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();
}
Future<void> leave() async {
await coordinator.leave();
await stopServer();
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle');
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;
}