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,90 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../shared/models/user.dart';
class AuthState {
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.loading = false,
this.error,
});
final User? user;
final String? accessToken;
final String? refreshToken;
final bool loading;
final String? error;
bool get isAuthenticated =>
accessToken != null && accessToken!.isNotEmpty && user != null;
AuthState copyWith({
User? user,
String? accessToken,
String? refreshToken,
bool? loading,
String? error,
bool clearError = false,
}) {
return AuthState(
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
void setSession({
required User user,
required String accessToken,
required String refreshToken,
}) {
state = AuthState(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
loading: false,
);
AuthStorage.save(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
Future<void> restoreSession() async {
final stored = await AuthStorage.load();
if (stored == null) return;
state = AuthState(
user: stored.user,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
loading: false,
);
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading, clearError: true);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void logout() {
state = const AuthState();
AuthStorage.clear();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/scoring/match_scoring_rules.dart';
import 'session_provider.dart';
final matchScoringRulesProvider = Provider<MatchScoringRules>((ref) {
final match = ref.watch(sessionProvider).match;
if (match == null) {
return const MatchScoringRules(setsToWin: 3);
}
return MatchScoringRules.fromMatch(match);
});

View File

@@ -0,0 +1,77 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/models/score_state.dart';
class ScoreNotifier extends StateNotifier<ScoreState> {
ScoreNotifier() : super(const ScoreState());
void reset(ScoreState score) {
state = score;
}
void applyRemote(ScoreState score) {
state = score;
}
void incrementHome() {
state = state.copyWith(homePoints: state.homePoints + 1);
}
void incrementAway() {
state = state.copyWith(awayPoints: state.awayPoints + 1);
}
void decrementHome() {
if (state.homePoints > 0) {
state = state.copyWith(homePoints: state.homePoints - 1);
}
}
void decrementAway() {
if (state.awayPoints > 0) {
state = state.copyWith(awayPoints: state.awayPoints - 1);
}
}
void closeSet() {
final homeWon = state.homePoints > state.awayPoints;
final partials = List<SetPartial>.from(state.setPartials);
if (state.homePoints > 0 || state.awayPoints > 0) {
partials.add(SetPartial(
set: state.currentSet,
home: state.homePoints,
away: state.awayPoints,
));
}
state = state.copyWith(
homeSets: homeWon ? state.homeSets + 1 : state.homeSets,
awaySets: homeWon ? state.awaySets : state.awaySets + 1,
homePoints: 0,
awayPoints: 0,
currentSet: state.currentSet + 1,
setPartials: partials,
timeoutHome: false,
timeoutAway: false,
);
}
void timeoutHome() {
state = state.copyWith(timeoutHome: true);
}
void timeoutAway() {
state = state.copyWith(timeoutAway: true);
}
void setTimeoutHome(bool value) {
state = state.copyWith(timeoutHome: value);
}
void setTimeoutAway(bool value) {
state = state.copyWith(timeoutAway: value);
}
}
final scoreProvider = StateNotifierProvider<ScoreNotifier, ScoreState>(
(ref) => ScoreNotifier(),
);

View File

@@ -0,0 +1,165 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/models/device_state.dart';
import '../shared/models/match.dart';
import '../shared/models/stream_session.dart';
enum DeviceRole { camera, controller }
class SessionState {
const SessionState({
this.session,
this.match,
this.connected = false,
this.deviceRole = DeviceRole.controller,
this.cameraDevice,
this.elapsed = Duration.zero,
this.viewerCount = 0,
this.loading = false,
this.error,
});
final StreamSession? session;
final MatchModel? match;
final bool connected;
final DeviceRole deviceRole;
final DeviceStateModel? cameraDevice;
final Duration elapsed;
final int viewerCount;
final bool loading;
final String? error;
SessionState copyWith({
StreamSession? session,
MatchModel? match,
bool? connected,
DeviceRole? deviceRole,
DeviceStateModel? cameraDevice,
Duration? elapsed,
int? viewerCount,
bool? loading,
String? error,
bool clearError = false,
}) {
return SessionState(
session: session ?? this.session,
match: match ?? this.match,
connected: connected ?? this.connected,
deviceRole: deviceRole ?? this.deviceRole,
cameraDevice: cameraDevice ?? this.cameraDevice,
elapsed: elapsed ?? this.elapsed,
viewerCount: viewerCount ?? this.viewerCount,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
class SessionNotifier extends StateNotifier<SessionState> {
SessionNotifier() : super(const SessionState());
void setSession(StreamSession session, {MatchModel? match}) {
state = state.copyWith(
session: session,
match: match,
clearError: true,
);
}
void setMatch(MatchModel match) {
state = state.copyWith(match: match);
}
void setConnected(bool connected) {
state = state.copyWith(connected: connected);
}
void setDeviceRole(DeviceRole role) {
state = state.copyWith(deviceRole: role);
}
void updateDeviceState(DeviceStateModel device) {
if (device.deviceRole == 'camera') {
state = state.copyWith(cameraDevice: device);
}
if (device.status != null && state.session != null) {
state = state.copyWith(
session: StreamSession(
id: state.session!.id,
matchId: state.session!.matchId,
status: device.status!,
platform: state.session!.platform,
rtmpIngestUrl: state.session!.rtmpIngestUrl,
youtubeBroadcastId: state.session!.youtubeBroadcastId,
privacyStatus: state.session!.privacyStatus,
qualityPreset: state.session!.qualityPreset,
targetBitrate: state.session!.targetBitrate,
startedAt: state.session!.startedAt,
disconnectionCount: state.session!.disconnectionCount,
score: state.session!.score,
devices: state.session!.devices,
),
);
}
}
void setElapsed(Duration elapsed) {
state = state.copyWith(elapsed: elapsed);
}
void setViewerCount(int count) {
state = state.copyWith(viewerCount: count);
}
void applyCommand(String action) {
final session = state.session;
if (session == null) return;
String newStatus = session.status;
switch (action) {
case 'start_stream':
newStatus = 'live';
break;
case 'pause_stream':
newStatus = 'paused';
break;
case 'stop_stream':
newStatus = 'ended';
break;
}
state = state.copyWith(
session: StreamSession(
id: session.id,
matchId: session.matchId,
status: newStatus,
platform: session.platform,
rtmpIngestUrl: session.rtmpIngestUrl,
youtubeBroadcastId: session.youtubeBroadcastId,
privacyStatus: session.privacyStatus,
qualityPreset: session.qualityPreset,
targetBitrate: session.targetBitrate,
startedAt: session.startedAt,
disconnectionCount: session.disconnectionCount,
score: session.score,
devices: session.devices,
),
);
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void reset() {
state = const SessionState();
}
}
final sessionProvider = StateNotifierProvider<SessionNotifier, SessionState>(
(ref) => SessionNotifier(),
);