diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index d454679..5e8a216 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -179,6 +179,103 @@ Map _segmentHeaders({int maxAge = 60}) => { // 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy) // ========================================== +/// Connexion amont ouverte, avec son client pour pouvoir le refermer. +class _LiveUpstream { + final http.Client client; + final http.StreamedResponse response; + _LiveUpstream(this.client, this.response); +} + +Future<_LiveUpstream?> _openLiveUpstream(String targetUrl) async { + final client = http.Client(); + try { + final request = http.Request('GET', Uri.parse(targetUrl)) + ..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18' + ..headers['Accept'] = '*/*'; + final response = await client.send(request); + return _LiveUpstream(client, response); + } catch (_) { + client.close(); + return null; + } +} + +/// Nombre de reconnexions consécutives tentées avant d'abandonner le flux. +const _liveProxyMaxRetries = 4; + +/// Corps du proxy `.ts` direct, avec reconnexion amont. +/// +/// Les panneaux Xtream ferment régulièrement la connexion en cours de route +/// (bascule de source, limite de connexions simultanées, recyclage nginx). +/// Sans reprise, le `MediaSource` du navigateur reçoit un `sourceEnded` et la +/// lecture s'arrête net — le symptôme « ça coupe au bout de 30 s ». +/// On rouvre donc la source tant que le client, lui, écoute toujours. +/// +/// Le compteur de tentatives est remis à zéro dès qu'une connexion a duré +/// assez longtemps pour être considérée saine : une coupure toutes les +/// 10 minutes ne doit pas finir par épuiser le quota. +Stream> _resilientLiveBody( + String streamId, + String targetUrl, + _LiveUpstream first, +) async* { + var upstream = first; + var attempt = 0; + + while (true) { + final startedAt = DateTime.now(); + var bytes = 0; + + try { + await for (final chunk in upstream.response.stream) { + bytes += chunk.length; + yield chunk; + } + } catch (e) { + print('[Live Proxy] $streamId : coupure amont ($e)'); + } finally { + upstream.client.close(); + } + + final lasted = DateTime.now().difference(startedAt); + if (lasted > const Duration(seconds: 60) && bytes > 0) { + attempt = 0; + } + + print( + '[Live Proxy] $streamId : flux terminé après ${lasted.inSeconds}s ' + '($bytes octets)', + ); + + // Rouvrir la source, en réessayant tant qu'il reste du quota. + _LiveUpstream? next; + while (next == null && attempt < _liveProxyMaxRetries) { + attempt++; + // Laisser le panneau libérer le slot de connexion précédent : sur les + // comptes limités à une connexion simultanée, rouvrir immédiatement se + // fait refuser. + await Future.delayed(const Duration(seconds: 1)); + + final candidate = await _openLiveUpstream(targetUrl); + if (candidate != null && candidate.response.statusCode == 200) { + next = candidate; + } else { + candidate?.client.close(); + print( + '[Live Proxy] $streamId : reconnexion ' + '$attempt/$_liveProxyMaxRetries échouée', + ); + } + } + + if (next == null) { + print('[Live Proxy] $streamId : abandon, source injoignable'); + return; + } + upstream = next; + } +} + Handler createLiveStreamHandler( Future Function(Request) getPlaylist, { bool Function()? isGpuEnabled, @@ -274,39 +371,23 @@ Handler createLiveStreamHandler( final targetUrl = '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - print('[Live Proxy] Forwarding $streamId: ${LogRedactor.redactUrl(targetUrl)}'); + print( + '[Live Proxy] Forwarding $streamId: ${LogRedactor.redactUrl(targetUrl)}'); - final client = http.Client(); - final proxyRequest = http.Request('GET', Uri.parse(targetUrl)); - proxyRequest.headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'; - proxyRequest.headers['Accept'] = '*/*'; - - final http.StreamedResponse response; - try { - response = await client.send(proxyRequest); - } catch (e) { - client.close(); + // Première connexion faite hors du générateur : elle seule décide du code + // de réponse renvoyé au client. + final first = await _openLiveUpstream(targetUrl); + if (first == null) { return Response(502, body: 'Upstream connection failed'); } - - // Close the client when the upstream stream ends or errors, otherwise - // each proxied stream leaks a socket. - final body = response.stream.transform>( - StreamTransformer.fromHandlers( - handleDone: (sink) { - sink.close(); - client.close(); - }, - handleError: (error, stackTrace, sink) { - sink.close(); - client.close(); - }, - ), - ); + if (first.response.statusCode != 200) { + first.client.close(); + return Response(first.response.statusCode, body: 'Upstream refused'); + } return Response( - response.statusCode, - body: body, + 200, + body: _resilientLiveBody(streamId, targetUrl, first), headers: { 'Content-Type': 'video/mp2t', 'Connection': 'keep-alive', diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index f9a6bcb..26a6ffa 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -61,6 +61,12 @@ class _PlayerScreenState extends ConsumerState { bool _isMuted = false; bool _ignoreStatusUpdates = false; + /// Reflète l'état plein écran du document. Le navigateur peut en sortir + /// sans passer par notre bouton (touche Échap), d'où l'écoute de + /// `fullscreenchange` plutôt qu'un simple booléen basculé au clic. + bool _isFullscreen = false; + StreamSubscription? _fullscreenSubscription; + /// Server-side transcoding quality. Live TV defaults to `source` /// (direct MPEG-TS proxy, zero transcoding); VOD defaults to `high`. late StreamQuality _quality = widget.streamType == StreamType.live @@ -85,15 +91,32 @@ class _PlayerScreenState extends ConsumerState { _onHover(); // Start the auto-hide timer _initializePlayer(startTimeOverride: widget.startTime); _setupMessageListener(); + _fullscreenSubscription = + html.document.onFullscreenChange.listen((_) => _syncFullscreenState()); } @override void dispose() { _messageSubscription?.cancel(); + _fullscreenSubscription?.cancel(); _controlsTimer?.cancel(); super.dispose(); } + bool get _documentIsFullscreen { + final doc = html.document; + return doc.fullscreenElement != null || + (doc as dynamic).webkitFullscreenElement != null || + (doc as dynamic).mozFullScreenElement != null; + } + + void _syncFullscreenState() { + final value = _documentIsFullscreen; + if (mounted && value != _isFullscreen) { + setState(() => _isFullscreen = value); + } + } + Future _initializePlayer({ double? startTimeOverride, bool isChannelSwitch = false, @@ -320,15 +343,10 @@ class _PlayerScreenState extends ConsumerState { void _toggleFullscreen() { try { - final doc = html.document; - final isFullscreen = doc.fullscreenElement != null || - (doc as dynamic).webkitFullscreenElement != null || - (doc as dynamic).mozFullScreenElement != null; - - if (isFullscreen) { - doc.exitFullscreen(); + if (_documentIsFullscreen) { + html.document.exitFullscreen(); } else { - final element = doc.documentElement; + final element = html.document.documentElement; if (element != null) { element.requestFullscreen(); } @@ -337,6 +355,12 @@ class _PlayerScreenState extends ConsumerState { print('[PlayerScreen] Fullscreen error: $e'); _sendMessage({'type': 'request_fullscreen'}); } + // `fullscreenchange` arrive de façon asynchrone : relire l'état juste + // après l'appel évite que l'icône reste figée si l'évènement est perdu. + Future.delayed( + const Duration(milliseconds: 150), + _syncFullscreenState, + ); } void _toggleMute() { @@ -860,10 +884,14 @@ class _PlayerScreenState extends ConsumerState { ), const SizedBox(width: 16), _buildGlassIconButton( - icon: Icons.aspect_ratio_rounded, + icon: _isFullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, onTap: _toggleFullscreen, transparent: true, - tooltip: 'Plein écran', + tooltip: _isFullscreen + ? 'Quitter le plein écran' + : 'Plein écran', ), ], ), @@ -972,6 +1000,18 @@ class _PlayerScreenState extends ConsumerState { onSelected: _changeQuality, size: buttonSize, ), + + SizedBox(width: spacing), + + // Fullscreen + _buildSimpleIconButton( + icon: _isFullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + onTap: _toggleFullscreen, + size: buttonSize, + tooltip: _isFullscreen ? 'Quitter le plein écran' : 'Plein écran', + ), ]; }