diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 50b4006..c6b5449 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -146,10 +146,11 @@ module Api platform: session.platform, rtmp_ingest_url: session.rtmp_ingest_url, hls_playback_url: session.hls_playback_url, - watch_page_url: session.watch_page_url, + watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil, share_url: session.share_url, youtube_watch_url: session.youtube_watch_url, youtube_broadcast_id: session.youtube_broadcast_id, + youtube_ready: session.youtube_ready?, privacy_status: session.privacy_status, quality_preset: session.quality_preset, target_bitrate: session.target_bitrate, diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index 9ab4819..b3ffdfc 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -14,6 +14,7 @@ module Public sessions = StreamSession .broadcasting .publicly_listed + .where(platform: "matchlivetv") .includes(:score_state, match: { team: :club }) sessions = sessions.joins(:match).where(matches: { team_id: team_ids }) if team_ids.present? diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 41ad3c9..17afa11 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -119,11 +119,13 @@ class StreamSession < ApplicationRecord end def share_url - if platform == "youtube" - youtube_watch_url.presence || watch_page_url - else - watch_page_url - end + return youtube_watch_url if platform == "youtube" + + watch_page_url + end + + def youtube_ready? + platform == "youtube" && youtube_watch_url.present? end def link_only? diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb index 0b30b3b..23d6bca 100644 --- a/backend/app/services/streams/youtube_relay.rb +++ b/backend/app/services/streams/youtube_relay.rb @@ -52,6 +52,19 @@ module Streams pid.present? && process_alive?(pid) end + # Riavvia il relay se è morto (es. avviato prima del segnale su _air). + def ensure_publishing!(session) + return unless session.platform == "youtube" + return if session.terminal? + return if session.stream_key.blank? + + return if running?(session.id) + + start(session) + rescue Error => e + Rails.logger.warn("[YoutubeRelay] ensure_publishing session=#{session.id}: #{e.message}") + end + private def mediamtx_intake_url(session) diff --git a/backend/app/services/webhooks/mediamtx_handler.rb b/backend/app/services/webhooks/mediamtx_handler.rb index 3b35ffc..dd3fe43 100644 --- a/backend/app/services/webhooks/mediamtx_handler.rb +++ b/backend/app/services/webhooks/mediamtx_handler.rb @@ -26,6 +26,7 @@ module Webhooks return if session.paused? Streams::OverlayRelay.ensure_publishing!(session) + Streams::YoutubeRelay.ensure_publishing!(session) if session.platform == "youtube" return if session.live? && session.stream_events.where(event_type: "connected").exists? diff --git a/backend/app/services/youtube/setup_broadcast.rb b/backend/app/services/youtube/setup_broadcast.rb index 481401b..f45b483 100644 --- a/backend/app/services/youtube/setup_broadcast.rb +++ b/backend/app/services/youtube/setup_broadcast.rb @@ -39,6 +39,15 @@ module Youtube metadata: { broadcast_id: broadcast[:broadcast_id] }, occurred_at: Time.current ) + SessionChannel.broadcast_message( + @session, + { + type: "youtube_ready", + youtube_watch_url: @session.youtube_watch_url, + share_url: @session.share_url, + youtube_broadcast_id: @session.youtube_broadcast_id + } + ) @session rescue BroadcastService::Error => e @session.stream_events.create!( diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index a9255c4..554b852 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -4,7 +4,7 @@ <% content_for :robots, "noindex, follow" %> <% content_for :head do %> - <% unless @stream_closed %> + <% unless @stream_closed || @session.platform == "youtube" %> <% end %> <% end %> @@ -25,6 +25,19 @@

Il punteggio finale resta visibile sopra.

<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %> +<% elsif @session.platform == "youtube" %> +
+ +

Diretta su YouTube

+ <% if @session.youtube_watch_url.present? %> +

Questa partita è trasmessa sul canale YouTube Match Live TV, non sul player del sito.

+ <%= link_to "Guarda su YouTube", @session.youtube_watch_url, class: "btn stream-ended-btn", target: "_blank", rel: "noopener" %> + <% else %> +

Stiamo preparando la diretta sul canale YouTube. Aggiorna tra qualche secondo.

+ + <% end %> +

Condividi solo il link YouTube con tifosi e società.

+
<% else %>
diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart index e3e548d..315c8d6 100644 --- a/mobile/lib/features/setup_wizard/step_network_test.dart +++ b/mobile/lib/features/setup_wizard/step_network_test.dart @@ -45,6 +45,7 @@ class _StepNetworkTestState extends ConsumerState { int _latencyMs = 0; String _networkType = '—'; bool _starting = false; + Timer? _youtubePoll; @override void initState() { @@ -54,6 +55,45 @@ class _StepNetworkTestState extends ConsumerState { if (session?.score != null) { ref.read(scoreProvider.notifier).reset(session!.score!); } + _startYoutubeLinkPoll(); + }); + } + + @override + void dispose() { + _youtubePoll?.cancel(); + super.dispose(); + } + + void _startYoutubeLinkPoll() { + final session = ref.read(sessionProvider).session; + if (session == null || session.platform != 'youtube') return; + if (session.youtubeReady || (session.youtubeWatchUrl?.isNotEmpty ?? false)) { + return; + } + + _youtubePoll?.cancel(); + var attempts = 0; + _youtubePoll = Timer.periodic(const Duration(seconds: 2), (_) async { + attempts++; + if (!mounted || attempts > 30) { + _youtubePoll?.cancel(); + return; + } + final current = ref.read(sessionProvider).session; + if (current == null) return; + try { + final updated = + await ref.read(apiClientProvider).fetchSession(current.id); + ref + .read(sessionProvider.notifier) + .setSession(updated, match: ref.read(sessionProvider).match); + if (updated.youtubeReady || + (updated.youtubeWatchUrl?.isNotEmpty ?? false)) { + _youtubePoll?.cancel(); + if (mounted) setState(() {}); + } + } catch (_) {} }); } @@ -240,6 +280,24 @@ class _StepNetworkTestState extends ConsumerState { ), const SizedBox(height: 12), ], + if (_testCompleted && + session != null && + session.platform == 'youtube' && + shareUrl == null) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Preparazione diretta sul canale YouTube Match Live TV… ' + 'Il link apparirà qui tra pochi secondi.', + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + ], if (_testCompleted && session != null && shareUrl != null) ...[ Container( padding: const EdgeInsets.all(14), @@ -252,7 +310,7 @@ class _StepNetworkTestState extends ConsumerState { children: [ Text( session.platform == 'youtube' - ? 'Link video YouTube' + ? 'Link diretta YouTube (canale Match Live TV)' : 'Link diretta Match Live TV', style: theme.textTheme.titleSmall, ), @@ -261,6 +319,13 @@ class _StepNetworkTestState extends ConsumerState { shareUrl, style: theme.textTheme.bodySmall, ), + if (session.platform == 'youtube') ...[ + const SizedBox(height: 6), + Text( + 'Condividi solo questo link: gli spettatori guardano su YouTube, non sul sito.', + style: theme.textTheme.bodySmall, + ), + ], const SizedBox(height: 8), Row( children: [ diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart index 46809bb..753e717 100644 --- a/mobile/lib/providers/session_provider.dart +++ b/mobile/lib/providers/session_provider.dart @@ -47,6 +47,38 @@ class SessionNotifier extends StateNotifier { state = state.copyWith(session: session, match: match); } + void updateYoutubeLinks({ + required String? youtubeWatchUrl, + required String? shareUrl, + String? youtubeBroadcastId, + bool youtubeReady = true, + }) { + final s = state.session; + if (s == null || s.platform != 'youtube') return; + state = state.copyWith( + session: StreamSession( + id: s.id, + matchId: s.matchId, + status: s.status, + platform: s.platform, + rtmpIngestUrl: s.rtmpIngestUrl, + hlsPlaybackUrl: s.hlsPlaybackUrl, + watchPageUrl: s.watchPageUrl, + shareUrl: shareUrl, + youtubeWatchUrl: youtubeWatchUrl, + youtubeBroadcastId: youtubeBroadcastId ?? s.youtubeBroadcastId, + youtubeReady: youtubeReady, + privacyStatus: s.privacyStatus, + qualityPreset: s.qualityPreset, + targetBitrate: s.targetBitrate, + startedAt: s.startedAt, + disconnectionCount: s.disconnectionCount, + score: s.score, + devices: s.devices, + ), + ); + } + void setMatch(MatchModel match) { state = state.copyWith(match: match); } @@ -91,6 +123,7 @@ class SessionNotifier extends StateNotifier { shareUrl: s.shareUrl, youtubeWatchUrl: s.youtubeWatchUrl, youtubeBroadcastId: s.youtubeBroadcastId, + youtubeReady: s.youtubeReady, privacyStatus: s.privacyStatus, qualityPreset: s.qualityPreset, targetBitrate: s.targetBitrate, @@ -133,7 +166,10 @@ class SessionNotifier extends StateNotifier { rtmpIngestUrl: session.rtmpIngestUrl, hlsPlaybackUrl: session.hlsPlaybackUrl, watchPageUrl: session.watchPageUrl, + shareUrl: session.shareUrl, + youtubeWatchUrl: session.youtubeWatchUrl, youtubeBroadcastId: session.youtubeBroadcastId, + youtubeReady: session.youtubeReady, privacyStatus: session.privacyStatus, qualityPreset: session.qualityPreset, targetBitrate: session.targetBitrate, diff --git a/mobile/lib/shared/models/stream_session.dart b/mobile/lib/shared/models/stream_session.dart index e2c4dca..5f97b39 100644 --- a/mobile/lib/shared/models/stream_session.dart +++ b/mobile/lib/shared/models/stream_session.dart @@ -13,6 +13,7 @@ class StreamSession { this.shareUrl, this.youtubeWatchUrl, this.youtubeBroadcastId, + this.youtubeReady = false, this.privacyStatus = 'public', this.qualityPreset = '720p_30_2.5mbps', this.targetBitrate = 2500000, @@ -32,6 +33,7 @@ class StreamSession { final String? shareUrl; final String? youtubeWatchUrl; final String? youtubeBroadcastId; + final bool youtubeReady; final String privacyStatus; final String qualityPreset; final int targetBitrate; @@ -62,6 +64,7 @@ class StreamSession { shareUrl: json['share_url'] as String?, youtubeWatchUrl: json['youtube_watch_url'] as String?, youtubeBroadcastId: json['youtube_broadcast_id'] as String?, + youtubeReady: json['youtube_ready'] as bool? ?? false, privacyStatus: json['privacy_status'] as String? ?? 'public', qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps', targetBitrate: json['target_bitrate'] as int? ?? 2500000, diff --git a/mobile/lib/shared/watch_share.dart b/mobile/lib/shared/watch_share.dart index d9fa2d9..570d2b9 100644 --- a/mobile/lib/shared/watch_share.dart +++ b/mobile/lib/shared/watch_share.dart @@ -5,7 +5,10 @@ import 'package:share_plus/share_plus.dart'; import 'models/stream_session.dart'; String? watchShareUrl(StreamSession session) { - return session.shareUrl ?? session.youtubeWatchUrl ?? session.watchPageUrl; + if (session.platform == 'youtube') { + return session.youtubeWatchUrl ?? session.shareUrl; + } + return session.shareUrl ?? session.watchPageUrl; } String watchShareLabel(StreamSession session) { diff --git a/mobile/lib/shared/websocket_service.dart b/mobile/lib/shared/websocket_service.dart index 5324677..f2806c4 100644 --- a/mobile/lib/shared/websocket_service.dart +++ b/mobile/lib/shared/websocket_service.dart @@ -121,6 +121,13 @@ class WebsocketService { _ref.read(sessionProvider.notifier).applyCommand('resume_stream'); } break; + case 'youtube_ready': + _ref.read(sessionProvider.notifier).updateYoutubeLinks( + youtubeWatchUrl: data['youtube_watch_url'] as String?, + shareUrl: data['share_url'] as String?, + youtubeBroadcastId: data['youtube_broadcast_id'] as String?, + ); + break; default: break; }