import 'dart:async'; import 'dart:math' as math; import 'package:battery_plus/battery_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../app/theme.dart'; import '../../platform/streaming_channel.dart'; import '../../platform/streaming_preview.dart'; import '../../providers/match_rules_provider.dart'; import '../../providers/score_provider.dart'; import '../../providers/score_sync_provider.dart'; import '../../shared/api_client.dart'; import '../../shared/regia_share.dart'; import '../../shared/watch_share.dart'; import '../../shared/models/score_state.dart'; import '../../providers/session_provider.dart'; import '../../shared/websocket_service.dart'; import '../../shared/widgets/camera_compact_metrics.dart'; import '../../shared/widgets/live_score_controls.dart'; import '../../shared/widgets/destructive_cta.dart'; import '../../shared/widgets/end_stream_dialog.dart'; import '../../shared/widgets/live_badge.dart'; import '../../shared/widgets/screen_insets.dart'; class CameraScreen extends ConsumerStatefulWidget { const CameraScreen({super.key, required this.sessionId}); final String sessionId; @override ConsumerState createState() => _CameraScreenState(); } class _CameraScreenState extends ConsumerState { static const _previewKey = ValueKey('camera_streaming_preview'); Timer? _elapsedTimer; Timer? _telemetryTimer; Timer? _statsTimer; Timer? _scorePullTimer; int _batteryLevel = 100; int? _lastDelta; int _prevHomePoints = 0; int _prevAwayPoints = 0; double _bitrateMbps = 0; int _fps = 0; String _networkType = '4G'; int _viewerCount = 0; StreamSubscription>? _metricsSub; Orientation? _lastOrientation; bool _pauseInFlight = false; bool _resumeInFlight = false; String? _lastStreamErrorShown; @override void initState() { super.initState(); WakelockPlus.enable(); _lockOrientations(); _bootstrap(); _readBattery(); _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { final session = ref.read(sessionProvider).session; if (session?.startedAt != null) { ref.read(sessionProvider.notifier).setElapsed( DateTime.now().difference(session!.startedAt!), ); } }); _telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry()); _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); _scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) { unawaited(ref.read(scoreSyncProvider).pullFromServer()); }); } @override void didChangeDependencies() { super.didChangeDependencies(); final orientation = MediaQuery.orientationOf(context); if (_lastOrientation != null && _lastOrientation != orientation) { WidgetsBinding.instance.addPostFrameCallback((_) async { await StreamingChannel.bindPreview(); }); } _lastOrientation = orientation; } Future _ensurePermissions() async { final camera = await Permission.camera.request(); final mic = await Permission.microphone.request(); if (camera.isGranted && mic.isGranted) return true; if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Servono permessi fotocamera e microfono per la diretta'), ), ); } return false; } Future _bootstrap() async { try { if (!await _ensurePermissions()) return; final client = ref.read(apiClientProvider); var session = await client.fetchSession(widget.sessionId); if (session.isTerminal) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Questa diretta è già terminata'), ), ); context.go('/matches'); } return; } final match = await client.fetchMatch(session.matchId); final statusAtOpen = session.status; ref.read(sessionProvider.notifier).setSession(session, match: match); if (session.score != null) { ref.read(scoreProvider.notifier).reset(session.score!); } // idle → startSession; wizard può aver già messo connecting prima della camera. var justStartedSession = false; if (session.status == 'idle') { session = await client.startSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); justStartedSession = true; } final ws = ref.read(websocketServiceProvider); await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera'); await Future.delayed(const Duration(milliseconds: 300)); await StreamingChannel.bindPreview(); final fresh = await client.fetchSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(fresh, match: match); if (fresh.score != null) { ref.read(scoreProvider.notifier).reset(fresh.score!); } // 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 && !wasPausedAtOpen && fresh.status != 'paused' && (justStartedSession || fresh.status == 'connecting' || fresh.status == 'live' || fresh.status == 'reconnecting'); if (shouldAutoPublish) { await Future.delayed(const Duration(milliseconds: 500)); 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 && (justStartedSession || fresh.status == 'connecting')) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Diretta avviata — connessione al server…'), duration: Duration(seconds: 3), ), ); } } _metricsSub = StreamingChannel.metricsStream .receiveBroadcastStream() .map((e) => Map.from(e as Map)) .listen((event) { if (event['type'] != 'metrics') return; final metrics = Map.from( (event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {}, ); final streamState = metrics['state'] as String?; final lastError = metrics['lastError'] as String?; setState(() { _bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000; _fps = (metrics['fps'] as num?)?.toInt() ?? 0; }); if (lastError != null && lastError.isNotEmpty && lastError != _lastStreamErrorShown && (streamState == 'ERROR' || streamState == 'RECONNECTING') && mounted) { _lastStreamErrorShown = lastError; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Connessione RTMP: $lastError'), duration: const Duration(seconds: 5), ), ); } }); } on PlatformException catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Errore streaming: ${e.message ?? e.code}')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Errore avvio camera: $e')), ); } } } Future _readBattery() async { final level = await Battery().batteryLevel; if (mounted) setState(() => _batteryLevel = level); } Future _sendTelemetry() async { try { await ref.read(apiClientProvider).postTelemetry( sessionId: widget.sessionId, deviceRole: 'camera', batteryLevel: _batteryLevel, networkType: _networkType, currentBitrate: (_bitrateMbps * 1_000_000).round(), fps: _fps, ); } catch (_) {} } Future _fetchStats() async { try { final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId); if (mounted) setState(() => _viewerCount = stats.concurrentViewers); } catch (_) {} } Future _lockOrientations() async { await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, DeviceOrientation.portraitUp, ]); } Future _unlockOrientation() async { await SystemChrome.setPreferredOrientations(DeviceOrientation.values); } Future _shareRegia() async { final match = ref.read(sessionProvider).match; await shareRegiaLink( context, ref, sessionId: widget.sessionId, shareSubject: 'Regia — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}', ); } Future _shareWatch() async { final session = ref.read(sessionProvider).session; final match = ref.read(sessionProvider).match; if (session == null) return; await shareWatchLink( context, session: session, title: 'Diretta — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}', ); } void _showShareMenu() { final session = ref.read(sessionProvider).session; if (session == null) return; final isYoutube = session.platform == 'youtube'; showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.live_tv), title: Text(isYoutube ? 'Condividi link YouTube' : 'Condividi link diretta'), onTap: () { Navigator.pop(ctx); _shareWatch(); }, ), ListTile( leading: const Icon(Icons.scoreboard), title: const Text('Condividi link regia (punteggio)'), onTap: () { Navigator.pop(ctx); _shareRegia(); }, ), ], ), ), ); } Future _leaveSession() async { try { await StreamingChannel.stopStream(); } catch (_) {} try { await ref.read(websocketServiceProvider).disconnect(); } catch (_) {} if (mounted) context.go('/matches'); } /// Pausa temporanea: il server manda la copertina, la camera resta aperta. Future _pauseStream() async { if (_pauseInFlight) return; _pauseInFlight = true; try { final match = ref.read(sessionProvider).match; final session = await ref.read(apiClientProvider).pauseSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); await StreamingChannel.pauseStream(); if (mounted) { setState(() { _bitrateMbps = 0; _fps = 0; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Diretta in pausa — gli spettatori vedono la copertina'), duration: Duration(seconds: 3), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Pausa sessione: $e')), ); } } finally { _pauseInFlight = false; } } Future _applyRemotePause() async { try { await StreamingChannel.pauseStream(); if (mounted) { setState(() { _bitrateMbps = 0; _fps = 0; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Pausa dalla regia — trasmissione fermata'), duration: Duration(seconds: 3), ), ); } } catch (_) {} } Future _applyRemoteResume() async { if (_resumeInFlight) return; final session = ref.read(sessionProvider).session; if (session == null || session.rtmpIngestUrl == null) return; _resumeInFlight = true; try { await StreamingChannel.resumeStream( rtmpUrl: session.rtmpIngestUrl!, targetBitrate: session.targetBitrate, targetFps: 30, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Ripresa dalla regia — di nuovo in onda'), duration: Duration(seconds: 3), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Ripresa RTMP: $e')), ); } } finally { _resumeInFlight = false; } } Future _resumeStream() async { if (_resumeInFlight) return; _resumeInFlight = true; try { final match = ref.read(sessionProvider).match; var session = await ref.read(apiClientProvider).resumeSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); if (session.rtmpIngestUrl != null) { await StreamingChannel.resumeStream( rtmpUrl: session.rtmpIngestUrl!, targetBitrate: session.targetBitrate, targetFps: 30, ); } session = await ref.read(apiClientProvider).fetchSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Diretta ripresa — di nuovo in onda'), duration: Duration(seconds: 3), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Ripresa diretta: $e')), ); } } finally { _resumeInFlight = false; } } /// Chiusura definitiva: sessione ended, pagina web senza player. Future _closeStreamPermanently() async { if (!mounted) return; final ok = await confirmEndStream(context); if (!ok || !mounted) return; try { await StreamingChannel.stopStream(); } catch (_) {} try { ref.read(websocketServiceProvider).sendCommand('stop_stream'); } catch (_) {} try { await ref.read(apiClientProvider).stopSession(widget.sessionId); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Chiusura sessione: $e')), ); } } await _leaveSession(); } void _trackScoreDelta(ScoreState score) { if (score.homePoints != _prevHomePoints) { _lastDelta = score.homePoints - _prevHomePoints; } else if (score.awayPoints != _prevAwayPoints) { _lastDelta = score.awayPoints - _prevAwayPoints; } _prevHomePoints = score.homePoints; _prevAwayPoints = score.awayPoints; } @override void dispose() { _elapsedTimer?.cancel(); _telemetryTimer?.cancel(); _statsTimer?.cancel(); _scorePullTimer?.cancel(); _metricsSub?.cancel(); WakelockPlus.disable(); _unlockOrientation(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen( sessionProvider.select((s) => s.session?.status), (previous, next) { if (next == 'paused' && previous != 'paused' && !_pauseInFlight) { _applyRemotePause(); } if (previous == 'paused' && (next == 'connecting' || next == 'live') && !_resumeInFlight) { _applyRemoteResume(); } if (next == 'ended' && previous != 'ended' && mounted) { _leaveSession(); } }, ); final sessionState = ref.watch(sessionProvider); final score = ref.watch(scoreProvider); _trackScoreDelta(score); final match = sessionState.match; final sessionStatus = sessionState.session?.status ?? 'live'; final isPaused = sessionStatus == 'paused'; final homeName = match?.teamName ?? 'HOME'; final awayName = match?.opponentName ?? 'AWAY'; final isLandscape = 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. return Scaffold( backgroundColor: isLandscape ? Colors.black : AppTheme.background, body: Stack( fit: StackFit.expand, children: [ Positioned.fill( child: NativeStreamingPreview( key: _previewKey, showGrid: true, platformLabel: isPaused ? 'PAUSA' : 'LIVE', ), ), if (isPaused) Positioned( left: 20, right: 20, bottom: isLandscape ? 100 : 200, child: Center( child: FilledButton.icon( onPressed: _resumeStream, icon: const Icon(Icons.play_arrow, size: 28), label: const Text( 'RIPRENDI DIRETTA', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), style: FilledButton.styleFrom( backgroundColor: AppTheme.primaryRed, padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), ), ), ), ), if (isLandscape) _LandscapeOverlay( elapsed: sessionState.elapsed, batteryLevel: _batteryLevel, homeName: homeName, awayName: awayName, score: score, lastDelta: _lastDelta, networkType: _networkType, bitrateMbps: _bitrateMbps, fps: _fps, viewerCount: _viewerCount, onShare: _showShareMenu, isPaused: isPaused, onPause: _pauseStream, onResume: _resumeStream, onCloseStream: _closeStreamPermanently, pointsTarget: pointsTarget, ) else _PortraitOverlay( elapsed: sessionState.elapsed, batteryLevel: _batteryLevel, homeName: homeName, awayName: awayName, score: score, networkType: _networkType, bitrateMbps: _bitrateMbps, fps: _fps, viewerCount: _viewerCount, onShare: _showShareMenu, isPaused: isPaused, onPause: _pauseStream, onResume: _resumeStream, onCloseStream: _closeStreamPermanently, pointsTarget: pointsTarget, ), ], ), ); } } class _PortraitOverlay extends StatelessWidget { const _PortraitOverlay({ required this.elapsed, required this.batteryLevel, required this.homeName, required this.awayName, required this.score, required this.networkType, required this.bitrateMbps, required this.fps, required this.viewerCount, required this.onShare, required this.isPaused, required this.onPause, required this.onResume, required this.onCloseStream, required this.pointsTarget, }); final Duration elapsed; final int batteryLevel; final String homeName; final String awayName; final ScoreState score; final String networkType; final double bitrateMbps; final int fps; final int viewerCount; final VoidCallback onShare; final bool isPaused; final Future Function() onPause; final Future Function() onResume; final Future Function() onCloseStream; final int pointsTarget; @override Widget build(BuildContext context) { final inset = ScreenInsets.cameraOverlay(context); return Column( children: [ Padding( 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, fps: fps, batteryLevel: batteryLevel, viewerCount: viewerCount > 0 ? viewerCount : null, ), ], ), ), 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, ), const SizedBox(height: 10), OutlinedButton.icon( onPressed: onShare, icon: const Icon(Icons.share, size: 18), label: const Text('Condividi link'), ), 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(), ), ], ), ), ], ); } } class _LandscapeOverlay extends StatelessWidget { const _LandscapeOverlay({ required this.elapsed, required this.batteryLevel, required this.homeName, required this.awayName, required this.score, required this.lastDelta, required this.networkType, required this.bitrateMbps, required this.fps, required this.viewerCount, required this.onShare, required this.isPaused, required this.onPause, required this.onResume, required this.onCloseStream, required this.pointsTarget, }); final Duration elapsed; final int batteryLevel; final String homeName; final String awayName; final ScoreState score; final int? lastDelta; final String networkType; final double bitrateMbps; final int fps; final int viewerCount; final VoidCallback onShare; final bool isPaused; final Future Function() onPause; final Future Function() onResume; final Future Function() onCloseStream; final int pointsTarget; @override Widget build(BuildContext context) { final inset = ScreenInsets.cameraOverlay(context); return Column( children: [ Padding( padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6), child: Row( children: [ LiveBadge(elapsed: elapsed, compact: true, paused: isPaused), const Spacer(), CameraCompactMetrics( networkType: networkType, bitrateMbps: bitrateMbps, fps: fps, batteryLevel: batteryLevel, viewerCount: viewerCount > 0 ? viewerCount : null, light: true, ), ], ), ), const Expanded(child: SizedBox.expand()), Container( color: Colors.black.withValues(alpha: 0.82), padding: EdgeInsets.fromLTRB( inset.left, 10, inset.right, math.max(inset.bottom, 12), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ LiveScoreControls( compact: true, homeName: homeName, awayName: awayName, lastDelta: lastDelta, pointsTarget: pointsTarget, onStopStream: onCloseStream, ), const SizedBox(height: 8), Row( children: [ IconButton( onPressed: onShare, icon: const Icon(Icons.share, color: Colors.white), tooltip: 'Condividi link', ), Expanded( child: isPaused ? FilledButton( onPressed: onResume, child: const Text('Riprendi', style: TextStyle(fontSize: 11)), ) : OutlinedButton( onPressed: onPause, child: const Text('Pausa', style: TextStyle(fontSize: 11)), ), ), const SizedBox(width: 8), Expanded( child: DestructiveCta( label: 'Chiudi', compact: true, onPressed: () => onCloseStream(), ), ), ], ), ], ), ), ], ); } }