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>
This commit is contained in:
@@ -56,7 +56,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
int _viewerCount = 0;
|
||||
|
||||
StreamSubscription<Map<String, dynamic>>? _metricsSub;
|
||||
Orientation? _lastOrientation;
|
||||
bool _pauseInFlight = false;
|
||||
bool _resumeInFlight = false;
|
||||
String? _lastStreamErrorShown;
|
||||
@@ -74,6 +73,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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), (_) {
|
||||
@@ -86,16 +87,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final orientation = MediaQuery.orientationOf(context);
|
||||
if (_lastOrientation != null && _lastOrientation != orientation) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await StreamingChannel.bindPreview();
|
||||
});
|
||||
}
|
||||
_lastOrientation = orientation;
|
||||
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 {
|
||||
@@ -112,6 +110,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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;
|
||||
@@ -150,7 +157,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await StreamingChannel.bindPreview();
|
||||
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);
|
||||
@@ -158,10 +175,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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 = fresh.rtmpIngestUrl != null &&
|
||||
final shouldAutoPublish = previewReady &&
|
||||
fresh.rtmpIngestUrl != null &&
|
||||
!wasPausedAtOpen &&
|
||||
fresh.status != 'paused' &&
|
||||
(justStartedSession ||
|
||||
@@ -174,21 +194,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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) 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(
|
||||
@@ -415,10 +430,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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(
|
||||
@@ -449,10 +470,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,9 +584,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
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.
|
||||
// Preview a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
|
||||
return Scaffold(
|
||||
backgroundColor: isLandscape ? Colors.black : AppTheme.background,
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -672,20 +698,21 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
@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: [
|
||||
Padding(
|
||||
// ── 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(),
|
||||
IconButton(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: 'Condividi link',
|
||||
),
|
||||
CameraCompactMetrics(
|
||||
networkType: networkType,
|
||||
bitrateMbps: bitrateMbps,
|
||||
@@ -693,52 +720,84 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
// ── 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),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text('Condividi link'),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -806,7 +865,7 @@ class _LandscapeOverlay extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.expand()),
|
||||
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
|
||||
Reference in New Issue
Block a user