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/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; 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); 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); } 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: 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, onShare: _showShareMenu, 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, onShare: _showShareMenu, 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.onShare, 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 onShare; 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(), 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), 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.onShare, 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 onShare; 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: [ 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: OutlinedButton( onPressed: onInterrupt, child: const Text('Interrompi', style: TextStyle(fontSize: 11)), ), ), const SizedBox(width: 8), Expanded( child: DestructiveCta( label: 'Chiudi', compact: true, onPressed: () => onCloseStream(), ), ), ], ), ], ), ), ], ); } }