Initial commit: watch-together phases 0-4 + presence hardening, internet-tested

This commit is contained in:
foxyz committed 2026-09-12 00:13:43 +03:00
commit 427d851a5b
101 files changed
+11326

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
import 'dart:async';
import 'dart:convert';
import 'package:sync_relay_core/core.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, kicked, closed, error }
class SyncClient {
WebSocketChannel? _channel;
StreamSubscription? _sub;
final StreamController<RoomBroadcast> _rooms = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<String> _kicked = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<RoomBroadcast> get rooms => _rooms.stream;
Stream<FeedMessage> get feeds => _feeds.stream;
Stream<String> get kicked => _kicked.stream;
Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
String token = '';
String memberId = '';
String? mediaUrl;
RoomBroadcast? lastRoom;
Completer<WelcomeMessage>? _pendingWelcome;
Future<WelcomeMessage> connect({
required Uri wsUri,
required String secret,
required String name,
String avatar = '',
HelloMedia? media,
}) async {
await disconnect();
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
_channel = ch;
final welcomeCompleter = Completer<WelcomeMessage>();
_sub = ch.stream.listen(
(raw) => _onText('$raw', welcomeCompleter),
onError: (Object e, StackTrace st) {
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(e, st);
}
_status.add(SyncStatus.error);
},
onDone: () {
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(StateError('closed before welcome'));
}
_status.add(SyncStatus.closed);
},
cancelOnError: false,
);
final hello = HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
);
ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter;
try {
final welcome = await welcomeCompleter.future.timeout(
const Duration(seconds: 10),
);
_pendingWelcome = null;
token = welcome.token;
memberId = welcome.memberId;
mediaUrl = welcome.mediaUrl;
if (welcome.state != null) {
try {
lastRoom = RoomBroadcast.fromJson(welcome.state!);
_rooms.add(lastRoom!);
} catch (_) {}
}
_status.add(SyncStatus.open);
return welcome;
} catch (e) {
_pendingWelcome = null;
await disconnect();
_status.add(SyncStatus.error);
rethrow;
}
}
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(raw);
if (parsed is! Map) return;
decoded = Map<String, Object?>.from(parsed);
} catch (_) {
return;
}
final t = decoded['t'];
if (!welcomeCompleter.isCompleted) {
if (t == 'welcome') {
try {
welcomeCompleter.complete(WelcomeMessage.fromJson(decoded));
} catch (e) {
welcomeCompleter.completeError(e);
}
} else if (t == 'err') {
welcomeCompleter.completeError(
ProtocolException('${decoded['message'] ?? 'rejected'}'),
);
}
return;
}
switch (t) {
case 'room':
try {
final room = RoomBroadcast.fromJson(decoded);
lastRoom = room;
_rooms.add(room);
} catch (_) {}
case 'feed':
try {
_feeds.add(FeedMessage.fromJson(decoded));
} catch (_) {}
case 'kicked':
try {
final msg = KickedMessage.fromJson(decoded);
_kicked.add(msg.reason);
_status.add(SyncStatus.kicked);
} catch (_) {
_kicked.add('');
}
case 'err':
_errors.add('${decoded['message'] ?? 'error'}');
case 'chat':
try {
_feeds.add(
FeedMessage(
actor: '${decoded['from'] ?? '?'}',
action: 'chat',
reason: (decoded['text'] as String?) ?? '',
at: DateTime.now().toUtc(),
),
);
} catch (_) {}
}
}
void _send(Map<String, Object?> json) {
final ch = _channel;
if (ch == null) return;
try {
ch.sink.add(jsonEncode(json));
} catch (_) {}
}
void sendReady(bool ready, {String source = 'manual'}) {
_send(ReadyIntent(ready: ready, source: source).toJson());
}
void sendForce(String target, bool ready) {
_send(ForceIntent(target: target, ready: ready).toJson());
}
void sendSeek(double position) {
if (position.isNaN || position < 0) return;
_send(SeekIntent(position: position).toJson());
}
void sendSwap(HelloMedia media) {
_send(SwapIntent(media: media).toJson());
}
void sendReport({
required double position,
required double buffered,
required int eventId,
}) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send(
ReportMessage(
position: position,
buffered: safeBuffered,
eventId: eventId,
).toJson(),
);
}
void sendChat(String text) {
final clean = text.length > RelayConstants.maxChatLength
? text.substring(0, RelayConstants.maxChatLength)
: text;
_send(ChatMessage(text: clean).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
}
Future<void> disconnect() async {
final pending = _pendingWelcome;
_pendingWelcome = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(StateError('cancelled'));
} try {
await _sub?.cancel();
} catch (_) {}
_sub = null;
try {
await _channel?.sink.close();
} catch (_) {}
_channel = null;
token = '';
memberId = '';
mediaUrl = null;
lastRoom = null;
}
Future<void> dispose() async {
await disconnect();
await _rooms.close();
await _feeds.close();
await _kicked.close();
await _errors.close();
await _status.close();
}
}