Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
425 lines
12 KiB
Dart
425 lines
12 KiB
Dart
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)),
|
|
],
|
|
);
|
|
}
|
|
}
|