Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter. Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
14 KiB
Dart
400 lines
14 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../app/theme.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../shared/api_client.dart';
|
|
import '../../shared/api_error.dart';
|
|
import '../../shared/models/match.dart';
|
|
import '../../shared/premium_dialog.dart';
|
|
import '../../shared/widgets/primary_cta.dart';
|
|
import '../../shared/widgets/screen_insets.dart';
|
|
import '../../shared/models/team.dart';
|
|
import '../matches/team_providers.dart';
|
|
|
|
class StepTransmission extends ConsumerStatefulWidget {
|
|
const StepTransmission({
|
|
super.key,
|
|
required this.match,
|
|
required this.onBack,
|
|
required this.onNext,
|
|
});
|
|
|
|
final MatchModel match;
|
|
final VoidCallback onBack;
|
|
final VoidCallback onNext;
|
|
|
|
@override
|
|
ConsumerState<StepTransmission> createState() => _StepTransmissionState();
|
|
}
|
|
|
|
class _StepTransmissionState extends ConsumerState<StepTransmission> {
|
|
String _platform = 'matchlivetv';
|
|
String _privacy = 'public';
|
|
String? _youtubeChannel;
|
|
bool _creating = false;
|
|
|
|
void _syncYoutubeChannel(Team? team) {
|
|
if (team == null) {
|
|
_youtubeChannel = null;
|
|
return;
|
|
}
|
|
if (!team.canChooseYoutubeChannel) {
|
|
if (team.youtubeTeamChannelAvailable) {
|
|
_youtubeChannel = 'team';
|
|
} else if (team.youtubePlatformAvailable) {
|
|
_youtubeChannel = 'platform';
|
|
} else {
|
|
_youtubeChannel = null;
|
|
}
|
|
return;
|
|
}
|
|
_youtubeChannel ??= 'platform';
|
|
}
|
|
|
|
Future<void> _createSession() async {
|
|
setState(() => _creating = true);
|
|
try {
|
|
final client = ref.read(apiClientProvider);
|
|
final session = await client.createSession(
|
|
widget.match.id,
|
|
platform: _platform,
|
|
youtubeChannel: _platform == 'youtube' ? _youtubeChannel : null,
|
|
privacyStatus: _privacy,
|
|
qualityPreset: '720p_30_2.5mbps',
|
|
targetBitrate: 2500000,
|
|
targetFps: 30,
|
|
);
|
|
ref.read(sessionProvider.notifier).setSession(session, match: widget.match);
|
|
widget.onNext();
|
|
} on DioException catch (e) {
|
|
final apiErr = ApiException.fromDio(e);
|
|
if (mounted) {
|
|
if (apiErr?.isPremiumRequired == true) {
|
|
await showPremiumRequiredDialog(
|
|
context,
|
|
message: apiErr!.message,
|
|
billingUrl: apiErr.billingUrl,
|
|
);
|
|
} else if (e.type == DioExceptionType.receiveTimeout ||
|
|
e.type == DioExceptionType.sendTimeout ||
|
|
e.type == DioExceptionType.connectionTimeout) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Il server impiega più del previsto (YouTube). Attendi e non ritentare subito: '
|
|
'controlla se la sessione è già stata creata.',
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(apiErr?.message ?? 'Errore nella creazione sessione')),
|
|
);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Errore nella creazione sessione')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _creating = false);
|
|
}
|
|
}
|
|
|
|
void _onYoutubeTap(Team? team) {
|
|
if (team == null) return;
|
|
if (team.isYoutubeReady) {
|
|
setState(() {
|
|
_platform = 'youtube';
|
|
_syncYoutubeChannel(team);
|
|
});
|
|
return;
|
|
}
|
|
if (team.canUseYoutube) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'YouTube non è ancora disponibile. Riprova più tardi o usa Match Live TV sul sito.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
showPremiumRequiredDialog(
|
|
context,
|
|
message: 'YouTube Live richiede Premium Light o Full.',
|
|
billingUrl: team.billingUrl,
|
|
);
|
|
}
|
|
|
|
String _youtubeSubtitle(Team? team) {
|
|
if (team == null || !team.canUseYoutube) {
|
|
return 'Premium Light o Full';
|
|
}
|
|
if (!team.isYoutubeReady) {
|
|
return 'Canale Match Live TV (in attivazione)';
|
|
}
|
|
if (_platform == 'youtube' && team.canChooseYoutubeChannel) {
|
|
return _youtubeChannel == 'team'
|
|
? 'Canale ${team.youtubeTeamChannelTitle ?? "società"}'
|
|
: 'Canale Match Live TV';
|
|
}
|
|
if (team.youtubeUsesPlatformChannel || team.youtubePlatformAvailable) {
|
|
final label = team.youtubeChannelTitle;
|
|
return label != null ? 'Canale $label' : 'Canale Match Live TV';
|
|
}
|
|
final label = team.youtubeTeamChannelTitle ?? team.youtubeChannelTitle;
|
|
return label != null ? 'Canale $label' : 'Canale società collegato';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final teamAsync = ref.watch(teamByIdProvider(widget.match.teamId));
|
|
|
|
return teamAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)),
|
|
error: (_, __) => const Center(child: Text('Errore caricamento squadra')),
|
|
data: (team) {
|
|
_syncYoutubeChannel(team);
|
|
final youtubeSelectable = team != null && team.isYoutubeReady;
|
|
final showYoutubeChannels =
|
|
_platform == 'youtube' && team != null && team.canChooseYoutubeChannel;
|
|
|
|
return SingleChildScrollView(
|
|
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (team != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surfaceElevated,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.3)),
|
|
),
|
|
child: Text(
|
|
'Piano ${team.planName} · Staff ${team.staffLimitLabel}'
|
|
'${team.concurrentStreamsLimit != null ? ' · Dirette ${team.concurrentStreamsUsed}/${team.concurrentStreamsLimit}' : ''}',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
Text('Piattaforma', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
_PlatformCard(
|
|
title: 'Match Live TV',
|
|
subtitle: 'Diretta sul nostro sito (incluso)',
|
|
selected: _platform == 'matchlivetv',
|
|
enabled: true,
|
|
onTap: () => setState(() => _platform = 'matchlivetv'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_PlatformCard(
|
|
title: 'YouTube Live',
|
|
subtitle: _youtubeSubtitle(team),
|
|
selected: _platform == 'youtube',
|
|
enabled: youtubeSelectable,
|
|
badge: team?.canUseYoutube == true ? null : 'Premium',
|
|
onTap: () => _onYoutubeTap(team),
|
|
),
|
|
if (showYoutubeChannels) ...[
|
|
const SizedBox(height: 16),
|
|
Text('Canale YouTube', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
_PlatformCard(
|
|
title: 'Match Live TV',
|
|
subtitle: 'Canale ufficiale della piattaforma',
|
|
selected: _youtubeChannel == 'platform',
|
|
enabled: true,
|
|
onTap: () => setState(() => _youtubeChannel = 'platform'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_PlatformCard(
|
|
title: team!.youtubeTeamChannelTitle ?? 'Canale società',
|
|
subtitle: 'Il tuo canale collegato',
|
|
selected: _youtubeChannel == 'team',
|
|
enabled: true,
|
|
onTap: () => setState(() => _youtubeChannel = 'team'),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
Text('Visibilità', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Match Live TV e YouTube (canale nostro o società).',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
SegmentedButton<String>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: 'public',
|
|
label: Text('Pubblico'),
|
|
icon: Icon(Icons.public),
|
|
),
|
|
ButtonSegment(
|
|
value: 'unlisted',
|
|
label: Text('Privato / non in elenco'),
|
|
icon: Icon(Icons.link),
|
|
),
|
|
],
|
|
selected: {_privacy},
|
|
onSelectionChanged: (s) => setState(() => _privacy = s.first),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_privacy == 'public'
|
|
? 'Compare nell\'elenco dirette e sul canale YouTube scelto.'
|
|
: 'Solo chi ha il link. Non in elenco pubblico né nelle ricerche.',
|
|
style: theme.textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text('Qualità', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surfaceElevated,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.4)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('720p · 30fps · 3 Mbps', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Bitrate fisso consigliato per reti instabili',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryRed.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'AUTO',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: AppTheme.primaryRed,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _creating ? null : widget.onBack,
|
|
child: const Text('Indietro'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 2,
|
|
child: PrimaryCta(
|
|
label: 'AVANTI >',
|
|
loading: _creating,
|
|
onPressed: _createSession,
|
|
icon: Icons.arrow_forward,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlatformCard extends StatelessWidget {
|
|
const _PlatformCard({
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.selected,
|
|
required this.enabled,
|
|
this.badge,
|
|
this.onTap,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool selected;
|
|
final bool enabled;
|
|
final String? badge;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Opacity(
|
|
opacity: enabled ? 1 : 0.55,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? AppTheme.primaryRed.withValues(alpha: 0.15)
|
|
: AppTheme.surfaceElevated,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: selected ? AppTheme.primaryRed : Colors.transparent,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
|
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: theme.textTheme.titleMedium),
|
|
if (subtitle != null)
|
|
Text(subtitle!, style: theme.textTheme.bodyMedium),
|
|
],
|
|
),
|
|
),
|
|
if (badge != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.textSecondary.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(badge!, style: theme.textTheme.labelSmall),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|