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:
37
mobile/lib/shared/models/device_state.dart
Normal file
37
mobile/lib/shared/models/device_state.dart
Normal 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;
|
||||
}
|
||||
81
mobile/lib/shared/models/match.dart
Normal file
81
mobile/lib/shared/models/match.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
35
mobile/lib/shared/models/recording_item.dart
Normal file
35
mobile/lib/shared/models/recording_item.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
mobile/lib/shared/models/score_state.dart
Normal file
100
mobile/lib/shared/models/score_state.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
mobile/lib/shared/models/stream_session.dart
Normal file
129
mobile/lib/shared/models/stream_session.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/shared/models/team.dart
Normal file
96
mobile/lib/shared/models/team.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
mobile/lib/shared/models/user.dart
Normal file
46
mobile/lib/shared/models/user.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user