Desktop: video freeze recovery, session fixes, Home redesign
- Player + VideoController per session; Reload video (Ctrl+R) and a frozen-picture watchdog that reloads automatically. - Shortcuts no longer fire while typing; empty swap disabled; hwdec setting/env actually applied. - Idle unready measured from real inactivity; cancellable reconnects and setup; ready restored from server state; kicked/failed host stops its server; graceful leave on window close; no URLs with keys in display names. - Welcome + connect screens replaced by one Home screen with Join and Host cards, recent servers, Settings, Integrations and About. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beae87b0cc
commit
aeff087c7d
30 files changed
+3278
-601
No files matched your search
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -9,14 +12,21 @@ import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
|
||||
enum AppRoute { welcome, connect, session }
|
||||
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;
|
||||
@@ -25,36 +35,137 @@ class 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)];
|
||||
}
|
||||
|
||||
class SessionState {
|
||||
static final SessionState instance = SessionState._();
|
||||
SessionState._();
|
||||
|
||||
final route = signal(AppRoute.welcome);
|
||||
final connectMode = signal(ConnectMode.host);
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final room = signal<RoomBroadcast?>(null);
|
||||
final SfxService sfx = const NoopSfxService();
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
final lastForm = ConnectForm();
|
||||
RoomServer? _server;
|
||||
void Function()? _stopKickWatch;
|
||||
// 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, '')
|
||||
..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 {
|
||||
@@ -63,61 +174,117 @@ class SessionState {
|
||||
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);
|
||||
|
||||
void goConnect(ConnectMode mode) {
|
||||
connectMode.value = mode;
|
||||
route.value = AppRoute.connect;
|
||||
/// 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();
|
||||
}
|
||||
|
||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
status.value = const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
await stopServer();
|
||||
final server = RoomServer(AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'room',
|
||||
mediaPath: params.mediaPath,
|
||||
));
|
||||
try {
|
||||
await server.start();
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||
rethrow;
|
||||
}
|
||||
_server = server;
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
linkUrl: params.linkUrl,
|
||||
));
|
||||
route.value = AppRoute.session;
|
||||
try {
|
||||
await saveForm();
|
||||
} catch (e, st) {
|
||||
debugPrint('save connect form failed: $e\n$st');
|
||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
await stopServer();
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
final server = RoomServer(AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'room',
|
||||
mediaPath: params.mediaPath,
|
||||
));
|
||||
try {
|
||||
await server.start();
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
status.value = HealthStatus(
|
||||
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||
rethrow;
|
||||
}
|
||||
if (op != _op) {
|
||||
await server.stop();
|
||||
throw const ConnectCancelled();
|
||||
}
|
||||
_server = server;
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
||||
try {
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
linkUrl: params.linkUrl,
|
||||
));
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
} catch (_) {
|
||||
// Own join failed or was cancelled: don't leave the port open.
|
||||
if (identical(_server, server)) await stopServer();
|
||||
rethrow;
|
||||
}
|
||||
_watchKick();
|
||||
route.value = AppRoute.session;
|
||||
} finally {
|
||||
await _saveFormQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
await coordinator.connect(params);
|
||||
route.value = AppRoute.session;
|
||||
try {
|
||||
await coordinator.connect(params);
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
route.value = AppRoute.session;
|
||||
} 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 (_) {}
|
||||
await stopServer();
|
||||
}
|
||||
|
||||
/// A kicked host must not keep serving.
|
||||
void _watchKick() {
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = effect(() {
|
||||
// Microtask: stopServer disposes this effect, not from inside it.
|
||||
if (coordinator.kickReason.value != null) scheduleMicrotask(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) {
|
||||
@@ -130,6 +297,8 @@ class SessionState {
|
||||
..host = form.host
|
||||
..port = form.port
|
||||
..secret = form.secret
|
||||
..hostPort = form.hostPort
|
||||
..hostSecret = form.hostSecret
|
||||
..name = form.name
|
||||
..avatarPath = form.avatarPath
|
||||
..mediaPath = form.mediaPath
|
||||
@@ -141,19 +310,22 @@ class SessionState {
|
||||
for (var i = 0; i < args.length - 1; i++) {
|
||||
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
||||
}
|
||||
_server = RoomServer(AppConfig(port: port, secret: 'change-me', name: 'room'));
|
||||
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
|
||||
await _server!.start();
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
await _server?.stop();
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||
route.value = AppRoute.connect;
|
||||
route.value = AppRoute.home;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user