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:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

View File

@@ -0,0 +1,424 @@
import 'dart:async';
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 '../../app/theme.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../../providers/session_provider.dart';
import '../shared/live_score_actions.dart';
import '../../shared/widgets/end_stream_dialog.dart';
import '../../shared/api_client.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/connected_badge.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/screen_insets.dart';
import 'controller_landscape.dart';
import 'controller_portrait.dart';
class ControllerScreen extends ConsumerStatefulWidget {
const ControllerScreen({super.key, required this.sessionId});
final String sessionId;
@override
ConsumerState<ControllerScreen> createState() => _ControllerScreenState();
}
class _ControllerScreenState extends ConsumerState<ControllerScreen> {
Timer? _elapsedTimer;
Timer? _statsTimer;
@override
void initState() {
super.initState();
_lockLandscape();
_bootstrap();
_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!),
);
}
});
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
}
Future<void> _bootstrap() async {
try {
final client = ref.read(apiClientProvider);
final session = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session);
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: widget.sessionId, deviceRole: 'controller');
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore connessione sessione')),
);
}
}
}
Future<void> _fetchStats() async {
try {
final client = ref.read(apiClientProvider);
final stats = await client.youtubeStats(widget.sessionId);
ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers);
} catch (_) {}
}
Future<void> _lockLandscape() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
Future<void> _leaveSession() async {
try {
await ref.read(websocketServiceProvider).disconnect();
} catch (_) {}
if (mounted) context.go('/matches');
}
Future<void> _interruptStream() async {
try {
ref.read(websocketServiceProvider).sendCommand('pause_stream');
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa: $e')),
);
}
}
await _leaveSession();
}
Future<void> _closeStreamPermanently() async {
if (!mounted) return;
final ok = await confirmEndStream(context);
if (!ok || !mounted) return;
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: $e')),
);
}
}
await _leaveSession();
}
@override
void dispose() {
_elapsedTimer?.cancel();
_statsTimer?.cancel();
_unlockOrientation();
super.dispose();
}
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,
);
}
@override
Widget build(BuildContext context) {
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
final match = sessionState.match;
final isLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
final homeName = match?.teamName ?? 'CASA';
final awayName = match?.opponentName ?? 'OSPITI';
final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet);
final body = isLandscape
? ControllerLandscape(
homeName: homeName,
awayName: awayName,
score: score,
sessionState: sessionState,
pointsTarget: pointsTarget,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
)
: ControllerPortrait(
homeName: homeName,
awayName: awayName,
score: score,
sessionState: sessionState,
pointsTarget: pointsTarget,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
);
final inset = ScreenInsets.of(context);
return Scaffold(
backgroundColor: AppTheme.background,
body: Column(
children: [
if (!isLandscape)
Padding(
padding: EdgeInsets.fromLTRB(
inset.left,
inset.top,
inset.right,
8,
),
child: Row(
children: [
const MatchLiveWordmark(compact: true),
const Spacer(),
ConnectedBadge(connected: sessionState.connected),
],
),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: inset.left,
right: inset.right,
top: isLandscape ? inset.top : 0,
bottom: inset.bottom,
),
child: body,
),
),
],
),
);
}
}
/// Pulsante score +1 con stile gamepad.
class ScoreTapButton extends StatelessWidget {
const ScoreTapButton({
super.key,
required this.label,
required this.onTap,
this.large = false,
});
final String label;
final VoidCallback onTap;
final bool large;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(large ? 16 : 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(large ? 16 : 12),
child: Container(
width: large ? double.infinity : null,
padding: EdgeInsets.symmetric(
vertical: large ? 20 : 12,
horizontal: large ? 24 : 16,
),
child: Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
fontSize: large ? 28 : 18,
),
),
),
),
);
}
}
/// CTA gialla per azioni secondarie (PAUSA, CHIUDI SET).
class YellowActionButton extends StatelessWidget {
const YellowActionButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentYellow,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(color: Colors.black),
),
),
);
}
}
/// Footer metriche stream (timer, segnale, Mbps, batteria).
class StreamMetricsFooter extends StatelessWidget {
const StreamMetricsFooter({
super.key,
required this.elapsed,
required this.connected,
this.cameraDevice,
this.viewerCount,
this.compact = false,
});
final Duration elapsed;
final bool connected;
final dynamic cameraDevice;
final int? viewerCount;
final bool compact;
String _format(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final network = cameraDevice?.networkType ?? '';
final mbps = cameraDevice?.bitrateMbps != null
? cameraDevice.bitrateMbps.toStringAsFixed(1)
: '';
final battery = cameraDevice?.batteryLevel?.toString() ?? '';
return Container(
padding: EdgeInsets.all(compact ? 8 : 12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(compact ? 8 : 10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_MetricChip(icon: Icons.timer, label: _format(elapsed)),
_MetricChip(icon: Icons.signal_cellular_alt, label: network),
_MetricChip(icon: Icons.speed, label: '$mbps Mbps'),
_MetricChip(icon: Icons.battery_std, label: '$battery%'),
if (viewerCount != null)
_MetricChip(icon: Icons.visibility, label: '$viewerCount'),
ConnectedBadge(connected: connected, label: compact ? null : 'COLLEGATO'),
],
),
);
}
}
class _MetricChip extends StatelessWidget {
const _MetricChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 11)),
],
);
}
}