Files
MatchLiveTv/mobile/lib/shared/websocket_service.dart
Emiliano Frascaro 854738b46d Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 15:30:55 +02:00

206 lines
5.5 KiB
Dart

import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:x_action_cable_v2/x_action_cable.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import '../providers/score_provider.dart';
import '../providers/score_sync_provider.dart';
import '../providers/session_provider.dart';
import 'models/device_state.dart';
import 'models/score_state.dart';
final websocketServiceProvider = Provider<WebsocketService>((ref) {
final service = WebsocketService(ref);
ref.onDispose(service.dispose);
return service;
});
typedef CableMessageHandler = void Function(Map<String, dynamic> data);
class WebsocketService {
WebsocketService(this._ref);
final Ref _ref;
ActionCable? _cable;
ActionChannel? _channel;
String? _sessionId;
Future<void>? _connectOp;
bool get isConnected => _cable != null && _channel != null;
Future<void> connect({
required String sessionId,
required String deviceRole,
}) async {
if (_sessionId == sessionId && isConnected) return;
final prev = _connectOp;
if (prev != null) await prev.catchError((_) {});
_connectOp = _connect(sessionId: sessionId, deviceRole: deviceRole);
final op = _connectOp!;
try {
await op;
} finally {
if (identical(_connectOp, op)) {
_connectOp = null;
}
}
}
Future<void> _connect({
required String sessionId,
required String deviceRole,
}) async {
await disconnect();
final token = _ref.read(authProvider).accessToken;
if (token == null || token.isEmpty) {
throw StateError('Token di accesso mancante');
}
_sessionId = sessionId;
_cable = ActionCable.connect(
AppConfig.cableUrl,
headers: {'Authorization': 'Bearer $token'},
onConnected: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onConnectionLost: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
onCannotConnect: (_) {
_ref.read(sessionProvider.notifier).setConnected(false);
},
);
_channel = _cable!.subscribe(
'SessionChannel',
channelParams: {
'session_id': sessionId,
'device_role': deviceRole,
},
onSubscribed: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onDisconnected: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
callbacks: [
ActionCallback(
name: 'receive_message',
callback: _handleMessage,
),
],
);
}
void _handleMessage(ActionResponse response) {
if (response.hasError || response.data == null) return;
_dispatch(Map<String, dynamic>.from(response.data!));
}
void _dispatch(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case 'score_update':
final remote = ScoreState.fromJson(data);
if (_ref.read(scoreSyncProvider).acceptCableScore(remote)) {
_ref.read(scoreProvider.notifier).applyRemote(remote);
}
break;
case 'device_state':
_ref
.read(sessionProvider.notifier)
.updateDeviceState(DeviceStateModel.fromJson(data));
break;
case 'timeout':
final team = data['team'] as String?;
if (team == 'home') {
_ref.read(scoreProvider.notifier).setTimeoutHome(true);
} else if (team == 'away') {
_ref.read(scoreProvider.notifier).setTimeoutAway(true);
}
break;
case 'command':
final action = data['action'] as String?;
if (action != null) {
_ref.read(sessionProvider.notifier).applyCommand(action);
}
break;
case 'stream_event':
final event = data['event'] as String?;
if (event == 'paused') {
_ref.read(sessionProvider.notifier).applyCommand('pause_stream');
} else if (event == 'resumed') {
_ref.read(sessionProvider.notifier).applyCommand('resume_stream');
}
break;
case 'youtube_ready':
_ref.read(sessionProvider.notifier).updateYoutubeLinks(
youtubeWatchUrl: data['youtube_watch_url'] as String?,
shareUrl: data['share_url'] as String?,
youtubeBroadcastId: data['youtube_broadcast_id'] as String?,
);
break;
default:
break;
}
}
void sendScoreUpdate(ScoreState score) {
_perform({'type': 'score_update', ...score.toCablePayload()});
}
void sendTimeout(String team) {
_perform({'type': 'timeout', 'team': team});
}
void sendCommand(String action) {
_perform({'type': 'command', 'action': action});
}
void sendDeviceState(DeviceStateModel state) {
_perform({
'type': 'device_state',
'device_role': state.deviceRole,
'battery': state.batteryLevel,
'network': state.networkType,
'bitrate': state.currentBitrate,
'fps': state.fps,
});
}
void _perform(Map<String, dynamic> payload) {
_channel?.performAction('receive', params: payload);
}
Future<void> disconnect() async {
final channel = _channel;
final cable = _cable;
_channel = null;
_cable = null;
_sessionId = null;
try {
channel?.unsubscribe();
} catch (_) {}
try {
cable?.disconnect();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 150));
_ref.read(sessionProvider.notifier).setConnected(false);
}
void dispose() {
disconnect();
}
String encodeQrPayload(Map<String, dynamic> payload) => jsonEncode(payload);
}