Files
MatchLiveTv/mobile/lib/features/camera_mode/camera_screen.dart
Emiliano Frascaro 1fb5cd5aa2 App: scelta canale YouTube MLTV/società, privacy e condivisione link.
Premium Full può forzare il canale piattaforma; visibilità non in elenco o privato;
share_url unificato per Match Live TV e YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:52:07 +02:00

632 lines
19 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 '../../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/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);
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);
}
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: 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,
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<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(),
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: [
CameraScoreControls(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
pointsTarget: pointsTarget,
),
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<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,
),
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(),
),
),
],
),
],
),
),
],
);
}
}