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>
632 lines
19 KiB
Dart
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(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|