Files

676 lines
23 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Error types for RemoteBridge
enum RemoteBridgeErrorType {
webrtcConnection,
signaling,
authentication,
dataChannel,
timeout,
unknown,
}
/// Error information from RemoteBridge
class RemoteBridgeError {
final RemoteBridgeErrorType type;
final String message;
final DateTime timestamp;
RemoteBridgeError({
required this.type,
required this.message,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
}
/// Connection state for RemoteBridge
enum RemoteBridgeState {
disconnected,
connecting,
authenticating,
connected,
reconnecting,
failed,
}
/// Connection quality metrics
class ConnectionMetrics {
final int requestsSent;
final int responsesReceived;
final int reconnectionCount;
final Duration? lastLatency;
final DateTime? lastActivity;
ConnectionMetrics({
required this.requestsSent,
required this.responsesReceived,
required this.reconnectionCount,
this.lastLatency,
this.lastActivity,
});
double get successRate {
if (requestsSent == 0) return 1.0;
return responsesReceived / requestsSent;
}
}
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
/// for remote connections. All traffic is tunneled through a local WebSocket server.
///
/// Architecture:
/// ```
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
/// ```
class RemoteBridge {
HttpServer? _server;
WebViewWebRTCConnection? _webrtcConnection;
int? _port;
// API client (MusicAssistantAPI)
WebSocket? _apiClientSocket;
StreamSubscription? _apiClientSubscription;
StreamSubscription? _apiWebrtcSubscription;
// Sendspin client (local player audio)
WebSocket? _sendspinClientSocket;
StreamSubscription? _sendspinClientSubscription;
StreamSubscription? _sendspinTextSubscription;
StreamSubscription? _sendspinBinarySubscription;
Timer? _healthCheckTimer;
Timer? _reconnectTimer;
// Connection parameters for reconnection
String? _remoteId;
String? _username;
String? _password;
bool _isReconnecting = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 3);
static const Duration _rateLimitDelay = Duration(seconds: 65);
bool _isRateLimited = false;
// Cached server hello to replay when WS client connects
String? _cachedServerHello;
// Debug counters
int _apiMessagesSent = 0;
int _apiMessagesReceived = 0;
int _sendspinMessagesSent = 0;
int _sendspinMessagesReceived = 0;
DateTime? _lastMessageReceived;
int _totalReconnections = 0;
// State tracking
RemoteBridgeState _state = RemoteBridgeState.disconnected;
final _stateController = StreamController<RemoteBridgeState>.broadcast();
final _errorController = StreamController<RemoteBridgeError>.broadcast();
final DebugLogger _logger = DebugLogger();
int? get port => _port;
bool get isRunning => _server != null;
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
RemoteBridgeState get state => _state;
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
/// Build the hidden WebView widget that must be added to the widget tree.
/// This must be called and the returned widget added to the tree BEFORE calling start().
/// The WebView is 1x1 pixel and invisible.
Widget buildWebView() {
// Create the connection if not already created
_webrtcConnection ??= WebViewWebRTCConnection();
return _webrtcConnection!.buildWebView();
}
/// Get current connection metrics
ConnectionMetrics get metrics => ConnectionMetrics(
requestsSent: _apiMessagesSent,
responsesReceived: _apiMessagesReceived,
reconnectionCount: _totalReconnections,
lastActivity: _lastMessageReceived,
);
/// Update state and notify listeners
void _setState(RemoteBridgeState newState) {
if (_state != newState) {
_logger.log('RemoteBridge: State changed: $_state -> $newState');
_state = newState;
_stateController.add(newState);
}
}
/// Emit an error to listeners
void _emitError(RemoteBridgeErrorType type, String message) {
final error = RemoteBridgeError(type: type, message: message);
_logger.log('RemoteBridge: Error [$type]: $message');
_errorController.add(error);
}
/// Start the bridge and connect to the remote MA server.
/// Returns the local port number to connect to, or null if failed.
Future<int?> start(String remoteId, String username, String password) async {
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
_setState(RemoteBridgeState.connecting);
// Store connection parameters for reconnection
_remoteId = remoteId;
_username = username;
_password = password;
_reconnectAttempts = 0;
try {
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
// The WebView must be in the widget tree first - call buildWebView() before start()
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state: $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_handleWebRTCDisconnection();
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error: $error');
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
};
final connected = await _webrtcConnection!.connect(remoteId);
if (!connected) {
_logger.log('RemoteBridge: WebRTC connection failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
await stop();
return null;
}
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
_setState(RemoteBridgeState.authenticating);
// Step 2: Authenticate with MA
final authenticated = await _authenticate(username, password);
if (!authenticated) {
_logger.log('RemoteBridge: Authentication failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
await stop();
return null;
}
_logger.log('RemoteBridge: Authentication successful');
_setState(RemoteBridgeState.connected);
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Cached server hello');
}
// Step 4: Start local WebSocket server
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_port = _server!.port;
_logger.log('RemoteBridge: Local WS server started on port $_port');
// Handle incoming connections
_server!.listen(_handleHttpRequest);
// Step 5: Subscribe to WebRTC API messages to forward to API client
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
// Forward binary audio data to Sendspin client
_sendspinClientSocket!.add(data);
}
});
// Step 8: Start health check timer
_startHealthCheck();
return _port;
} catch (e) {
_logger.log('RemoteBridge: Start failed: $e');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.unknown, e.toString());
await stop();
return null;
}
}
/// Authenticate with the MA server over WebRTC.
Future<bool> _authenticate(String username, String password) async {
try {
// Step 1: Login to get access token
_logger.log('RemoteBridge: Sending auth/login...');
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
'username': username,
'password': password,
'device_name': 'Ensemble Mobile App',
});
_logger.log('RemoteBridge: Login result: $loginResult');
// Check for error response
if (loginResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
return false;
}
String? accessToken;
if (loginResult['result'] != null) {
final result = loginResult['result'];
if (result is Map<String, dynamic>) {
if (result['success'] == true) {
accessToken = result['access_token'] as String?;
final user = result['user'] as Map<String, dynamic>?;
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
}
}
}
if (accessToken == null) {
_logger.log('RemoteBridge: No access token received');
return false;
}
// Step 2: Authenticate the session with the token
_logger.log('RemoteBridge: Authenticating session with token...');
final authResult = await _webrtcConnection!.sendRequest('auth', {
'token': accessToken,
});
_logger.log('RemoteBridge: Auth result: $authResult');
if (authResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
return false;
}
_logger.log('RemoteBridge: Session authenticated successfully!');
return true;
} catch (e) {
_logger.log('RemoteBridge: Auth error: $e');
return false;
}
}
/// Handle incoming HTTP requests and upgrade to WebSocket.
void _handleHttpRequest(HttpRequest request) async {
final path = request.uri.path;
_logger.log('RemoteBridge: Incoming HTTP request: $path');
if (WebSocketTransformer.isUpgradeRequest(request)) {
try {
final socket = await WebSocketTransformer.upgrade(request);
// Route based on path: /sendspin for audio, anything else for API
if (path == '/sendspin') {
_handleSendspinConnection(socket);
} else {
_handleApiConnection(socket);
}
} catch (e) {
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
request.response.statusCode = HttpStatus.internalServerError;
await request.response.close();
}
} else {
// Not a WebSocket request
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
}
}
/// Handle a new API WebSocket client connection.
void _handleApiConnection(WebSocket socket) {
_logger.log('RemoteBridge: API client connected');
// Only allow one API client at a time
if (_apiClientSocket != null) {
_logger.log('RemoteBridge: Closing existing API client connection');
_apiClientSubscription?.cancel();
_apiClientSocket?.close();
}
_apiClientSocket = socket;
// Step 1: Send cached server hello immediately
if (_cachedServerHello != null) {
_logger.log('RemoteBridge: Sending cached server hello to API client');
_apiClientSocket!.add(_cachedServerHello!);
}
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
_apiClientSubscription = _apiClientSocket!.listen(
(message) {
if (message is String && _webrtcConnection != null) {
_apiMessagesSent++;
_webrtcConnection!.sendRaw(message);
}
},
onDone: () {
_logger.log('RemoteBridge: API client disconnected');
_apiClientSocket = null;
_apiClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: API client error: $error');
_apiClientSocket = null;
_apiClientSubscription = null;
},
);
}
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
void _handleSendspinConnection(WebSocket socket) {
_logger.log('RemoteBridge: Sendspin client connected');
// Only allow one Sendspin client at a time
if (_sendspinClientSocket != null) {
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
_sendspinClientSubscription?.cancel();
_sendspinClientSocket?.close();
}
_sendspinClientSocket = socket;
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
_sendspinClientSubscription = _sendspinClientSocket!.listen(
(message) {
if (_webrtcConnection != null) {
_sendspinMessagesSent++;
if (message is String) {
// Text message (JSON control)
_webrtcConnection!.sendSendspinText(message);
} else if (message is List<int>) {
// Binary message (would be audio data from client, unlikely but handle it)
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
}
}
},
onDone: () {
_logger.log('RemoteBridge: Sendspin client disconnected');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: Sendspin client error: $error');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
);
}
/// Handle WebRTC disconnection - attempt reconnection.
void _handleWebRTCDisconnection() {
_logger.log('RemoteBridge: WebRTC disconnected');
// Don't close WS clients immediately - they will reconnect when WebRTC is back
// Instead, attempt to reconnect WebRTC
_scheduleReconnect();
}
/// Schedule a WebRTC reconnection attempt.
void _scheduleReconnect() {
if (_isReconnecting) {
_logger.log('RemoteBridge: Already reconnecting, skipping');
return;
}
if (_remoteId == null || _username == null || _password == null) {
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
_closeAllClients();
return;
}
if (_reconnectAttempts >= _maxReconnectAttempts) {
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
_closeAllClients();
return;
}
_setState(RemoteBridgeState.reconnecting);
_reconnectAttempts++;
_totalReconnections++;
// Use longer delay if rate limited, otherwise exponential backoff
final Duration delay;
if (_isRateLimited) {
delay = _rateLimitDelay;
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
} else {
delay = _reconnectDelay * _reconnectAttempts;
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, _reconnectWebRTC);
}
/// Attempt to reconnect WebRTC.
Future<void> _reconnectWebRTC() async {
if (_isReconnecting) return;
_isReconnecting = true;
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
try {
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
_apiWebrtcSubscription?.cancel();
_sendspinTextSubscription?.cancel();
_sendspinBinarySubscription?.cancel();
await _webrtcConnection?.disconnect();
// Reuse existing WebView connection (WebView is already in widget tree)
// If somehow null, create a new one (shouldn't happen in normal flow)
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_isReconnecting = false;
_handleWebRTCDisconnection();
} else if (state == WebViewWebRTCConnectionState.connected) {
_reconnectAttempts = 0; // Reset on successful connection
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
// Detect rate limiting from signaling server
if (error.toLowerCase().contains('rate limit')) {
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
_isRateLimited = true;
}
};
final connected = await _webrtcConnection!.connect(_remoteId!);
if (!connected) {
_logger.log('RemoteBridge: WebRTC reconnection failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
// Re-authenticate
final authenticated = await _authenticate(_username!, _password!);
if (!authenticated) {
_logger.log('RemoteBridge: Re-authentication failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: Re-authentication successful');
// Re-cache server hello
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Re-cached server hello');
}
// Re-subscribe to WebRTC streams
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(data);
}
});
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
_setState(RemoteBridgeState.connected);
_isReconnecting = false;
_reconnectAttempts = 0;
_isRateLimited = false;
} catch (e) {
_logger.log('RemoteBridge: Reconnection error: $e');
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
_isReconnecting = false;
_scheduleReconnect();
}
}
/// Close all WS clients (called when giving up on reconnection).
void _closeAllClients() {
_logger.log('RemoteBridge: Closing all WS clients');
_apiClientSocket?.close();
_apiClientSocket = null;
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_sendspinClientSocket?.close();
_sendspinClientSocket = null;
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
}
/// Start periodic health check to monitor connection state
void _startHealthCheck() {
_healthCheckTimer?.cancel();
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
final timeSinceLastMsg = _lastMessageReceived != null
? DateTime.now().difference(_lastMessageReceived!).inSeconds
: -1;
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
'lastRecv:${timeSinceLastMsg}s ago');
});
}
/// Stop the bridge and disconnect from the remote server.
Future<void> stop() async {
_logger.log('RemoteBridge: Stopping bridge');
_setState(RemoteBridgeState.disconnected);
// Cancel reconnection
_reconnectTimer?.cancel();
_reconnectTimer = null;
_isReconnecting = false;
_reconnectAttempts = 0;
_healthCheckTimer?.cancel();
_healthCheckTimer = null;
// Cancel API subscriptions
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_apiWebrtcSubscription?.cancel();
_apiWebrtcSubscription = null;
// Cancel Sendspin subscriptions
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
_sendspinTextSubscription?.cancel();
_sendspinTextSubscription = null;
_sendspinBinarySubscription?.cancel();
_sendspinBinarySubscription = null;
// Close client sockets
await _apiClientSocket?.close();
_apiClientSocket = null;
await _sendspinClientSocket?.close();
_sendspinClientSocket = null;
await _server?.close(force: true);
_server = null;
_port = null;
await _webrtcConnection?.disconnect();
_webrtcConnection = null;
_cachedServerHello = null;
// Clear connection parameters
_remoteId = null;
_username = null;
_password = null;
_logger.log('RemoteBridge: Bridge stopped');
}
/// Dispose the bridge and close all stream controllers.
void dispose() {
stop();
_stateController.close();
_errorController.close();
}
}