Files
MatchLiveTv/mobile/lib/features/camera_mode/camera_screen.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

924 lines
30 KiB
Dart

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<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> {
static const _previewKey = ValueKey<String>('camera_streaming_preview');
Timer? _elapsedTimer;
Timer? _sessionSyncTimer;
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<Map<String, dynamic>>? _metricsSub;
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!),
);
} else {
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
}
});
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
unawaited(_syncSessionStartedAt());
});
_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());
});
}
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 {
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<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;
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<void>.delayed(const Duration(milliseconds: 300));
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);
if (fresh.score != null) {
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 = previewReady &&
fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen &&
fresh.status != 'paused' &&
(justStartedSession ||
fresh.status == 'connecting' ||
fresh.status == 'live' ||
fresh.status == 'reconnecting');
if (shouldAutoPublish) {
// Reset motore nativo (sessione precedente / RECONNECTING blocca startStream).
try {
await StreamingChannel.stopStream();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 400));
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(
content: Text('Diretta avviata — connessione al server…'),
duration: Duration(seconds: 3),
),
);
}
}
_metricsSub = StreamingChannel.metricsStream
.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e as Map))
.listen((event) {
if (event['type'] != 'metrics') return;
final metrics = Map<String, dynamic>.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<void> _readBattery() async {
final level = await Battery().batteryLevel;
if (mounted) setState(() => _batteryLevel = level);
}
Future<void> _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<void> _fetchStats() async {
try {
final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId);
if (mounted) setState(() => _viewerCount = stats.concurrentViewers);
} catch (_) {}
}
/// Il server imposta `started_at` solo al primo go_live; sincronizza per il timer LIVE.
Future<void> _syncSessionStartedAt() async {
final current = ref.read(sessionProvider).session;
if (current == null || current.startedAt != null || !current.isLive) return;
try {
final fresh = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
if (!mounted || fresh.startedAt == null) return;
ref.read(sessionProvider.notifier).setSession(
fresh,
match: ref.read(sessionProvider).match,
);
} catch (_) {}
}
Future<void> _lockOrientations() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
Future<void> _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<void> _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<void>(
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<void> _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<void> _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<void> _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<void> _applyRemoteResume() async {
if (_resumeInFlight) return;
final session = ref.read(sessionProvider).session;
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(
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<void> _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) {
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,
);
}
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<void> _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();
_sessionSyncTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_scorePullTimer?.cancel();
_metricsSub?.cancel();
unawaited(StreamingChannel.stopStream());
WakelockPlus.disable();
_unlockOrientation();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(
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 a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
return Scaffold(
backgroundColor: Colors.black,
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<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@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: [
// ── 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(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
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),
),
],
),
),
// ── 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),
),
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),
),
),
],
),
),
),
),
],
);
}
}
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<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> 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: IgnorePointer(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(),
),
),
],
),
],
),
),
],
);
}
}