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:
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal file
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user