From f490e9644855db7784e0df51d23cce31adaab2a9 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 10:31:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(player):=20bouton=20plein=20=C3=A9cran=20e?= =?UTF-8?q?t=20reconnexion=20du=20proxy=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plein écran — le bouton n'existait que dans les contrôles VOD, sous une icône aspect_ratio peu parlante, et les contrôles Live TV n'en avaient aucun. Ajout du bouton côté Live TV et passage aux icônes fullscreen / fullscreen_exit. L'état suit désormais l'évènement fullscreenchange du document : sortir par la touche Échap laissait l'icône désynchronisée. Proxy live — la route /api/live/.ts relayait la source sans aucune reprise. Les panneaux Xtream ferment régulièrement la connexion en cours de route, et le MediaSource du navigateur recevait alors un sourceEnded : la lecture s'arrêtait net au bout de quelques dizaines de secondes. Le proxy rouvre maintenant la source tant que le client écoute, avec quatre tentatives et une seconde d'attente entre chaque, le temps que le panneau libère le slot de connexion. Le compteur repart de zéro dès qu'une connexion a tenu plus d'une minute. Co-Authored-By: Claude Opus 5 --- bin/api/streaming_handler.dart | 137 +++++++++++++++---- lib/features/iptv/screens/player_screen.dart | 60 ++++++-- 2 files changed, 159 insertions(+), 38 deletions(-) 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', + ), ]; }