Initial commit: monorepo Match Live TV.
Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
652
mobile/lib/features/camera_mode/camera_screen.dart
Normal file
652
mobile/lib/features/camera_mode/camera_screen.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
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<CameraScreen> createState() => _CameraScreenState();
|
||||
}
|
||||
|
||||
class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
static const _previewKey = ValueKey<String>('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<Map<String, dynamic>>? _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<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);
|
||||
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<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.rtmpIngestUrl != null) {
|
||||
await Future<void>.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<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)) ?? {},
|
||||
);
|
||||
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<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);
|
||||
}
|
||||
|
||||
void _syncScore() {
|
||||
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _closeSet() async {
|
||||
await LiveScoreActions(ref).requestCloseSet(
|
||||
context,
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<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();
|
||||
_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<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),
|
||||
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<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),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
134
mobile/lib/features/camera_mode/camera_screen_landscape.dart
Normal file
134
mobile/lib/features/camera_mode/camera_screen_landscape.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import '../../shared/widgets/live_badge.dart';
|
||||
import '../../shared/widgets/metric_card.dart';
|
||||
import '../../shared/widgets/score_overlay_bar.dart';
|
||||
import '../../platform/streaming_preview.dart';
|
||||
|
||||
class CameraScreenLandscape extends StatelessWidget {
|
||||
const CameraScreenLandscape({
|
||||
super.key,
|
||||
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,
|
||||
this.platformLabel = 'LIVE',
|
||||
required this.onStop,
|
||||
});
|
||||
|
||||
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 String platformLabel;
|
||||
final VoidCallback onStop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
NativeStreamingPreview(showGrid: false, platformLabel: platformLabel),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: LiveBadge(elapsed: elapsed, compact: true),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.battery_std, size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$batteryLevel%',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
child: ScoreOverlayBar(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet,
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets,
|
||||
compact: true,
|
||||
lastDelta: lastDelta,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 100,
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MetricCard(
|
||||
label: 'Segnale',
|
||||
value: networkType,
|
||||
icon: Icons.signal_cellular_alt,
|
||||
expand: false,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
MetricCard(
|
||||
label: 'Mbps',
|
||||
value: bitrateMbps.toStringAsFixed(1),
|
||||
icon: Icons.speed,
|
||||
expand: false,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
MetricCard(
|
||||
label: 'fps',
|
||||
value: '$fps',
|
||||
icon: Icons.movie,
|
||||
expand: false,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
MetricCard(
|
||||
label: 'Spett.',
|
||||
value: viewerCount > 0 ? '$viewerCount' : 'LIVE',
|
||||
icon: Icons.visibility,
|
||||
expand: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: DestructiveCta(
|
||||
label: 'INTERROMPI',
|
||||
compact: true,
|
||||
onPressed: onStop,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
mobile/lib/features/camera_mode/camera_screen_portrait.dart
Normal file
158
mobile/lib/features/camera_mode/camera_screen_portrait.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import '../../shared/widgets/live_badge.dart';
|
||||
import '../../shared/widgets/metric_card.dart';
|
||||
import '../../shared/widgets/score_overlay_bar.dart';
|
||||
import '../../platform/streaming_preview.dart';
|
||||
|
||||
class CameraScreenPortrait extends StatelessWidget {
|
||||
const CameraScreenPortrait({
|
||||
super.key,
|
||||
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.sessionId,
|
||||
this.platformLabel = 'LIVE',
|
||||
required this.onStop,
|
||||
});
|
||||
|
||||
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 String sessionId;
|
||||
final String platformLabel;
|
||||
final VoidCallback onStop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
LiveBadge(elapsed: elapsed),
|
||||
const Spacer(),
|
||||
const Icon(Icons.battery_std, size: 18, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text('$batteryLevel%', style: theme.textTheme.labelLarge),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: NativeStreamingPreview(
|
||||
showGrid: true,
|
||||
platformLabel: platformLabel,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ScoreOverlayBar(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet,
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
MetricCard(
|
||||
label: 'Segnale',
|
||||
value: networkType,
|
||||
icon: Icons.signal_cellular_alt,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
MetricCard(
|
||||
label: 'Mbps',
|
||||
value: bitrateMbps.toStringAsFixed(1),
|
||||
icon: Icons.speed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
MetricCard(label: 'fps', value: '$fps', icon: Icons.movie),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.play_circle, color: AppTheme.primaryRed),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(platformLabel, style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
viewerCount > 0
|
||||
? '$viewerCount spettatori'
|
||||
: 'IN DIRETTA',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
'Registrazione locale attiva: /sd/match_$sessionId',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DestructiveCta(label: 'INTERROMPI', onPressed: onStop),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user