feat: replace webrtc with ws relay
This commit is contained in:
+6
-6
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_webrtc
|
||||
os_media_controls
|
||||
screen_retriever_windows
|
||||
sqlite3_flutter_libs
|
||||
|
||||
Reference in New Issue
Block a user