Aggiunge modulo Replay, Garage dev e YouTube a livello società.

Pipeline registrazione/upload con storage S3, archivio web e app, proxy replay
per il browser, OAuth YouTube sulla pagina club (Premium Full) e footer legale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 07:53:11 +02:00
parent a87cda156b
commit 1f273f849d
87 changed files with 2952 additions and 195 deletions

View File

@@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart';
@@ -18,7 +20,7 @@ class ArchiveScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Archivio gare'),
title: const Text('Replay'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'),
@@ -41,15 +43,21 @@ class ArchiveScreen extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Archivio replay con Premium Light o Full',
'Replay disponibili con Premium Light o Full',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Light: 30 giorni · Full: 90 giorni',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
context,
message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.',
message: 'Salva e rivedi le gare con il piano Premium.',
billingUrl: team.billingUrl,
),
style: FilledButton.styleFrom(
@@ -71,7 +79,15 @@ class ArchiveScreen extends ConsumerWidget {
error: (e, _) => Center(child: Text('$e')),
data: (recs) {
if (recs.isEmpty) {
return const Center(child: Text('Nessuna gara in archivio'));
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.',
textAlign: TextAlign.center,
),
),
);
}
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
return ListView.builder(
@@ -79,27 +95,68 @@ class ArchiveScreen extends ConsumerWidget {
itemCount: recs.length,
itemBuilder: (context, i) {
final r = recs[i];
final when = r.recordedAt ?? r.endedAt;
final subtitle = [
if (when != null) df.format(when.toLocal()),
if (r.durationLabel != null) r.durationLabel,
if (r.viewsLabel != null) r.viewsLabel,
r.statusLabel,
if (r.expiresAt != null)
'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}',
].whereType<String>().join(' · ');
return Card(
color: AppTheme.surface,
child: ListTile(
title: Text('${r.teamName} vs ${r.opponentName}'),
subtitle: Text(
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '',
),
trailing: const Icon(Icons.play_circle_outline),
onTap: () async {
final url = r.replayUrl;
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: team.canDownloadOnPhone
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
}
},
leading: r.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
r.thumbnailUrl!,
width: 72,
height: 40,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.movie),
),
)
: const Icon(Icons.movie_outlined, size: 40),
title: Text(r.title),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: r.isProcessing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: PopupMenuButton<String>(
onSelected: (action) => _onMenuAction(
context,
ref,
action,
r,
team.canDownloadOnPhone,
),
itemBuilder: (_) => [
if (r.isReady)
const PopupMenuItem(
value: 'play',
child: Text('Guarda replay'),
),
if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone)
const PopupMenuItem(
value: 'download',
child: Text('Scarica MP4'),
),
if (r.youtubeWatchUrl != null)
const PopupMenuItem(
value: 'youtube',
child: Text('Apri su YouTube'),
),
],
),
onTap: r.isReady ? () => _openReplay(r.replayUrl) : null,
),
);
},
@@ -110,4 +167,50 @@ class ArchiveScreen extends ConsumerWidget {
),
);
}
Future<void> _openReplay(String? url) async {
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
Future<void> _onMenuAction(
BuildContext context,
WidgetRef ref,
String action,
RecordingItem r,
bool canDownload,
) async {
switch (action) {
case 'play':
await _openReplay(r.replayUrl);
break;
case 'download':
if (!canDownload) return;
try {
final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id);
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download: $e')),
);
}
}
break;
case 'youtube':
final yt = r.youtubeWatchUrl;
if (yt == null) return;
final uri = Uri.parse(yt);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
break;
}
}
}

View File

@@ -112,6 +112,13 @@ class ApiClient {
.toList();
}
Future<String> fetchRecordingDownloadUrl(String recordingId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/recordings/$recordingId/download',
);
return response.data!['download_url'] as String;
}
// --- Matches ---
Future<List<MatchModel>> fetchMatches(String teamId) async {

View File

@@ -2,31 +2,72 @@ class RecordingItem {
const RecordingItem({
required this.id,
required this.sessionId,
required this.title,
required this.opponentName,
required this.teamName,
this.status = 'ready',
this.statusLabel = 'Disponibile',
this.privacyStatus = 'unlisted',
this.endedAt,
this.recordedAt,
this.replayUrl,
this.playbackUrl,
this.thumbnailUrl,
this.durationLabel,
this.viewCount = 0,
this.viewsLabel,
this.downloadEnabled = false,
this.youtubeWatchUrl,
this.expiresAt,
});
final String id;
final String sessionId;
final String title;
final String opponentName;
final String teamName;
final String status;
final String statusLabel;
final String privacyStatus;
final DateTime? endedAt;
final DateTime? recordedAt;
final String? replayUrl;
final String? playbackUrl;
final String? thumbnailUrl;
final String? durationLabel;
final int viewCount;
final String? viewsLabel;
final bool downloadEnabled;
final String? youtubeWatchUrl;
final DateTime? expiresAt;
bool get isReady => status == 'ready';
bool get isProcessing => status == 'processing';
factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem(
id: json['id'] as String,
sessionId: json['session_id'] as String,
title: json['title'] as String? ?? '${json['team_name']} vs ${json['opponent_name']}',
opponentName: json['opponent_name'] as String,
teamName: json['team_name'] as String,
status: json['status'] as String? ?? 'ready',
statusLabel: json['status_label'] as String? ?? 'Disponibile',
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
endedAt: json['ended_at'] != null
? DateTime.parse(json['ended_at'] as String)
: null,
recordedAt: json['recorded_at'] != null
? DateTime.parse(json['recorded_at'] as String)
: null,
replayUrl: json['replay_url'] as String?,
playbackUrl: json['playback_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?,
durationLabel: json['duration_label'] as String?,
viewCount: json['view_count'] as int? ?? 0,
viewsLabel: json['views_label'] as String?,
downloadEnabled: json['download_enabled'] as bool? ?? false,
youtubeWatchUrl: json['youtube_watch_url'] as String?,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,