feat: replace webrtc with ws relay

This commit is contained in:
edde746
2026-02-04 07:49:35 +01:00
parent 873936c28d
commit f67ca187a8
16 changed files with 826 additions and 454 deletions
+6 -6
View File
@@ -9,8 +9,6 @@ PODS:
- flutter_webrtc (1.2.0):
- Flutter
- WebRTC-SDK (= 137.7151.04)
- gamepads_ios (0.1.1):
- Flutter
- in_app_review (2.0.0):
- Flutter
- os_media_controls (0.0.1):
@@ -51,6 +49,8 @@ PODS:
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- universal_gamepad (0.1.0):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
@@ -65,7 +65,6 @@ DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -73,6 +72,7 @@ DEPENDENCIES:
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
@@ -93,8 +93,6 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gamepads_ios:
:path: ".symlinks/plugins/gamepads_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
os_media_controls:
@@ -109,6 +107,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
universal_gamepad:
:path: ".symlinks/plugins/universal_gamepad/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
@@ -122,7 +122,6 @@ SPEC CHECKSUMS:
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
@@ -131,6 +130,7 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
universal_gamepad: e10172778a8a399cce234494968f38724974919e
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
@@ -302,6 +302,17 @@ class WatchTogetherProvider with ChangeNotifier {
);
}
// If we're the host, send our join info back so the new peer
// adds us to their participant list. This is done at provider
// level (in addition to sync manager) so it works even when
// no player is attached yet.
if (isHost && _peerService != null) {
_peerService!.sendTo(
message.peerId!,
SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true),
);
}
notifyListeners();
}
break;
@@ -1,7 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:peerdart/peerdart.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../utils/app_logger.dart';
import '../models/sync_message.dart';
@@ -21,16 +22,19 @@ class PeerError {
String toString() => 'PeerError($type): $message';
}
/// Service for managing WebRTC peer connections using PeerJS
/// Service for managing Watch Together connections via a WebSocket relay
///
/// This service handles:
/// - Creating sessions (as host)
/// - Joining sessions (as guest)
/// - Sending/receiving sync messages over data channels
/// - Managing multiple peer connections
/// - Sending/receiving sync messages through the relay server
/// - Reconnection on WebSocket drops
class WatchTogetherPeerService {
Peer? _peer;
final Map<String, DataConnection> _connections = {};
static const String _relayUrl = 'wss://ice.plezy.app/relay';
WebSocketChannel? _channel;
StreamSubscription? _channelSubscription;
final Set<String> _connectedPeers = {};
String? _sessionId;
String? _myPeerId;
bool _isHost = false;
@@ -42,18 +46,16 @@ class WatchTogetherPeerService {
final _errorController = StreamController<PeerError>.broadcast();
final _connectionStateController = StreamController<bool>.broadcast();
// Reconnection state (signaling server)
// Reconnection state
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 3;
Timer? _reconnectTimer;
// Peer health monitoring (data channel)
final Map<String, DateTime> _lastPeerActivity = {};
final Map<String, int> _peerReconnectAttempts = {};
Timer? _peerHealthCheckTimer;
static const Duration _peerTimeout = Duration(seconds: 30);
static const Duration _peerHealthCheckInterval = Duration(seconds: 10);
static const int _maxPeerReconnectAttempts = 3;
// Keepalive
Timer? _pingTimer;
Timer? _pongTimer;
static const Duration _pingInterval = Duration(seconds: 15);
static const Duration _pongTimeout = Duration(seconds: 30);
/// Stream of peer IDs when a new peer connects
Stream<String> get onPeerConnected => _peerConnectedController.stream;
@@ -80,334 +82,322 @@ class WatchTogetherPeerService {
bool get isHost => _isHost;
/// Whether currently connected to a session
bool get isConnected => _peer != null && _connections.isNotEmpty;
bool get isConnected => _channel != null && _connectedPeers.isNotEmpty;
/// List of connected peer IDs
List<String> get connectedPeers => _connections.keys.toList();
List<String> get connectedPeers => _connectedPeers.toList();
/// Generate a short, readable session ID
String _generateSessionId() {
// Use first 8 characters of UUID for readability
return const Uuid().v4().substring(0, 8).toUpperCase();
}
/// Attach common peer event listeners for disconnected/close/error events
void _attachCommonPeerListeners({
required Completer completer,
required PeerErrorType errorType,
required String errorMessage,
}) {
_peer!.on('disconnected').listen((_) {
appLogger.w('WatchTogether: Peer disconnected from server');
_handleDisconnectedFromServer();
});
/// Connect to the relay WebSocket and set up the message listener.
/// Returns a completer that completes when the expected response arrives.
Future<WebSocketChannel> _connectToRelay() async {
final uri = Uri.parse(_relayUrl);
final channel = WebSocketChannel.connect(uri);
_peer!.on('close').listen((_) {
appLogger.d('WatchTogether: Peer closed');
_connectionStateController.add(false);
});
// Wait for the connection to be established
await channel.ready;
_peer!.on('error').listen((error) {
appLogger.e('WatchTogether: Peer error', error: error);
_errorController.add(PeerError(type: errorType, message: '$errorMessage: $error', originalError: error));
if (!completer.isCompleted) {
completer.completeError(error);
return channel;
}
/// Listen on the channel stream and route incoming server messages.
void _listenToChannel(WebSocketChannel channel, {Completer<void>? setupCompleter}) {
_channelSubscription?.cancel();
_channelSubscription = channel.stream.listen(
(data) {
_resetPongTimer();
_handleServerMessage(data as String, setupCompleter: setupCompleter);
},
onError: (error) {
appLogger.e('WatchTogether: WebSocket error', error: error);
_errorController.add(
PeerError(type: PeerErrorType.serverError, message: 'WebSocket error: $error', originalError: error),
);
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.completeError(error);
}
_handleWebSocketClosed();
},
onDone: () {
appLogger.w('WatchTogether: WebSocket closed');
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.completeError(
const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'),
);
}
_handleWebSocketClosed();
},
);
}
/// Handle an incoming server message (JSON string).
void _handleServerMessage(String raw, {Completer<void>? setupCompleter}) {
try {
final msg = jsonDecode(raw) as Map<String, dynamic>;
final type = msg['type'] as String?;
switch (type) {
case 'created':
appLogger.d('WatchTogether: Room created: ${msg['sessionId']}');
_connectionStateController.add(true);
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.complete();
}
case 'joined':
final peers = (msg['peers'] as List<dynamic>?)?.cast<String>() ?? [];
appLogger.d('WatchTogether: Joined room ${msg['sessionId']} with peers: $peers');
for (final peerId in peers) {
_connectedPeers.add(peerId);
_peerConnectedController.add(peerId);
}
_connectionStateController.add(true);
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.complete();
}
case 'peerJoined':
final peerId = msg['peerId'] as String;
appLogger.d('WatchTogether: Peer joined: $peerId');
_connectedPeers.add(peerId);
_peerConnectedController.add(peerId);
_connectionStateController.add(true);
case 'peerLeft':
final peerId = msg['peerId'] as String;
appLogger.d('WatchTogether: Peer left: $peerId');
_connectedPeers.remove(peerId);
_peerDisconnectedController.add(peerId);
if (_connectedPeers.isEmpty) {
_connectionStateController.add(false);
}
case 'message':
final from = msg['from'] as String?;
final payload = msg['payload'];
if (payload != null) {
try {
final payloadStr = payload is String ? payload : jsonEncode(payload);
final syncMsg = SyncMessage.fromJson(payloadStr);
appLogger.d('WatchTogether: Received ${syncMsg.type} from $from');
_messageReceivedController.add(syncMsg);
} catch (e) {
appLogger.e('WatchTogether: Failed to parse sync message payload', error: e);
}
}
case 'error':
final code = msg['code'] as String? ?? 'unknown';
final message = msg['message'] as String? ?? 'Unknown error';
appLogger.e('WatchTogether: Server error: $code - $message');
final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message');
_errorController.add(error);
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.completeError(error);
}
case 'pong':
// Handled by _resetPongTimer already
break;
default:
appLogger.w('WatchTogether: Unknown server message type: $type');
}
} catch (e) {
appLogger.e('WatchTogether: Failed to parse server message', error: e);
}
}
/// Start the keepalive ping timer.
void _startPingTimer() {
_pingTimer?.cancel();
_pingTimer = Timer.periodic(_pingInterval, (_) {
_sendRaw({'type': 'ping'});
});
_resetPongTimer();
}
/// Reset the pong timeout timer (called on every incoming message).
void _resetPongTimer() {
_pongTimer?.cancel();
_pongTimer = Timer(_pongTimeout, () {
appLogger.w('WatchTogether: Pong timeout — closing WebSocket');
_channel?.sink.close();
});
}
/// Stop keepalive timers.
void _stopTimers() {
_pingTimer?.cancel();
_pingTimer = null;
_pongTimer?.cancel();
_pongTimer = null;
}
/// Send a raw JSON map to the relay.
void _sendRaw(Map<String, dynamic> msg) {
try {
_channel?.sink.add(jsonEncode(msg));
} catch (e) {
appLogger.e('WatchTogether: Failed to send message', error: e);
}
}
/// Handle the WebSocket being closed unexpectedly — attempt reconnection.
void _handleWebSocketClosed() {
_stopTimers();
_channelSubscription?.cancel();
_channelSubscription = null;
_channel = null;
// Notify peers lost
for (final peerId in _connectedPeers.toList()) {
_peerDisconnectedController.add(peerId);
}
_connectedPeers.clear();
_connectionStateController.add(false);
// Attempt to reconnect if we had a session
if (_sessionId != null) {
_attemptReconnect();
}
}
/// Attempt to reconnect to the relay and re-join/re-create the room.
void _attemptReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
appLogger.e('WatchTogether: Max reconnect attempts reached');
_errorController.add(
const PeerError(
type: PeerErrorType.connectionFailed,
message: 'Lost connection to relay after multiple reconnect attempts',
),
);
return;
}
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2);
appLogger.d('WatchTogether: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () async {
try {
final channel = await _connectToRelay();
_channel = channel;
_reconnectAttempts = 0;
final completer = Completer<void>();
_listenToChannel(channel, setupCompleter: completer);
_startPingTimer();
// Re-send create or join
if (_isHost) {
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId});
} else {
_sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId});
}
await completer.future.timeout(const Duration(seconds: 10));
appLogger.d('WatchTogether: Reconnected successfully');
} catch (e) {
appLogger.e('WatchTogether: Reconnect failed', error: e);
_handleWebSocketClosed();
}
});
}
/// Create a new session as host
///
/// Returns the session ID that others can use to join
/// Returns the session ID that others can use to join.
Future<String> createSession() async {
if (_peer != null) {
if (_channel != null) {
await disconnect();
}
_isHost = true;
_sessionId = _generateSessionId();
_myPeerId = 'wt-$_sessionId';
_reconnectAttempts = 0;
// Create peer with session ID as the peer ID so guests can connect directly
final completer = Completer<String>();
try {
_peer = Peer(id: 'wt-$_sessionId');
final channel = await _connectToRelay();
_channel = channel;
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('WatchTogether: Host peer opened with ID: $_myPeerId');
_connectionStateController.add(true);
if (!completer.isCompleted) {
completer.complete(_sessionId);
}
});
final completer = Completer<void>();
_listenToChannel(channel, setupCompleter: completer);
_startPingTimer();
_peer!.on('connection').listen((conn) {
final dataConn = conn as DataConnection;
_handleNewConnection(dataConn);
});
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId});
_attachCommonPeerListeners(
completer: completer,
errorType: PeerErrorType.serverError,
errorMessage: 'Server error',
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
},
);
} catch (e) {
appLogger.e('WatchTogether: Failed to create peer', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
}
// Timeout after 10 seconds
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
},
);
appLogger.d('WatchTogether: Session created: $_sessionId');
return _sessionId!;
} catch (e) {
appLogger.e('WatchTogether: Failed to create session', error: e);
await disconnect();
rethrow;
}
}
/// Join an existing session as guest
/// Join an existing session as guest.
Future<void> joinSession(String sessionId) async {
if (_peer != null) {
if (_channel != null) {
await disconnect();
}
_isHost = false;
_sessionId = sessionId.toUpperCase();
_myPeerId = const Uuid().v4();
_reconnectAttempts = 0;
final completer = Completer<void>();
try {
// Create a random peer ID for guest
_peer = Peer();
final channel = await _connectToRelay();
_channel = channel;
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('WatchTogether: Guest peer opened with ID: $_myPeerId');
final completer = Completer<void>();
_listenToChannel(channel, setupCompleter: completer);
_startPingTimer();
// Connect to the host
final hostPeerId = 'wt-$_sessionId';
appLogger.d('WatchTogether: Connecting to host: $hostPeerId');
_sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId});
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
_handleNewConnection(conn, isOutgoing: true, completer: completer);
});
_attachCommonPeerListeners(
completer: completer,
errorType: PeerErrorType.connectionFailed,
errorMessage: 'Failed to connect to session',
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session');
},
);
appLogger.d('WatchTogether: Joined session: $_sessionId');
} catch (e) {
appLogger.e('WatchTogether: Failed to create peer for joining', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
appLogger.e('WatchTogether: Failed to join session', error: e);
await disconnect();
rethrow;
}
// Timeout after 15 seconds
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session');
},
);
}
/// Handle a new data connection (incoming or outgoing)
void _handleNewConnection(DataConnection conn, {bool isOutgoing = false, Completer<void>? completer}) {
final peerId = conn.peer;
appLogger.d('WatchTogether: New connection ${isOutgoing ? "to" : "from"}: $peerId');
conn.on('open').listen((_) {
appLogger.d('WatchTogether: Data channel opened with: $peerId');
_connections[peerId] = conn;
_updatePeerActivity(peerId); // Track initial activity
_peerConnectedController.add(peerId);
_connectionStateController.add(true);
// Start health monitoring if not already running
if (_peerHealthCheckTimer == null) {
_startPeerHealthCheck();
}
if (completer != null && !completer.isCompleted) {
completer.complete();
}
});
conn.on('data').listen((data) {
try {
_updatePeerActivity(peerId); // Track activity on each message
final message = SyncMessage.fromJson(data as String);
appLogger.d('WatchTogether: Received message: ${message.type} from $peerId');
_messageReceivedController.add(message);
} catch (e) {
appLogger.e('WatchTogether: Failed to parse message', error: e);
}
});
conn.on('close').listen((_) {
appLogger.d('WatchTogether: Connection closed with: $peerId');
_connections.remove(peerId);
_lastPeerActivity.remove(peerId);
_peerReconnectAttempts.remove(peerId);
_peerDisconnectedController.add(peerId);
if (_connections.isEmpty) {
_stopPeerHealthCheck();
_connectionStateController.add(false);
}
});
conn.on('error').listen((error) {
appLogger.e('WatchTogether: Connection error with $peerId', error: error);
_errorController.add(
PeerError(
type: PeerErrorType.dataChannelError,
message: 'Connection error with peer: $error',
originalError: error,
),
);
// Attempt reconnection on data channel error
_attemptPeerReconnect(peerId);
});
}
/// Handle disconnection from PeerJS server
void _handleDisconnectedFromServer() {
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2); // Exponential backoff
appLogger.d(
'WatchTogether: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
_peer?.reconnect();
});
} else {
appLogger.e('WatchTogether: Max reconnect attempts reached');
_errorController.add(
const PeerError(
type: PeerErrorType.connectionFailed,
message: 'Lost connection to server after multiple reconnect attempts',
),
);
}
}
/// Start peer health monitoring
void _startPeerHealthCheck() {
_peerHealthCheckTimer?.cancel();
_peerHealthCheckTimer = Timer.periodic(_peerHealthCheckInterval, (_) {
_checkPeerHealth();
});
}
/// Stop peer health monitoring
void _stopPeerHealthCheck() {
_peerHealthCheckTimer?.cancel();
_peerHealthCheckTimer = null;
}
/// Check health of all peer connections
void _checkPeerHealth() {
final now = DateTime.now();
final peersToReconnect = <String>[];
for (final peerId in _connections.keys.toList()) {
final lastActivity = _lastPeerActivity[peerId];
if (lastActivity != null && now.difference(lastActivity) > _peerTimeout) {
appLogger.w('WatchTogether: Peer $peerId timed out (no activity for ${_peerTimeout.inSeconds}s)');
peersToReconnect.add(peerId);
}
}
for (final peerId in peersToReconnect) {
_attemptPeerReconnect(peerId);
}
}
/// Update peer activity timestamp (called on each message received)
void _updatePeerActivity(String peerId) {
_lastPeerActivity[peerId] = DateTime.now();
// Reset reconnect attempts on successful activity
_peerReconnectAttempts[peerId] = 0;
}
/// Attempt to reconnect to a peer with exponential backoff
void _attemptPeerReconnect(String peerId) {
final attempts = _peerReconnectAttempts[peerId] ?? 0;
if (attempts >= _maxPeerReconnectAttempts) {
appLogger.e('WatchTogether: Max reconnect attempts reached for peer $peerId');
// Remove the dead connection and notify
_connections.remove(peerId);
_lastPeerActivity.remove(peerId);
_peerReconnectAttempts.remove(peerId);
_peerDisconnectedController.add(peerId);
if (_connections.isEmpty) {
_connectionStateController.add(false);
}
return;
}
_peerReconnectAttempts[peerId] = attempts + 1;
final delay = Duration(seconds: (attempts + 1) * 2); // Exponential backoff
appLogger.d(
'WatchTogether: Attempting peer reconnect to $peerId (${attempts + 1}/$_maxPeerReconnectAttempts) in ${delay.inSeconds}s',
);
// Close existing connection if any
_connections[peerId]?.close();
_connections.remove(peerId);
// Schedule reconnection attempt
Timer(delay, () {
if (_peer != null && !_connections.containsKey(peerId)) {
appLogger.d('WatchTogether: Reconnecting to peer $peerId');
final conn = _peer!.connect(peerId, options: PeerConnectOption(reliable: true));
_handleNewConnection(conn, isOutgoing: true);
}
});
}
/// Broadcast a message to all connected peers
void broadcast(SyncMessage message) {
final json = message.toJson();
appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connections.length} peers');
for (final conn in _connections.values) {
try {
conn.send(json);
} catch (e) {
appLogger.e('WatchTogether: Failed to send to ${conn.peer}', error: e);
}
}
final payload = message.toJson();
appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connectedPeers.length} peers');
_sendRaw({'type': 'broadcast', 'payload': payload});
}
/// Send a message to a specific peer
void sendTo(String peerId, SyncMessage message) {
final conn = _connections[peerId];
if (conn != null) {
try {
conn.send(message.toJson());
} catch (e) {
appLogger.e('WatchTogether: Failed to send to $peerId', error: e);
}
} else {
appLogger.w('WatchTogether: No connection to peer: $peerId');
}
final payload = message.toJson();
appLogger.d('WatchTogether: Sending ${message.type} to $peerId');
_sendRaw({'type': 'sendTo', 'to': peerId, 'payload': payload});
}
/// Disconnect from all peers and close the session
@@ -416,22 +406,15 @@ class WatchTogetherPeerService {
_reconnectTimer?.cancel();
_reconnectTimer = null;
_stopTimers();
// Stop peer health monitoring
_stopPeerHealthCheck();
_lastPeerActivity.clear();
_peerReconnectAttempts.clear();
_channelSubscription?.cancel();
_channelSubscription = null;
// Close all data connections
for (final conn in _connections.values) {
conn.close();
}
_connections.clear();
// Destroy the peer
_peer?.dispose();
_peer = null;
await _channel?.sink.close();
_channel = null;
_connectedPeers.clear();
_sessionId = null;
_myPeerId = null;
_isHost = false;
@@ -144,14 +144,22 @@ class WatchTogetherSyncManager {
void initializeParticipants(List<String> peerIds) {
for (final peerId in peerIds) {
if (peerId != _peerService.myPeerId) {
// Assume they're buffering until they tell us otherwise
_participantBuffering[peerId] = true;
// They're not ready until they send playerReady
_participantReady[peerId] = false;
if (_session.isHost) {
// Host waits for each peer to load their video before allowing play.
_participantBuffering[peerId] = true;
_participantReady[peerId] = false;
} else {
// Guests use optimistic defaults — the host coordinates readiness
// and will broadcast pause/play as needed. Pessimistic defaults
// cause a deadlock because the host's playerReady/buffering
// messages arrive before the sync manager subscribes.
_participantBuffering[peerId] = false;
_participantReady[peerId] = true;
}
}
}
final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length;
appLogger.d('WatchTogether: Initialized $otherCount existing participants');
appLogger.d('WatchTogether: Initialized $otherCount existing participants (host=${_session.isHost})');
}
/// Detach the player and stop sync
@@ -637,10 +645,16 @@ class WatchTogetherSyncManager {
void _handlePeerJoin(SyncMessage message) {
appLogger.d('WatchTogether: Peer joined: ${message.displayName}');
// Assume new peer is buffering and not ready until they explicitly signal
if (message.peerId != null) {
_participantBuffering[message.peerId!] = true;
_participantReady[message.peerId!] = false;
if (_session.isHost) {
// Host waits for new peer to load their video before allowing play.
_participantBuffering[message.peerId!] = true;
_participantReady[message.peerId!] = false;
} else if (!_participantBuffering.containsKey(message.peerId!)) {
// Guests use optimistic defaults for peers they haven't seen yet.
_participantBuffering[message.peerId!] = false;
_participantReady[message.peerId!] = true;
}
}
// If we're the host, send session config AND our own join info to the new peer
@@ -648,6 +662,16 @@ class WatchTogetherSyncManager {
// Only send config if our video is loaded (we know the correct position)
if (_hasAnnouncedReady) {
_sendSessionConfig(toPeerId: message.peerId);
// Re-send our ready and buffering state so the new peer doesn't
// get stuck waiting for updates that were broadcast before it joined.
_peerService.sendTo(message.peerId!, SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
}
if (_player != null) {
_peerService.sendTo(
message.peerId!,
SyncMessage.buffering(_player!.state.buffering, peerId: _peerService.myPeerId),
);
}
// Send host's join info so guest adds host to their participants list
_peerService.sendTo(
@@ -663,6 +687,15 @@ class WatchTogetherSyncManager {
appLogger.d('WatchTogether: Received session config');
// The host only sends sessionConfig after its player is ready, so we
// can safely mark it as ready and not buffering. This prevents the
// guest from being permanently stuck waiting for ready/buffering
// messages that were broadcast before it joined.
if (message.peerId != null) {
_participantReady[message.peerId!] = true;
_participantBuffering[message.peerId!] = false;
}
// Update control mode
if (message.controlMode != null) {
onSessionConfigReceived?.call(message.controlMode!);
@@ -6,7 +6,6 @@
#include "generated_plugin_registrant.h"
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <os_media_controls/os_media_controls_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
@@ -15,9 +14,6 @@
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
g_autoptr(FlPluginRegistrar) os_media_controls_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin");
os_media_controls_plugin_register_with_registrar(os_media_controls_registrar);
-1
View File
@@ -3,7 +3,6 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_webrtc
os_media_controls
screen_retriever_linux
sqlite3_flutter_libs
@@ -8,7 +8,6 @@ import Foundation
import connectivity_plus
import device_info_plus
import file_picker
import flutter_webrtc
import in_app_review
import os_media_controls
import package_info_plus
@@ -26,7 +25,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
-10
View File
@@ -5,9 +5,6 @@ PODS:
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- flutter_webrtc (1.2.0):
- FlutterMacOS
- WebRTC-SDK (= 137.7151.04)
- FlutterMacOS (1.0.0)
- in_app_review (2.0.0):
- FlutterMacOS
@@ -57,7 +54,6 @@ PODS:
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
- WebRTC-SDK (137.7151.04)
- window_manager (0.2.0):
- FlutterMacOS
@@ -65,7 +61,6 @@ DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
@@ -83,7 +78,6 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- sqlite3
- WebRTC-SDK
EXTERNAL SOURCES:
connectivity_plus:
@@ -92,8 +86,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
flutter_webrtc:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
FlutterMacOS:
:path: Flutter/ephemeral
in_app_review:
@@ -125,7 +117,6 @@ SPEC CHECKSUMS:
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
@@ -139,7 +130,6 @@ SPEC CHECKSUMS:
universal_gamepad: 8922f1f238f62d6847de887228976d5b572b57da
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
+74 -74
View File
@@ -21,6 +21,7 @@
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
0BAE8FC95BA6BEE8BE54E226 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */; };
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
@@ -32,8 +33,7 @@
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; };
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */; };
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; };
DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -67,7 +67,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
0BF4BB2D93DB27E23E7E5693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
@@ -83,20 +84,19 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6E770EFFB2EDFFA0C566F238 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
84260356652A3270E664FED4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9B9AC1D995B9CED225415BEA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -104,7 +104,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */,
0BAE8FC95BA6BEE8BE54E226 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -112,8 +112,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */,
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */,
DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -123,12 +123,12 @@
295400F6A94D53E806ED64A8 /* Pods */ = {
isa = PBXGroup;
children = (
21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */,
4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */,
8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */,
B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */,
B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */,
5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */,
6E770EFFB2EDFFA0C566F238 /* Pods-Runner.debug.xcconfig */,
0BF4BB2D93DB27E23E7E5693 /* Pods-Runner.release.xcconfig */,
9B9AC1D995B9CED225415BEA /* Pods-Runner.profile.xcconfig */,
0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */,
AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */,
FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -161,7 +161,7 @@
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
295400F6A94D53E806ED64A8 /* Pods */,
36FC208C02D36438C31F4842 /* Frameworks */,
A25A237F70C8052CAABD850E /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -211,15 +211,6 @@
path = Runner;
sourceTree = "<group>";
};
36FC208C02D36438C31F4842 /* Frameworks */ = {
isa = PBXGroup;
children = (
78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */,
84260356652A3270E664FED4 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = {
isa = PBXGroup;
children = (
@@ -229,6 +220,15 @@
path = MpvPlayer;
sourceTree = "<group>";
};
A25A237F70C8052CAABD850E /* Frameworks */ = {
isa = PBXGroup;
children = (
81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */,
C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -236,7 +236,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */,
CEDCA9B4C8CE113636A31DED /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
@@ -255,13 +255,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */,
835BFBC4F69B434783C6907B /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */,
53E8C05D7685362D47C19320 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -347,45 +347,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -424,7 +385,24 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */ = {
53E8C05D7685362D47C19320 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
835BFBC4F69B434783C6907B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -446,6 +424,28 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
CEDCA9B4C8CE113636A31DED /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -501,7 +501,7 @@
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */;
baseConfigurationReference = 0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -516,7 +516,7 @@
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */;
baseConfigurationReference = AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -531,7 +531,7 @@
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */;
baseConfigurationReference = FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
+1 -41
View File
@@ -297,14 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
dbus:
dependency: transitive
description:
@@ -369,14 +361,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.3"
events_emitter:
dependency: transitive
description:
name: events_emitter
sha256: a075477bdf9c8c0c31bb7c7b7bdd357b4486c34f30163119f96de4e7f54abeff
url: "https://pub.dev"
source: hosted
version: "0.5.2"
fake_async:
dependency: transitive
description:
@@ -485,14 +469,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct overridden"
description:
name: flutter_webrtc
sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
frontend_server_client:
dependency: transitive
description:
@@ -814,14 +790,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
peerdart:
dependency: "direct main"
description:
name: peerdart
sha256: "1d0db041d42194f42e57d8889d029fb8d107610bf1062b39f43f4651de547e3c"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
petitparser:
dependency: transitive
description:
@@ -1396,21 +1364,13 @@ packages:
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
win32:
dependency: transitive
description:
+1 -4
View File
@@ -44,7 +44,7 @@ dependencies:
saf_util: ^0.11.0
saf_stream: ^0.12.2
material_symbols_icons: ^4.2892.0
peerdart: ^0.5.6
web_socket_channel: ^3.0.1
in_app_review: ^2.0.11
dart_discord_presence: ^1.1.0
@@ -59,9 +59,6 @@ dev_dependencies:
dart_code_linter: ^3.1.1
drift_dev: ^2.14.0
dependency_overrides:
flutter_webrtc: ^1.2.1
flutter:
uses-material-design: true
assets:
+13
View File
@@ -0,0 +1,13 @@
module github.com/edde746/plezy-relay
go 1.22
require (
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.31.0
)
require (
golang.org/x/net v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+388
View File
@@ -0,0 +1,388 @@
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/acme/autocert"
)
const (
maxRoomSize = 8
rateBurst = 30
rateSustained = 10
cleanupInterval = 5 * time.Minute
emptyRoomMaxAge = 5 * time.Minute
roomMaxAge = 24 * time.Hour
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingInterval = 30 * time.Second
maxMessageSize = 64 * 1024
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// --- Rate limiter (token bucket) ---
type rateLimiter struct {
tokens float64
maxTokens float64
refillRate float64
lastTime time.Time
mu sync.Mutex
}
func newRateLimiter(burst, sustained int) *rateLimiter {
return &rateLimiter{
tokens: float64(burst),
maxTokens: float64(burst),
refillRate: float64(sustained),
lastTime: time.Now(),
}
}
func (rl *rateLimiter) allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
elapsed := now.Sub(rl.lastTime).Seconds()
rl.lastTime = now
rl.tokens += elapsed * rl.refillRate
if rl.tokens > rl.maxTokens {
rl.tokens = rl.maxTokens
}
if rl.tokens < 1 {
return false
}
rl.tokens--
return true
}
// --- Messages ---
type clientMsg struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
PeerID string `json:"peerId,omitempty"`
To string `json:"to,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type serverMsg struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
PeerID string `json:"peerId,omitempty"`
From string `json:"from,omitempty"`
Peers []string `json:"peers,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// --- Room ---
type Room struct {
SessionID string
HostPeerID string
Peers map[string]*websocket.Conn
mu sync.RWMutex
CreatedAt time.Time
}
func (r *Room) peerIDs() []string {
ids := make([]string, 0, len(r.Peers))
for id := range r.Peers {
ids = append(ids, id)
}
return ids
}
func (r *Room) broadcastExcept(senderID string, msg serverMsg) {
data, err := json.Marshal(msg)
if err != nil {
return
}
r.mu.RLock()
defer r.mu.RUnlock()
for id, conn := range r.Peers {
if id != senderID {
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
}
}
}
func (r *Room) sendTo(targetID string, msg serverMsg) bool {
data, err := json.Marshal(msg)
if err != nil {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
conn, ok := r.Peers[targetID]
if !ok {
return false
}
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
return true
}
// --- Server ---
type Server struct {
rooms map[string]*Room
mu sync.RWMutex
}
func newServer() *Server {
s := &Server{rooms: make(map[string]*Room)}
go s.cleanupLoop()
return s
}
func (s *Server) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
now := time.Now()
for id, room := range s.rooms {
room.mu.RLock()
empty := len(room.Peers) == 0
age := now.Sub(room.CreatedAt)
room.mu.RUnlock()
if (empty && age > emptyRoomMaxAge) || age > roomMaxAge {
log.Printf("cleanup: removing room %s (empty=%v, age=%v)", id, empty, age)
delete(s.rooms, id)
}
}
s.mu.Unlock()
}
}
func (s *Server) sendError(conn *websocket.Conn, code, message string) {
data, _ := json.Marshal(serverMsg{Type: "error", Code: code, Message: message})
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
}
func (s *Server) sendJSON(conn *websocket.Conn, msg serverMsg) {
data, _ := json.Marshal(msg)
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
defer conn.Close()
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Ping ticker
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
go func() {
for range ticker.C {
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}()
rl := newRateLimiter(rateBurst, rateSustained)
var currentRoom *Room
var currentPeerID string
// Cleanup on disconnect
defer func() {
if currentRoom != nil && currentPeerID != "" {
currentRoom.mu.Lock()
delete(currentRoom.Peers, currentPeerID)
currentRoom.mu.Unlock()
currentRoom.broadcastExcept(currentPeerID, serverMsg{
Type: "peerLeft",
PeerID: currentPeerID,
})
log.Printf("peer %s left room %s", currentPeerID, currentRoom.SessionID)
}
}()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("read error: %v", err)
}
return
}
if !rl.allow() {
s.sendError(conn, "rate_limited", "Too many messages")
continue
}
var msg clientMsg
if err := json.Unmarshal(raw, &msg); err != nil {
s.sendError(conn, "invalid_message", "Invalid JSON")
continue
}
switch msg.Type {
case "create":
if msg.SessionID == "" || msg.PeerID == "" {
s.sendError(conn, "invalid_message", "sessionId and peerId required")
continue
}
s.mu.Lock()
if _, exists := s.rooms[msg.SessionID]; exists {
s.mu.Unlock()
s.sendError(conn, "room_exists", "Room already exists")
continue
}
room := &Room{
SessionID: msg.SessionID,
HostPeerID: msg.PeerID,
Peers: map[string]*websocket.Conn{msg.PeerID: conn},
CreatedAt: time.Now(),
}
s.rooms[msg.SessionID] = room
s.mu.Unlock()
currentRoom = room
currentPeerID = msg.PeerID
log.Printf("room %s created by %s", msg.SessionID, msg.PeerID)
s.sendJSON(conn, serverMsg{Type: "created", SessionID: msg.SessionID})
case "join":
if msg.SessionID == "" || msg.PeerID == "" {
s.sendError(conn, "invalid_message", "sessionId and peerId required")
continue
}
s.mu.RLock()
room, exists := s.rooms[msg.SessionID]
s.mu.RUnlock()
if !exists {
s.sendError(conn, "room_not_found", "Room does not exist")
continue
}
room.mu.Lock()
if len(room.Peers) >= maxRoomSize {
room.mu.Unlock()
s.sendError(conn, "room_full", "Room is full")
continue
}
room.Peers[msg.PeerID] = conn
peers := room.peerIDs()
room.mu.Unlock()
currentRoom = room
currentPeerID = msg.PeerID
log.Printf("peer %s joined room %s", msg.PeerID, msg.SessionID)
// Tell the joiner who's already here (excluding themselves)
existingPeers := make([]string, 0, len(peers)-1)
for _, p := range peers {
if p != msg.PeerID {
existingPeers = append(existingPeers, p)
}
}
s.sendJSON(conn, serverMsg{Type: "joined", SessionID: msg.SessionID, Peers: existingPeers})
room.broadcastExcept(msg.PeerID, serverMsg{Type: "peerJoined", PeerID: msg.PeerID})
case "broadcast":
if currentRoom == nil {
s.sendError(conn, "not_in_room", "Not in a room")
continue
}
currentRoom.broadcastExcept(currentPeerID, serverMsg{
Type: "message",
From: currentPeerID,
Payload: msg.Payload,
})
case "sendTo":
if currentRoom == nil {
s.sendError(conn, "not_in_room", "Not in a room")
continue
}
if msg.To == "" {
s.sendError(conn, "invalid_message", "to field required")
continue
}
if !currentRoom.sendTo(msg.To, serverMsg{
Type: "message",
From: currentPeerID,
Payload: msg.Payload,
}) {
s.sendError(conn, "not_in_room", "Target peer not found")
}
case "ping":
s.sendJSON(conn, serverMsg{Type: "pong"})
default:
s.sendError(conn, "invalid_message", "Unknown message type")
}
}
}
func main() {
dev := flag.Bool("dev", false, "Run in development mode (plain HTTP on :8080)")
host := flag.String("host", "ice.plezy.app", "Hostname for TLS autocert")
flag.Parse()
srv := newServer()
mux := http.NewServeMux()
mux.HandleFunc("/relay", srv.handleWS)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
if *dev {
log.Println("Starting dev server on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
} else {
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(*host),
Cache: autocert.DirCache("/var/lib/plezy-relay/certs"),
}
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: certManager.TLSConfig(),
}
// HTTP challenge server for Let's Encrypt
go func() {
log.Println("Starting HTTP challenge server on :80")
log.Fatal(http.ListenAndServe(":80", certManager.HTTPHandler(nil)))
}()
log.Printf("Starting relay server on :443 (host=%s)", *host)
log.Fatal(server.ListenAndServeTLS("", ""))
}
}
@@ -7,7 +7,6 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <os_media_controls/os_media_controls_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
@@ -18,8 +17,6 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
OsMediaControlsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
-1
View File
@@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_webrtc
os_media_controls
screen_retriever_windows
sqlite3_flutter_libs