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 '../shared/live_score_actions.dart'; import '../../shared/api_client.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/camera_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; 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; @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()); } @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); ref.read(sessionProvider.notifier).setSession(session, match: match); ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera); if (session.score != null) { ref.read(scoreProvider.notifier).reset(session.score!); } // Ripresa dopo crash: la sessione resta connecting/live, riallineiamo lo stato. if (session.status == 'idle' || session.status == 'paused') { session = await client.startSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); } 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.rtmpIngestUrl != null) { await Future.delayed(const Duration(milliseconds: 500)); await StreamingChannel.startStream( rtmpUrl: fresh.rtmpIngestUrl!, targetBitrate: fresh.targetBitrate, targetFps: 30, ); if (mounted && fresh.canResumeBroadcast) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Diretta ripresa — stai di nuovo in onda'), 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)) ?? {}, ); setState(() { _bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000; _fps = (metrics['fps'] as num?)?.toInt() ?? 0; }); }); } 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); } void _syncScore() { ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); } Future _homePoint() async { ref.read(scoreProvider.notifier).incrementHome(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterPointChange( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onCloseSetConfirmed: () => _closeSetConfirmed(), ); } Future _awayPoint() async { ref.read(scoreProvider.notifier).incrementAway(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterPointChange( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onCloseSetConfirmed: () => _closeSetConfirmed(), ); } void _homeMinus() { ref.read(scoreProvider.notifier).decrementHome(); _syncScore(); } void _awayMinus() { ref.read(scoreProvider.notifier).decrementAway(); _syncScore(); } Future _closeSet() async { await LiveScoreActions(ref).requestCloseSet( context, onCloseSetConfirmed: () => _closeSetConfirmed(), ); } Future _closeSetConfirmed() async { ref.read(scoreProvider.notifier).closeSet(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterCloseSet( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onStopStream: _closeStreamPermanently, ); } Future _leaveSession() async { try { await StreamingChannel.stopStream(); } catch (_) {} try { await ref.read(websocketServiceProvider).disconnect(); } catch (_) {} if (mounted) context.go('/matches'); } /// Pausa temporanea: si può riprendere la stessa sessione. Future _interruptStream() async { try { await StreamingChannel.stopStream(); } catch (_) {} try { await ref.read(apiClientProvider).pauseSession(widget.sessionId); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Pausa sessione: $e')), ); } } await _leaveSession(); } /// 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(); _metricsSub?.cancel(); WakelockPlus.disable(); _unlockOrientation(); super.dispose(); } @override Widget build(BuildContext context) { final sessionState = ref.watch(sessionProvider); final score = ref.watch(scoreProvider); _trackScoreDelta(score); final match = sessionState.match; 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: [ const Positioned.fill( child: NativeStreamingPreview( key: _previewKey, showGrid: true, platformLabel: 'LIVE', ), ), if (isLandscape) _LandscapeOverlay( elapsed: sessionState.elapsed, batteryLevel: _batteryLevel, homeName: homeName, awayName: awayName, score: score, lastDelta: _lastDelta, networkType: _networkType, bitrateMbps: _bitrateMbps, fps: _fps, viewerCount: _viewerCount, onHomePoint: () => _homePoint(), onAwayPoint: () => _awayPoint(), onHomeMinus: _homeMinus, onAwayMinus: _awayMinus, onCloseSet: () => _closeSet(), onInterrupt: _interruptStream, 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, onHomePoint: () => _homePoint(), onAwayPoint: () => _awayPoint(), onHomeMinus: _homeMinus, onAwayMinus: _awayMinus, onCloseSet: () => _closeSet(), onInterrupt: _interruptStream, 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.onHomePoint, required this.onAwayPoint, required this.onHomeMinus, required this.onAwayMinus, required this.onCloseSet, required this.onInterrupt, 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 onHomePoint; final VoidCallback onAwayPoint; final VoidCallback onHomeMinus; final VoidCallback onAwayMinus; final VoidCallback onCloseSet; final VoidCallback onInterrupt; 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), const Spacer(), 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: [ CameraScoreControls( homeName: homeName, awayName: awayName, homePoints: score.homePoints, awayPoints: score.awayPoints, currentSet: score.currentSet, homeSets: score.homeSets, awaySets: score.awaySets, pointsTarget: pointsTarget, onHomePoint: onHomePoint, onAwayPoint: onAwayPoint, onHomeMinus: onHomeMinus, onAwayMinus: onAwayMinus, onCloseSet: onCloseSet, ), const SizedBox(height: 10), OutlinedButton( onPressed: onInterrupt, child: const Text('Interrompi (riprendibile)'), ), 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.onHomePoint, required this.onAwayPoint, required this.onHomeMinus, required this.onAwayMinus, required this.onCloseSet, required this.onInterrupt, 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 onHomePoint; final VoidCallback onAwayPoint; final VoidCallback onHomeMinus; final VoidCallback onAwayMinus; final VoidCallback onCloseSet; final VoidCallback onInterrupt; 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), 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: [ CameraScoreControls( compact: true, homeName: homeName, awayName: awayName, homePoints: score.homePoints, awayPoints: score.awayPoints, currentSet: score.currentSet, homeSets: score.homeSets, awaySets: score.awaySets, lastDelta: lastDelta, pointsTarget: pointsTarget, onHomePoint: onHomePoint, onAwayPoint: onAwayPoint, onHomeMinus: onHomeMinus, onAwayMinus: onAwayMinus, onCloseSet: onCloseSet, ), const SizedBox(height: 8), Row( children: [ Expanded( child: OutlinedButton( onPressed: onInterrupt, child: const Text('Interrompi', style: TextStyle(fontSize: 11)), ), ), const SizedBox(width: 8), Expanded( child: DestructiveCta( label: 'Chiudi', compact: true, onPressed: () => onCloseStream(), ), ), ], ), ], ), ), ], ); } }