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,37 @@
class DeviceStateModel {
const DeviceStateModel({
this.deviceRole = 'camera',
this.batteryLevel,
this.networkType,
this.signalStrength,
this.currentBitrate,
this.targetBitrate,
this.fps,
this.status,
});
final String deviceRole;
final int? batteryLevel;
final String? networkType;
final int? signalStrength;
final int? currentBitrate;
final int? targetBitrate;
final int? fps;
final String? status;
factory DeviceStateModel.fromJson(Map<String, dynamic> json) {
return DeviceStateModel(
deviceRole: json['device_role'] as String? ?? 'camera',
batteryLevel: json['battery'] as int? ?? json['battery_level'] as int?,
networkType: json['network'] as String? ?? json['network_type'] as String?,
signalStrength: json['signal_strength'] as int?,
currentBitrate: json['bitrate'] as int? ?? json['current_bitrate'] as int?,
targetBitrate: json['target_bitrate'] as int?,
fps: json['fps'] as int?,
status: json['status'] as String?,
);
}
double get bitrateMbps =>
currentBitrate != null ? currentBitrate! / 1_000_000 : 0;
}

View File

@@ -0,0 +1,81 @@
class MatchModel {
const MatchModel({
required this.id,
required this.teamId,
required this.teamName,
required this.opponentName,
this.location,
this.scheduledAt,
this.sport = 'volleyball',
this.setsToWin = 3,
this.scoringRules,
this.rosterNumbers = const [],
this.category,
this.phase,
this.activeSessionId,
this.activeSessionStatus,
});
final String id;
final String teamId;
final String teamName;
final String opponentName;
final String? location;
final DateTime? scheduledAt;
final String sport;
final int setsToWin;
final Map<String, dynamic>? scoringRules;
final List<int> rosterNumbers;
final String? category;
final String? phase;
final String? activeSessionId;
final String? activeSessionStatus;
/// Sessione avviata (o in ripresa) — si può rientrare in camera senza rifare il wizard.
bool get canResumeCamera {
const resumable = {'connecting', 'live', 'reconnecting', 'paused'};
return activeSessionId != null &&
activeSessionStatus != null &&
resumable.contains(activeSessionStatus);
}
bool get hasActiveSession => activeSessionId != null;
factory MatchModel.fromJson(Map<String, dynamic> json) {
return MatchModel(
id: json['id'] as String,
teamId: json['team_id'] as String,
teamName: json['team_name'] as String? ?? '',
opponentName: json['opponent_name'] as String,
location: json['location'] as String?,
scheduledAt: json['scheduled_at'] != null
? DateTime.tryParse(json['scheduled_at'] as String)
: null,
sport: json['sport'] as String? ?? 'volleyball',
setsToWin: json['sets_to_win'] as int? ?? 3,
scoringRules: json['scoring_rules'] is Map
? Map<String, dynamic>.from(json['scoring_rules'] as Map)
: null,
rosterNumbers: (json['roster_numbers'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
category: json['category'] as String?,
phase: json['phase'] as String?,
activeSessionId: json['active_session_id'] as String?,
activeSessionStatus: json['active_session_status'] as String?,
);
}
Map<String, dynamic> toJson() => {
'opponent_name': opponentName,
'location': location,
'scheduled_at': scheduledAt?.toIso8601String(),
'sport': sport,
'sets_to_win': setsToWin,
if (scoringRules != null) 'scoring_rules': scoringRules,
'roster_numbers': rosterNumbers,
'category': category,
'phase': phase,
};
}

View File

@@ -0,0 +1,35 @@
class RecordingItem {
const RecordingItem({
required this.id,
required this.sessionId,
required this.opponentName,
required this.teamName,
this.endedAt,
this.replayUrl,
this.expiresAt,
});
final String id;
final String sessionId;
final String opponentName;
final String teamName;
final DateTime? endedAt;
final String? replayUrl;
final DateTime? expiresAt;
factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem(
id: json['id'] as String,
sessionId: json['session_id'] as String,
opponentName: json['opponent_name'] as String,
teamName: json['team_name'] as String,
endedAt: json['ended_at'] != null
? DateTime.parse(json['ended_at'] as String)
: null,
replayUrl: json['replay_url'] as String?,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,
);
}
}

View File

@@ -0,0 +1,100 @@
class SetPartial {
const SetPartial({
required this.set,
required this.home,
required this.away,
});
final int set;
final int home;
final int away;
factory SetPartial.fromJson(Map<String, dynamic> json) {
return SetPartial(
set: json['set'] as int? ?? 1,
home: json['home'] as int? ?? 0,
away: json['away'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'set': set,
'home': home,
'away': away,
};
}
class ScoreState {
const ScoreState({
this.homeSets = 0,
this.awaySets = 0,
this.homePoints = 0,
this.awayPoints = 0,
this.currentSet = 1,
this.setPartials = const [],
this.timeoutHome = false,
this.timeoutAway = false,
});
final int homeSets;
final int awaySets;
final int homePoints;
final int awayPoints;
final int currentSet;
final List<SetPartial> setPartials;
final bool timeoutHome;
final bool timeoutAway;
factory ScoreState.fromJson(Map<String, dynamic> json) {
final rawPartials = json['set_partials'];
final partials = rawPartials is List
? rawPartials
.whereType<Map>()
.map((e) => SetPartial.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <SetPartial>[];
return ScoreState(
homeSets: json['home_sets'] as int? ?? 0,
awaySets: json['away_sets'] as int? ?? 0,
homePoints: json['home_points'] as int? ?? 0,
awayPoints: json['away_points'] as int? ?? 0,
currentSet: json['current_set'] as int? ?? 1,
setPartials: partials,
timeoutHome: json['timeout_home'] as bool? ?? false,
timeoutAway: json['timeout_away'] as bool? ?? false,
);
}
Map<String, dynamic> toCablePayload() => {
'type': 'score_update',
'home_sets': homeSets,
'away_sets': awaySets,
'home_points': homePoints,
'away_points': awayPoints,
'current_set': currentSet,
'set_partials': setPartials.map((p) => p.toJson()).toList(),
};
ScoreState copyWith({
int? homeSets,
int? awaySets,
int? homePoints,
int? awayPoints,
int? currentSet,
List<SetPartial>? setPartials,
bool? timeoutHome,
bool? timeoutAway,
}) {
return ScoreState(
homeSets: homeSets ?? this.homeSets,
awaySets: awaySets ?? this.awaySets,
homePoints: homePoints ?? this.homePoints,
awayPoints: awayPoints ?? this.awayPoints,
currentSet: currentSet ?? this.currentSet,
setPartials: setPartials ?? this.setPartials,
timeoutHome: timeoutHome ?? this.timeoutHome,
timeoutAway: timeoutAway ?? this.timeoutAway,
);
}
}

View File

@@ -0,0 +1,129 @@
import 'device_state.dart';
import 'score_state.dart';
class StreamSession {
const StreamSession({
required this.id,
required this.matchId,
required this.status,
this.platform = 'matchlivetv',
this.rtmpIngestUrl,
this.hlsPlaybackUrl,
this.watchPageUrl,
this.youtubeBroadcastId,
this.privacyStatus = 'unlisted',
this.qualityPreset = '720p_30_2.5mbps',
this.targetBitrate = 2500000,
this.startedAt,
this.disconnectionCount = 0,
this.score,
this.devices = const [],
});
final String id;
final String matchId;
final String status;
final String platform;
final String? rtmpIngestUrl;
final String? hlsPlaybackUrl;
final String? watchPageUrl;
final String? youtubeBroadcastId;
final String privacyStatus;
final String qualityPreset;
final int targetBitrate;
final DateTime? startedAt;
final int disconnectionCount;
final ScoreState? score;
final List<DeviceStateModel> devices;
bool get isLive => status == 'live' || status == 'reconnecting';
bool get isTerminal => status == 'ended' || status == 'error';
bool get canResumeBroadcast =>
status == 'connecting' ||
status == 'live' ||
status == 'reconnecting' ||
status == 'paused';
factory StreamSession.fromJson(Map<String, dynamic> json) {
return StreamSession(
id: json['id'] as String,
matchId: json['match_id'] as String,
status: json['status'] as String? ?? 'idle',
platform: json['platform'] as String? ?? 'matchlivetv',
rtmpIngestUrl: json['rtmp_ingest_url'] as String?,
hlsPlaybackUrl: json['hls_playback_url'] as String?,
watchPageUrl: json['watch_page_url'] as String?,
youtubeBroadcastId: json['youtube_broadcast_id'] as String?,
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps',
targetBitrate: json['target_bitrate'] as int? ?? 2500000,
startedAt: json['started_at'] != null
? DateTime.tryParse(json['started_at'] as String)
: null,
disconnectionCount: json['disconnection_count'] as int? ?? 0,
score: json['score'] != null
? ScoreState.fromJson(json['score'] as Map<String, dynamic>)
: null,
devices: (json['devices'] as List<dynamic>?)
?.map((e) => DeviceStateModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}
}
class PairingTokenResponse {
const PairingTokenResponse({
required this.pairingToken,
required this.expiresAt,
required this.qrPayload,
});
final String pairingToken;
final DateTime expiresAt;
final Map<String, dynamic> qrPayload;
factory PairingTokenResponse.fromJson(Map<String, dynamic> json) {
return PairingTokenResponse(
pairingToken: json['pairing_token'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String),
qrPayload: json['qr_payload'] as Map<String, dynamic>,
);
}
}
class NetworkTestResult {
const NetworkTestResult({
required this.ready,
required this.targetUploadMbps,
});
final bool ready;
final double targetUploadMbps;
factory NetworkTestResult.fromJson(Map<String, dynamic> json) {
return NetworkTestResult(
ready: json['ready'] as bool? ?? false,
targetUploadMbps: (json['target_upload_mbps'] as num?)?.toDouble() ?? 2.5,
);
}
}
class YoutubeStats {
const YoutubeStats({
required this.concurrentViewers,
required this.live,
});
final int concurrentViewers;
final bool live;
factory YoutubeStats.fromJson(Map<String, dynamic> json) {
return YoutubeStats(
concurrentViewers: json['concurrent_viewers'] as int? ?? 0,
live: json['live'] as bool? ?? false,
);
}
}

View File

@@ -0,0 +1,96 @@
class Team {
const Team({
required this.id,
required this.name,
required this.sport,
this.logoUrl,
this.youtubeConnected = false,
this.planSlug = 'free',
this.planName = 'Free',
this.premiumActive = false,
this.premiumFull = false,
this.billingUrl,
this.staffManageUrl,
this.maxStaff = 2,
this.staffUsed = 0,
this.maxStaffTransmission,
this.maxStaffRegia,
this.staffTransmissionUsed = 0,
this.staffRegiaUsed = 0,
this.concurrentStreamsUsed = 0,
this.concurrentStreamsLimit,
this.recordingsEnabled = false,
this.phoneDownloadEnabled = false,
this.youtubeEnabled = false,
this.youtubeMode,
});
final String id;
final String name;
final String sport;
final String? logoUrl;
final bool youtubeConnected;
final String planSlug;
final String planName;
final bool premiumActive;
final bool premiumFull;
final String? billingUrl;
final String? staffManageUrl;
final int maxStaff;
final int staffUsed;
final int? maxStaffTransmission;
final int? maxStaffRegia;
final int staffTransmissionUsed;
final int staffRegiaUsed;
final int concurrentStreamsUsed;
final int? concurrentStreamsLimit;
final bool recordingsEnabled;
final bool phoneDownloadEnabled;
final bool youtubeEnabled;
final String? youtubeMode;
bool get canUseYoutube => premiumFull && youtubeEnabled;
bool get canUseRecordings => recordingsEnabled;
bool get canDownloadOnPhone => phoneDownloadEnabled;
String get staffLimitLabel {
if (maxStaffTransmission == null && maxStaffRegia == null) {
return 'illimitato';
}
final tx = maxStaffTransmission == null
? 'tx ∞'
: 'tx $staffTransmissionUsed/$maxStaffTransmission';
final rg = maxStaffRegia == null
? 'rg ∞'
: 'rg $staffRegiaUsed/$maxStaffRegia';
return '$tx · $rg';
}
factory Team.fromJson(Map<String, dynamic> json) {
return Team(
id: json['id'] as String,
name: json['name'] as String,
sport: json['sport'] as String? ?? 'volleyball',
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',
planName: json['plan_name'] as String? ?? 'Free',
premiumActive: json['premium_active'] as bool? ?? false,
premiumFull: json['premium_full'] as bool? ?? false,
billingUrl: json['billing_url'] as String?,
staffManageUrl: json['staff_manage_url'] as String?,
maxStaff: json['max_staff'] as int? ?? 2,
staffUsed: json['staff_used'] as int? ?? 0,
maxStaffTransmission: json['max_staff_transmission'] as int?,
maxStaffRegia: json['max_staff_regia'] as int?,
staffTransmissionUsed: json['staff_transmission_used'] as int? ?? 0,
staffRegiaUsed: json['staff_regia_used'] as int? ?? 0,
concurrentStreamsUsed: json['concurrent_streams_used'] as int? ?? 0,
concurrentStreamsLimit: json['concurrent_streams_limit'] as int?,
recordingsEnabled: json['recordings_enabled'] as bool? ?? false,
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
youtubeMode: json['youtube_mode'] as String?,
);
}
}

View File

@@ -0,0 +1,46 @@
class User {
const User({
required this.id,
required this.email,
required this.name,
required this.role,
});
final String id;
final String email;
final String name;
final String role;
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
role: json['role'] as String? ?? 'coach',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'role': role,
};
}
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
});
final String accessToken;
final String refreshToken;
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
);
}
}