Il motore Android ignorava startStream in CONNECTING/RECONNECTING; la camera ora resetta prima di pubblicare. YouTube relay parte solo con overlay attivo. Co-authored-by: Cursor <cursoragent@cursor.com>
846 lines
26 KiB
Dart
846 lines
26 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? _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;
|
|
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<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<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));
|
|
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) {
|
|
// Reset motore nativo (sessione precedente / RECONNECTING blocca startStream).
|
|
try {
|
|
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 && (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 (_) {}
|
|
}
|
|
|
|
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 {
|
|
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<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) {
|
|
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<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();
|
|
_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 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<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, 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<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: 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(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|