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>
This commit is contained in:
2026-06-06 15:30:55 +02:00
parent 1058c644bd
commit 854738b46d
65 changed files with 2606 additions and 579 deletions

View File

@@ -23,10 +23,23 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
}
Future<void> _bootstrap() async {
await ref.read(authProvider.notifier).restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 800));
final notifier = ref.read(authProvider.notifier);
await notifier.restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
var auth = ref.read(authProvider);
if (auth.isAuthenticated) {
final ok = await notifier.validateOrRefreshSession();
if (!mounted) return;
auth = ref.read(authProvider);
if (!ok || !auth.isAuthenticated) {
context.go('/login');
return;
}
}
if (!mounted) return;
final auth = ref.read(authProvider);
if (auth.isAuthenticated) {
context.go('/matches');
} else {

View File

@@ -56,7 +56,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
int _viewerCount = 0;
StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
bool _pauseInFlight = false;
bool _resumeInFlight = false;
String? _lastStreamErrorShown;
@@ -74,6 +73,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(sessionProvider.notifier).setElapsed(
DateTime.now().difference(session!.startedAt!),
);
} else {
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
}
});
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
@@ -86,16 +87,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final orientation = MediaQuery.orientationOf(context);
if (_lastOrientation != null && _lastOrientation != orientation) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await StreamingChannel.bindPreview();
});
}
_lastOrientation = orientation;
Map<String, dynamic> _streamVideoParams(BuildContext context) {
final portrait = MediaQuery.orientationOf(context) == Orientation.portrait;
return {
'width': portrait ? 720 : 1280,
'height': portrait ? 1280 : 720,
'portrait': portrait,
};
}
Future<bool> _ensurePermissions() async {
@@ -112,6 +110,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
return false;
}
Future<bool> _bindPreviewWithRetry({int attempts = 12}) async {
for (var i = 0; i < attempts; i++) {
final ok = await StreamingChannel.bindPreview();
if (ok) return true;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
return false;
}
Future<void> _bootstrap() async {
try {
if (!await _ensurePermissions()) return;
@@ -150,7 +157,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300));
await StreamingChannel.bindPreview();
final previewReady = await _bindPreviewWithRetry();
if (!previewReady && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anteprima camera non pronta — video non disponibile. Riapri per riprovare.'),
duration: Duration(seconds: 4),
),
);
// Non return: sessione e score sono già settati, WebSocket connesso.
// L'utente può ancora gestire il punteggio anche senza preview video.
}
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
@@ -158,10 +175,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(scoreProvider.notifier).reset(fresh.score!);
}
if (!mounted) return;
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
final wasPausedAtOpen = statusAtOpen == 'paused';
final shouldAutoPublish = fresh.rtmpIngestUrl != null &&
final shouldAutoPublish = previewReady &&
fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen &&
fresh.status != 'paused' &&
(justStartedSession ||
@@ -174,21 +194,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
await StreamingChannel.stopStream();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 400));
final useFreshStart =
justStartedSession || fresh.status == 'connecting';
if (useFreshStart) {
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
} else {
await StreamingChannel.resumeStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
}
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -415,10 +430,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
if (session == null || session.rtmpIngestUrl == null) return;
_resumeInFlight = true;
try {
final isPortrait = mounted &&
MediaQuery.orientationOf(context) == Orientation.portrait;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -449,10 +470,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) {
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
}
@@ -558,9 +584,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
MediaQuery.orientationOf(context) == Orientation.landscape;
final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet);
// Preview unica: non viene mai smontata al cambio orientamento.
// Preview a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
return Scaffold(
backgroundColor: isLandscape ? Colors.black : AppTheme.background,
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
@@ -672,20 +698,21 @@ class _PortraitOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
final screenH = MediaQuery.sizeOf(context).height;
// Riserva almeno 35% dello schermo all'anteprima camera, il resto ai controlli.
final maxControlsH = screenH * 0.65;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
// ── Barra superiore: badge live + metriche ──────────────────────────
Container(
color: Colors.black54,
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, paused: isPaused),
const Spacer(),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share),
tooltip: 'Condividi link',
),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
@@ -693,52 +720,84 @@ class _PortraitOverlay extends StatelessWidget {
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, size: 20),
tooltip: 'Condividi link',
color: Colors.white70,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
const Expanded(child: SizedBox.expand()),
Container(
color: AppTheme.background,
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LiveScoreControls(
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
// ── Area camera (AndroidView a tutto schermo sotto) ───────────────
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
// ── Pannello controlli ───────────────────────────────────────────────
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxControlsH),
child: Container(
color: AppTheme.background,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: onShare,
icon: const Icon(Icons.share, size: 18),
label: const Text('Condividi link'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Tabellone + pulsanti punteggio — primo widget, sempre visibile.
LiveScoreControls(
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 12),
// Azioni stream: pausa/riprendi + chiudi.
Row(
children: [
Expanded(
child: isPaused
? FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
)
: OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Pausa'),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
),
],
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onShare,
icon: const Icon(Icons.share, size: 16),
label: const Text('Condividi link diretta'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Colors.white24),
),
),
],
),
const SizedBox(height: 8),
if (isPaused)
FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
)
else
OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Metti in pausa'),
),
const SizedBox(height: 8),
DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
],
),
),
),
],
@@ -806,7 +865,7 @@ class _LandscapeOverlay extends StatelessWidget {
],
),
),
const Expanded(child: SizedBox.expand()),
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
Container(
color: Colors.black.withValues(alpha: 0.82),
padding: EdgeInsets.fromLTRB(

View File

@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../core/config.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
@@ -64,7 +65,60 @@ class MatchesScreen extends ConsumerWidget {
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore squadre: $e')),
error: (e, stack) {
final msg = e.toString();
final unauthorized = msg.contains('401');
final timeout = msg.contains('timeout') || msg.contains('Timeout');
final offline = timeout ||
msg.contains('SocketException') ||
msg.contains('Failed host lookup');
String title = 'Errore nel caricamento squadre';
String hint = 'Controlla WiFi o dati mobili e riprova.';
if (unauthorized) {
title = 'Sessione scaduta';
hint = 'Accedi di nuovo con email e password.';
} else if (offline) {
title = 'Server non raggiungibile';
hint = timeout
? 'La connessione è troppo lenta o assente. Verifica la rete (serve almeno qualche Mbps).'
: 'Impossibile contattare ${AppConfig.apiBaseUrl}. Riprova tra poco.';
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
hint,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (unauthorized)
FilledButton(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
child: const Text('VAI AL LOGIN'),
)
else
TextButton(
onPressed: () => ref.invalidate(teamsProvider),
child: const Text('RIPROVA'),
),
],
),
),
);
},
data: (teams) {
if (teams.isEmpty) {
return NoTeamOnboarding(theme: theme);

View File

@@ -2,12 +2,14 @@ import 'dart:async';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/score_provider.dart';
import '../../providers/score_sync_provider.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
@@ -74,9 +76,9 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
_youtubePoll?.cancel();
var attempts = 0;
_youtubePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
_youtubePoll = Timer.periodic(const Duration(seconds: 3), (_) async {
attempts++;
if (!mounted || attempts > 30) {
if (!mounted || attempts > 60) {
_youtubePoll?.cancel();
return;
}
@@ -150,14 +152,10 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// Tabellone via HTTP; WebSocket solo dalla camera (evita doppio connect controller/camera).
try {
await ref.read(websocketServiceProvider).connect(
sessionId: session.id,
deviceRole: 'controller',
);
} catch (_) {
// Tabellone locale; sync quando la camera si connette.
}
await ref.read(scoreSyncProvider).pullFromServer();
} catch (_) {}
}
String _connectivityLabel(List<ConnectivityResult> results) {
@@ -184,18 +182,22 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
ref.read(scoreProvider.notifier).reset(const ScoreState());
}
if (!mounted) return;
await ref.read(websocketServiceProvider).disconnect();
if (!mounted) return;
context.go('/session/${started.id}/camera');
try {
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: started.id, deviceRole: 'controller');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Diretta avviata ma sync limitata: $e')),
);
}
} on DioException catch (e) {
if (mounted) {
final msg = e.response?.data is Map
? (e.response!.data['error'] ?? e.response!.data['message'])?.toString()
: null;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
msg?.isNotEmpty == true ? 'Errore avvio diretta: $msg' : 'Errore avvio diretta: ${e.message}',
),
),
);
}
} catch (e) {
if (mounted) {

View File

@@ -270,7 +270,7 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium),
Text('720p · 30fps · 3 Mbps', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Bitrate fisso consigliato per reti instabili',

View File

@@ -19,32 +19,53 @@ class StreamingChannel {
return ok ?? false;
}
/// Dopo rotazione: riallinea preview/stream senza fermare RTMP.
static Future<void> syncOrientation() async {
await _channel.invokeMethod<void>('syncOrientation');
}
static Future<void> stopPreview() async {
await _channel.invokeMethod('stopPreview');
}
static Future<void> startStream({
required String rtmpUrl,
int targetBitrate = 2500000,
int targetBitrate = 3000000,
int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async {
await _channel.invokeMethod('startStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
});
}
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
static Future<void> resumeStream({
required String rtmpUrl,
int targetBitrate = 2500000,
int targetBitrate = 3000000,
int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async {
await _channel.invokeMethod('resumeStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
});
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme.
/// Preview camera nativa (OpenGL) integrata nel layout Flutter.
class NativeStreamingPreview extends StatelessWidget {
const NativeStreamingPreview({
super.key,
@@ -23,7 +23,7 @@ class NativeStreamingPreview extends StatelessWidget {
key: key,
viewType: 'match_live_tv/streaming_preview',
layoutDirection: TextDirection.ltr,
creationParamsCodec: StandardMessageCodec(),
creationParamsCodec: const StandardMessageCodec(),
),
if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel),
],

View File

@@ -1,7 +1,9 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../core/team_selection_storage.dart';
import '../shared/api_client.dart';
import '../shared/models/user.dart';
class AuthState {
@@ -72,6 +74,53 @@ class AuthNotifier extends StateNotifier<AuthState> {
);
}
/// Dopo restore: verifica token o rinnova con refresh (evita 401 su /teams).
Future<bool> validateOrRefreshSession() async {
if (!state.isAuthenticated) return false;
final client = ApiClient(accessToken: state.accessToken);
try {
final user = await client.me();
state = state.copyWith(user: user);
await AuthStorage.save(
user: user,
accessToken: state.accessToken!,
refreshToken: state.refreshToken!,
);
return true;
} on DioException catch (e) {
if (e.response?.statusCode != 401) {
return true;
}
} catch (_) {
return true;
}
return refreshSession();
}
Future<bool> refreshSession() async {
final refreshToken = state.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
logout();
return false;
}
try {
final client = ApiClient();
final result = await client.refreshSession(refreshToken);
setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
return true;
} catch (_) {
logout();
return false;
}
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading, clearError: true);
}

View File

@@ -44,7 +44,12 @@ class SessionNotifier extends StateNotifier<SessionState> {
SessionNotifier() : super(const SessionState());
void setSession(StreamSession session, {MatchModel? match}) {
state = state.copyWith(session: session, match: match);
final prevId = state.session?.id;
state = state.copyWith(
session: session,
match: match,
elapsed: prevId != null && prevId != session.id ? Duration.zero : state.elapsed,
);
}
void updateYoutubeLinks({
@@ -111,8 +116,6 @@ class SessionNotifier extends StateNotifier<SessionState> {
state = state.copyWith(cameraDevice: device);
return;
}
final startedAt = s.startedAt ??
((incoming == 'live' || incoming == 'reconnecting') ? DateTime.now() : null);
state = state.copyWith(
session: StreamSession(
id: s.id,
@@ -129,7 +132,7 @@ class SessionNotifier extends StateNotifier<SessionState> {
privacyStatus: s.privacyStatus,
qualityPreset: s.qualityPreset,
targetBitrate: s.targetBitrate,
startedAt: startedAt,
startedAt: s.startedAt,
disconnectionCount: s.disconnectionCount,
score: s.score,
devices: s.devices,

View File

@@ -21,8 +21,8 @@ class ApiClient {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiV1,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@@ -62,12 +62,18 @@ class ApiClient {
);
}
Future<AuthTokens> refresh(String refreshToken) async {
Future<({User user, AuthTokens tokens})> refreshSession(
String refreshToken,
) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
return AuthTokens.fromJson(response.data!);
final data = response.data!;
return (
user: User.fromJson(data['user'] as Map<String, dynamic>),
tokens: AuthTokens.fromJson(data),
);
}
Future<User> me() async {

View File

@@ -26,6 +26,7 @@ class WebsocketService {
ActionCable? _cable;
ActionChannel? _channel;
String? _sessionId;
Future<void>? _connectOp;
bool get isConnected => _cable != null && _channel != null;
@@ -35,6 +36,24 @@ class WebsocketService {
}) 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;
@@ -161,11 +180,20 @@ class WebsocketService {
}
Future<void> disconnect() async {
_channel?.unsubscribe();
final channel = _channel;
final cable = _cable;
_channel = null;
_cable?.disconnect();
_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);
}