diff --git a/CHANGELOG.md b/CHANGELOG.md index 8099e60..6aa7daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Non publié ### ▶️ Lecture vidéo +- **Lecteur avancé pour les enregistrements** : barre de progression réellement utilisable (avance, retour, saut n'importe où), pause/reprise, et reprise automatique là où on s'était arrêté. La playlist d'un enregistrement était servie en `EXT-X-PLAYLIST-TYPE:VOD` : hls.js la considérait comme définitive et ne voyait donc que les quelques secondes déjà transcodées à l'ouverture. Elle passe en `EVENT` (la zone navigable grandit avec l'encodage) et un saut hors de cette zone relance FFmpeg à la position visée (`?start=`), au lieu d'attendre que l'encodeur y arrive +- **Reprise des enregistrements** : la liste affiche « Reprendre à … » avec la progression, et propose au clic de reprendre ou de repartir du début ; la durée affichée est mesurée par ffprobe (`duration_seconds`) et non plus déduite des horaires programmés — un enregistrement arrêté en avance donnait une barre fausse +- **Contrôles enrichis (VOD, séries, enregistrements)** : sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre, et raccourcis clavier `K`/espace, `J`/`L`, Maj+←/→, `0`-`9`, `F`, `M`, `,`/`.` - **Fix du son manquant sur certaines chaînes** : nouvelle route `/api/live//turbo.ts` pour le zapping — vidéo copiée telle quelle, audio systématiquement réencodé en AAC côté serveur. Les chaînes en AC-3/E-AC-3/MP2 (que mpegts.js ne décode pas) ont maintenant du son, sans coût de transcodage vidéo - **Players mis à jour** : hls.js 1.6.7 → 1.7.1, mpegts.js 1.7.3 → 1.8.2 (vendorisés) - **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur) diff --git a/bin/api/recording_playlist.dart b/bin/api/recording_playlist.dart new file mode 100644 index 0000000..ac2b178 --- /dev/null +++ b/bin/api/recording_playlist.dart @@ -0,0 +1,43 @@ +/// Adressage des sessions de lecture d'un enregistrement. +/// +/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement. +/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur +/// y arrive : le lecteur demande donc une playlist qui *commence* à cette +/// position (`?start=2700`), servie par une session FFmpeg dédiée. +/// +/// Ces fonctions sont isolées du handler HTTP pour rester testables. +library; + +/// Position de départ demandée (`?start=`), bornée au positif. +/// Toute valeur absente, négative ou non numérique vaut « depuis le début ». +int parseRecordingStart(String? raw) { + final value = double.tryParse(raw ?? ''); + if (value == null || !value.isFinite || value <= 0) return 0; + return value.floor(); +} + +/// Segment de chemin qui rattache un segment `.ts` à SA session. +/// +/// Les segments sont référencés en relatif dans la playlist et la +/// query string n'y survit pas : sans ce répertoire virtuel, la playlist +/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux +/// `segment_000.ts` à la même URL. +String recordingOffsetKey(int start) => 't$start'; + +/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée. +int? parseRecordingOffsetKey(String key) { + if (!key.startsWith('t') || key.length < 2) return null; + final value = int.tryParse(key.substring(1)); + return (value == null || value < 0) ? null : value; +} + +/// Préfixe les segments de [playlist] par [offsetKey]. +/// +/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et +/// les lignes vides passent telles quelles. +String rewriteRecordingPlaylist(String playlist, String offsetKey) { + return playlist.replaceAllMapped( + RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true), + (m) => '$offsetKey/${m[1]}', + ); +} diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart index 908d877..f2a403c 100644 --- a/bin/api/recordings_api.dart +++ b/bin/api/recordings_api.dart @@ -74,18 +74,94 @@ class RecordingsApi { /// GET /api/recordings — Liste les enregistrements de l'utilisateur /// (tous les enregistrements pour un admin), enrichis des informations de /// suivi : taille du fichier, progression, relances FFmpeg. - Response handleGetAll(Request request) { + Future handleGetAll(Request request) async { final user = request.context['user'] as User?; final recordings = (user != null && !user.isAdmin) ? _db.getUserRecordings(user.id) : _db.getAllRecordings(); final now = DateTime.now().toUtc(); + final enriched = >[]; + for (final r in recordings) { + final map = _enrich(r, now); + // Durée réelle du média : `end_time - start_time` n'est que la durée + // *programmée*. Un enregistrement arrêté en avance ou tronqué par une + // coupure amont donnait une barre de progression fausse et un « reprendre » + // hors des clous. + final duration = await _probeDuration(r); + if (duration != null) map['duration_seconds'] = duration; + enriched.add(map); + } return Response.ok( - json.encode(recordings.map((r) => _enrich(r, now)).toList()), + json.encode(enriched), headers: {'Content-Type': 'application/json'}, ); } + /// Binaire ffprobe : même logique de résolution que FFmpeg côté scheduler + /// (l'image Docker les installe tous deux dans `/usr/local/bin`). + static String _ffprobePath() { + final fromEnv = Platform.environment['FFPROBE_PATH']; + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) { + return '/usr/local/bin/ffprobe'; + } + return 'ffprobe'; + } + + /// Durées mesurées par ffprobe, mémorisées tant que le fichier ne bouge pas. + /// Sans ce cache, chaque cycle de rafraîchissement (5 s pendant une capture) + /// relancerait un ffprobe par enregistrement. + static final Map + _durationCache = {}; + + /// Durée du média en secondes, ou `null` si elle n'est pas mesurable + /// (ffprobe absent, fichier en cours d'écriture, conteneur sans durée). + Future _probeDuration(Recording r) async { + final path = r.filePath; + // Un enregistrement en cours grossit en permanence : le cache serait + // invalidé à chaque appel et ffprobe tournerait en boucle. La durée + // programmée suffit tant que la capture n'est pas terminée. + if (path == null || r.status != 'completed') return null; + + final safePath = SafePath.resolveWithin(recordingsDirPath, path); + if (safePath == null) return null; + + final file = File(safePath); + FileStat stat; + try { + stat = file.statSync(); + if (stat.type == FileSystemEntityType.notFound) return null; + } catch (_) { + return null; + } + + final cached = _durationCache[safePath]; + if (cached != null && + cached.size == stat.size && + cached.mtimeMs == stat.modified.millisecondsSinceEpoch) { + return cached.duration; + } + + try { + final result = await Process.run(_ffprobePath(), [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + safePath, + ]); + final value = double.tryParse((result.stdout as String).trim()); + if (value == null || !value.isFinite || value <= 0) return null; + _durationCache[safePath] = ( + size: stat.size, + mtimeMs: stat.modified.millisecondsSinceEpoch, + duration: value, + ); + return value; + } catch (_) { + return null; + } + } + Map _enrich(Recording r, DateTime now) { final map = r.toMap(); diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 8b966a2..38335a3 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -7,6 +7,7 @@ import '../database/database.dart'; import '../models/playlist_config.dart'; import '../services/ffmpeg_session_manager.dart'; import '../utils/log_redactor.dart'; +import 'recording_playlist.dart'; /// Directory for temporary HLS segments final Directory _hlsTempDir = @@ -632,6 +633,10 @@ Handler createRecordingStreamHandler( router.get('//playlist.m3u8', (Request request, String streamId) async { + if (!_isValidStreamId(streamId)) { + return Response.badRequest(body: 'Invalid request'); + } + final recording = db.getRecordingById(streamId); if (recording == null) { return Response.notFound('Recording not found'); @@ -661,7 +666,10 @@ Handler createRecordingStreamHandler( } final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - final sessionId = 'rec_$streamId'; + final start = parseRecordingStart(request.url.queryParameters['start']); + final offsetKey = recordingOffsetKey(start); + final sessionPrefix = 'rec_${streamId}_t'; + final sessionId = 'rec_${streamId}_$offsetKey'; final session = await sessionManager.getOrStart( id: sessionId, @@ -670,6 +678,9 @@ Handler createRecordingStreamHandler( argsBuilder: (dir) => [ '-hide_banner', '-loglevel', 'warning', if (useNvidiaGpu) ...['-hwaccel', 'cuda'], + // -ss AVANT -i : recherche rapide par index, sans décoder ce qui + // précède. La sortie repart de 0, le lecteur ré-ajoute l'offset. + if (start > 0) ...['-ss', '$start'], '-i', targetUrl, if (useNvidiaGpu) ...[ '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', @@ -686,8 +697,13 @@ Handler createRecordingStreamHandler( '-g', '48', '-threads', '0', ], ..._audioArgs(withFilters: true), + // `event` et non `vod` : en VOD, hls.js considère la playlist comme + // définitive et ne la relit jamais. Il ne voyait donc que les + // quelques secondes déjà transcodées à l'ouverture — d'où une durée + // absurde et une barre de progression inutilisable. En `event`, la + // playlist est rechargée et la zone navigable grandit avec l'encodage. '-f', 'hls', '-hls_time', '4', '-hls_list_size', '0', - '-hls_playlist_type', 'vod', '-hls_allow_cache', '1', + '-hls_playlist_type', 'event', '-hls_allow_cache', '1', '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', 'playlist.m3u8', @@ -702,22 +718,46 @@ Handler createRecordingStreamHandler( } session.touch(); + // Ménage des offsets abandonnés (aller-retours dans la barre de + // progression), une fois la nouvelle session confirmée démarrée. + sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId); + + // Les segments sont préfixés par l'offset pour rester rattachés à LEUR + // session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à + // 45 min sont deux fichiers différents. + final playlist = await File('${session.dir.path}/playlist.m3u8') + .readAsString(); + return Response.ok( - File('${session.dir.path}/playlist.m3u8').openRead(), + rewriteRecordingPlaylist(playlist, offsetKey), headers: _hlsHeaders(), ); }); - router.get('//', - (Request request, String streamId, String segment) async { - if (segment.contains('..')) { + Response serveSegment(String streamId, String offsetKey, String segment) { + if (!_isValidStreamId(streamId) || + parseRecordingOffsetKey(offsetKey) == null || + segment.contains('..') || + segment.contains('/')) { return Response.badRequest(body: 'Invalid request'); } - final sessionId = 'rec_$streamId'; + final sessionId = 'rec_${streamId}_$offsetKey'; sessionManager.touch(sessionId); final file = File('${_hlsTempDir.path}/$sessionId/$segment'); if (!file.existsSync()) return Response.notFound('Segment not found'); return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600)); + } + + router.get('///', + (Request request, String streamId, String offsetKey, String segment) { + return serveSegment(streamId, offsetKey, segment); + }); + + // Compat : playlist servie avant la mise en place des offsets, encore en + // cache dans un onglet ouvert. + router.get('//', + (Request request, String streamId, String segment) { + return serveSegment(streamId, recordingOffsetKey(0), segment); }); return router.call; diff --git a/bin/services/ffmpeg_session_manager.dart b/bin/services/ffmpeg_session_manager.dart index fa7dfa9..8f477b9 100644 --- a/bin/services/ffmpeg_session_manager.dart +++ b/bin/services/ffmpeg_session_manager.dart @@ -185,6 +185,28 @@ class FfmpegSessionManager { } } + /// Tue les sessions frères d'un même contenu (`rec__t0`, `rec__t600`…) + /// que plus personne ne consomme depuis [idleFor]. + /// + /// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un + /// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la + /// barre de progression laisserait autant de processus vivants jusqu'au + /// TTL de 15 minutes. Le délai d'inactivité protège un second spectateur + /// du même enregistrement : sa session est « touchée » à chaque segment. + void killIdleSiblings( + String prefix, { + required String keep, + Duration idleFor = const Duration(seconds: 30), + }) { + final now = DateTime.now(); + for (final session in _sessions.values.toList()) { + if (session.id == keep || !session.id.startsWith(prefix)) continue; + if (now.difference(session.lastAccess) < idleFor) continue; + print('[FFmpegManager] Dropping stale sibling ${session.id}'); + killSession(session.id); + } + } + void killAll() { print('[FFmpegManager] Killing ${_sessions.length} session(s)'); for (final id in _sessions.keys.toList()) { diff --git a/bin/test/recording_playlist_test.dart b/bin/test/recording_playlist_test.dart new file mode 100644 index 0000000..f04a1fd --- /dev/null +++ b/bin/test/recording_playlist_test.dart @@ -0,0 +1,69 @@ +import 'package:test/test.dart'; +import '../api/recording_playlist.dart'; + +void main() { + group('parseRecordingStart', () { + test('reads a positive position in seconds', () { + expect(parseRecordingStart('2700'), 2700); + }); + + test('truncates fractions of a second', () { + expect(parseRecordingStart('2700.9'), 2700); + }); + + test('falls back to the beginning when absent or unusable', () { + expect(parseRecordingStart(null), 0); + expect(parseRecordingStart(''), 0); + expect(parseRecordingStart('abc'), 0); + expect(parseRecordingStart('-30'), 0); + expect(parseRecordingStart('NaN'), 0); + expect(parseRecordingStart('Infinity'), 0); + }); + }); + + group('recordingOffsetKey', () { + test('round-trips through its parser', () { + for (final start in [0, 1, 2700, 86400]) { + expect(parseRecordingOffsetKey(recordingOffsetKey(start)), start); + } + }); + + test('rejects keys that are not an offset', () { + expect(parseRecordingOffsetKey('t'), isNull); + expect(parseRecordingOffsetKey('segment_000.ts'), isNull); + expect(parseRecordingOffsetKey('..'), isNull); + expect(parseRecordingOffsetKey('t-30'), isNull); + expect(parseRecordingOffsetKey('tabc'), isNull); + }); + }); + + group('rewriteRecordingPlaylist', () { + const playlist = '#EXTM3U\n' + '#EXT-X-VERSION:3\n' + '#EXT-X-TARGETDURATION:4\n' + '#EXT-X-PLAYLIST-TYPE:EVENT\n' + '#EXTINF:4.000000,\n' + 'segment_000.ts\n' + '#EXTINF:4.000000,\n' + 'segment_001.ts\n'; + + test('binds every segment to its own session directory', () { + final rewritten = rewriteRecordingPlaylist(playlist, 't2700'); + expect(rewritten, contains('t2700/segment_000.ts')); + expect(rewritten, contains('t2700/segment_001.ts')); + expect(rewritten, isNot(contains('\nsegment_'))); + }); + + test('leaves tags untouched', () { + final rewritten = rewriteRecordingPlaylist(playlist, 't0'); + expect(rewritten, startsWith('#EXTM3U\n#EXT-X-VERSION:3\n')); + expect(rewritten, contains('#EXT-X-PLAYLIST-TYPE:EVENT')); + expect('#EXTINF:4.000000,'.allMatches(rewritten).length, 2); + }); + + test('is idempotent on an already rewritten playlist', () { + final once = rewriteRecordingPlaylist(playlist, 't60'); + expect(rewriteRecordingPlaylist(once, 't60'), once); + }); + }); +} diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 5031cc0..b0679e2 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -61,6 +61,29 @@ class _PlayerScreenState extends ConsumerState { bool _isMuted = false; bool _ignoreStatusUpdates = false; + /// Fin de la plage réellement navigable, en temps absolu. + /// + /// Un enregistrement est transcodé au fil de l'eau par FFmpeg : ce qui est + /// au-delà de ce point n'existe pas encore côté serveur. Sauter dedans + /// suppose de relancer l'encodeur à la position visée. + double _seekableEnd = 0; + + /// Décalage du média courant par rapport au début du contenu : un + /// enregistrement rouvert à 45 min est transcodé *depuis* 45 min. + double _mediaOffset = 0; + + /// Vitesse de lecture (hors direct). + double _rate = 1.0; + + /// Un rechargement du flux à une nouvelle position est en cours : les + /// positions remontées par l'ancienne iframe ne doivent plus rien écraser. + bool _seekReloading = false; + + /// Incrémenté à chaque (re)création de l'iframe : deux vues de plateforme + /// ne peuvent pas partager le même identifiant, et il faut un identifiant + /// neuf pour que Flutter reconstruise réellement l'iframe. + int _viewGeneration = 0; + /// 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. @@ -113,6 +136,15 @@ class _PlayerScreenState extends ConsumerState { /// tous les navigateurs visés. bool get _documentIsFullscreen => html.document.fullscreenElement != null; + /// Clé de reprise. Les enregistrements sont préfixés : leur identifiant est + /// un UUID stocké dans le même espace de noms que les identifiants VOD. + String get _positionKey => widget.streamType == StreamType.recording + ? 'rec_${widget.streamId}' + : widget.streamId; + + /// La position peut-elle avoir un sens (barre de progression, reprise) ? + bool get _isSeekable => widget.streamType != StreamType.live; + void _syncFullscreenState() { final value = _documentIsFullscreen; if (mounted && value != _isFullscreen) { @@ -139,9 +171,12 @@ class _PlayerScreenState extends ConsumerState { } // Stable view ID for the instance to prevent iframe reload on rebuild - // (orientation change). Includes quality so a quality switch forces a - // fresh iframe. - _viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix'; + // (orientation change). La génération change à chaque appel : sans + // elle, relancer le flux à une autre position réutiliserait la même + // vue de plateforme et l'iframe ne serait jamais reconstruite. + _viewId = + 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix-' + '${_viewGeneration++}'; final service = ref.read(xtreamServiceProvider(widget.playlist)); String streamUrl = ''; @@ -166,7 +201,16 @@ class _PlayerScreenState extends ConsumerState { quality: _quality.value, ); } else if (widget.streamType == StreamType.recording) { - streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; + // FFmpeg transcode l'enregistrement séquentiellement : reprendre à + // 45 min sans le lui dire imposerait d'attendre qu'il y arrive. + // `?start=` lui fait démarrer l'encodage à la position demandée. + _mediaOffset = (startTimeOverride ?? 0) > 0 + ? startTimeOverride!.floorToDouble() + : 0; + final base = '${service.backendBaseUrl}/api/recordings/stream/' + '$currentStreamId/playlist.m3u8'; + streamUrl = + _mediaOffset > 0 ? '$base?start=${_mediaOffset.toInt()}' : base; } // Store URL for Lite Player @@ -204,9 +248,10 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.recording) { playerSrc += '&is_recording=true'; - } - - if (startTimeOverride != null) { + // Le média commence à l'offset : le lecteur ré-ajoute ce décalage + // pour exprimer position et recherches en temps absolu. + if (_mediaOffset > 0) playerSrc += '&offset=${_mediaOffset.toInt()}'; + } else if (startTimeOverride != null && startTimeOverride > 0) { playerSrc += '&t=$startTimeOverride'; } @@ -230,12 +275,20 @@ class _PlayerScreenState extends ConsumerState { setState(() { _isInitialized = true; _isLoading = false; + _isPlaying = true; + _seekReloading = false; + // La nouvelle iframe repart d'une plage navigable vide, qui + // grandira au rythme du transcodage. + _seekableEnd = _mediaOffset; + if (_mediaOffset > 0) _currentPosition = _mediaOffset; }); // Force unmute on load (after a short delay to ensure iframe is ready) Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - _sendMessage({'type': 'set_volume', 'value': 1.0}); + if (!mounted) return; + _sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0}); + if (_rate != 1.0) { + _sendMessage({'type': 'set_rate', 'value': _rate}); } }); } @@ -258,28 +311,47 @@ class _PlayerScreenState extends ConsumerState { final type = data['type']; if (type == 'playback_position') { + // Un rechargement à une autre position est en cours : l'ancienne + // iframe continue d'émettre quelques instants, ne pas la laisser + // ramener la barre en arrière. + if (_seekReloading) return; + final currentTime = (data['currentTime'] as num).toDouble(); final rawDuration = (data['duration'] as num).toDouble(); final duration = rawDuration.isFinite ? rawDuration : 0.0; + final rawSeekable = data['seekableEnd']; + final seekableEnd = + rawSeekable is num ? rawSeekable.toDouble() : currentTime; // Only update position if user is NOT dragging the slider - if (!_isSeeking) { - setState(() { + setState(() { + if (!_isSeeking) { _currentPosition = currentTime; _totalDuration = duration > 0 ? duration : 1; - }); - } + } + if (seekableEnd.isFinite && seekableEnd > 0) { + _seekableEnd = seekableEnd; + } + }); // Update watch history if relevant. Jamais en live : la « position » // d'un flux continu n'a pas de sens et polluait le stockage avec une // entrée bidon par chaîne zappée. if (currentTime > 0 && widget.streamType != StreamType.live) { ref.read(playbackPositionsProvider.notifier).savePosition( - widget.streamId, + _positionKey, currentTime, duration, ); } + } else if (type == 'seek_out_of_range') { + // Cible hors de la zone déjà transcodée : seul un redémarrage du + // flux à cet instant peut la servir. + final target = (data['value'] as num?)?.toDouble(); + if (target != null) _restartAt(target); + } else if (type == 'playback_ended') { + setState(() => _isPlaying = false); + ref.read(playbackPositionsProvider.notifier).clearPosition(_positionKey); } else if (type == 'playback_status') { if (!_ignoreStatusUpdates) { setState(() => _isPlaying = data['status'] == 'playing'); @@ -345,6 +417,48 @@ class _PlayerScreenState extends ConsumerState { } } + /// Recherche à un instant absolu du contenu. + /// + /// Le lecteur tranche lui-même : saut local s'il a la donnée, sinon il + /// répond `seek_out_of_range` et le flux est relancé à cet instant. + void _seekTo(double target) { + final clamped = target.clamp(0.0, _totalDuration); + setState(() => _currentPosition = clamped); + _sendMessage({'type': 'seek', 'value': clamped}); + _onHover(); + } + + void _seekBy(double delta) => _seekTo(_currentPosition + delta); + + /// Relance le flux à [target] : nouvelle session de transcodage côté + /// serveur, donc quelques secondes d'attente — réservé aux sauts que la + /// zone déjà transcodée ne couvre pas. + void _restartAt(double target) { + if (!_isSeekable || _seekReloading) return; + // Jamais tout à la fin : FFmpeg lancé au-delà de la dernière image + // n'encoderait rien et le lecteur n'aurait qu'une erreur à afficher. + final ceiling = + _totalDuration > 10 ? _totalDuration - 5 : _totalDuration; + final clamped = target.clamp(0.0, ceiling); + if (widget.streamType != StreamType.recording) { + // VOD/séries : le serveur ne sait pas démarrer ailleurs qu'au début, + // on se contente du bord de la zone disponible. + _sendMessage({'type': 'seek', 'value': _seekableEnd}); + return; + } + setState(() { + _seekReloading = true; + _currentPosition = clamped; + }); + _initializePlayer(startTimeOverride: clamped, isChannelSwitch: true); + } + + void _setRate(double rate) { + setState(() => _rate = rate); + _sendMessage({'type': 'set_rate', 'value': rate}); + _onHover(); + } + void _toggleFullscreen() { try { if (_documentIsFullscreen) { @@ -379,6 +493,8 @@ class _PlayerScreenState extends ConsumerState { // current position, live restarts at the live edge. final resumeAt = widget.streamType == StreamType.live ? null : _currentPosition; + // Un enregistrement redémarre à cette position côté serveur : sa zone + // navigable repart de zéro. _initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true); } @@ -408,17 +524,27 @@ class _PlayerScreenState extends ConsumerState { } } + /// Sauts proposés par les raccourcis et les boutons. + static const _smallStep = 10.0; + static const _bigStep = 60.0; + + /// Vitesses de lecture proposées hors direct. + static const _rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + /// Keyboard / TV remote controls: - /// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live), - /// ↑/↓ = zap (live), M = mute, Esc = exit player. + /// space-enter-K = play/pause, ←/→ = seek ±10 s (VOD) or zap (live), + /// Maj+←/→ ou J/L = ±60 s, ↑/↓ = zap (live), 0-9 = saut en % du contenu, + /// M = mute, F = plein écran, +/- = vitesse, Esc = exit player. KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final isLive = widget.streamType == StreamType.live; final key = event.logicalKey; + final shift = HardwareKeyboard.instance.isShiftPressed; if (key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.keyK || key == LogicalKeyboardKey.mediaPlayPause) { _togglePlayPause(); _onHover(); @@ -433,28 +559,27 @@ class _PlayerScreenState extends ConsumerState { _onHover(); return KeyEventResult.handled; } + if (key == LogicalKeyboardKey.keyF) { + _toggleFullscreen(); + _onHover(); + return KeyEventResult.handled; + } if (key == LogicalKeyboardKey.arrowLeft) { if (isLive) { _previousChannel(); + _onHover(); } else { - _sendMessage({ - 'type': 'seek', - 'value': (_currentPosition - 10).clamp(0, _totalDuration), - }); + _seekBy(shift ? -_bigStep : -_smallStep); } - _onHover(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight) { if (isLive) { _nextChannel(); + _onHover(); } else { - _sendMessage({ - 'type': 'seek', - 'value': (_currentPosition + 10).clamp(0, _totalDuration), - }); + _seekBy(shift ? _bigStep : _smallStep); } - _onHover(); return KeyEventResult.handled; } if (isLive && key == LogicalKeyboardKey.arrowUp) { @@ -467,9 +592,68 @@ class _PlayerScreenState extends ConsumerState { _onHover(); return KeyEventResult.handled; } + if (!isLive) { + if (key == LogicalKeyboardKey.keyJ) { + _seekBy(-_bigStep); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.keyL) { + _seekBy(_bigStep); + return KeyEventResult.handled; + } + // 0-9 : saut direct au dixième correspondant du contenu. + final decile = _decileFor(key); + if (decile != null) { + _seekTo(_totalDuration * decile / 10); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.period) { + _stepRate(1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.comma) { + _stepRate(-1); + return KeyEventResult.handled; + } + } return KeyEventResult.ignored; } + /// Touche 0-9 (rangée du haut ou pavé numérique) → dixième visé. + /// Table non `const` : `LogicalKeyboardKey` redéfinit `==`, ce qui interdit + /// une table constante. + static final Map _digitKeys = { + LogicalKeyboardKey.digit0: 0, + LogicalKeyboardKey.digit1: 1, + LogicalKeyboardKey.digit2: 2, + LogicalKeyboardKey.digit3: 3, + LogicalKeyboardKey.digit4: 4, + LogicalKeyboardKey.digit5: 5, + LogicalKeyboardKey.digit6: 6, + LogicalKeyboardKey.digit7: 7, + LogicalKeyboardKey.digit8: 8, + LogicalKeyboardKey.digit9: 9, + LogicalKeyboardKey.numpad0: 0, + LogicalKeyboardKey.numpad1: 1, + LogicalKeyboardKey.numpad2: 2, + LogicalKeyboardKey.numpad3: 3, + LogicalKeyboardKey.numpad4: 4, + LogicalKeyboardKey.numpad5: 5, + LogicalKeyboardKey.numpad6: 6, + LogicalKeyboardKey.numpad7: 7, + LogicalKeyboardKey.numpad8: 8, + LogicalKeyboardKey.numpad9: 9, + }; + + int? _decileFor(LogicalKeyboardKey key) => _digitKeys[key]; + + void _stepRate(int direction) { + final index = _rates.indexOf(_rate); + final next = (index < 0 ? _rates.indexOf(1.0) : index) + direction; + if (next < 0 || next >= _rates.length) return; + _setRate(_rates[next]); + } + String _formatDuration(Duration d) { String twoDigits(int n) => n.toString().padLeft(2, '0'); final hours = twoDigits(d.inHours); @@ -748,9 +932,28 @@ class _PlayerScreenState extends ConsumerState { // Center Loading Indicator (only show when loading) if (_isLoading) - const Center( - child: CircularProgressIndicator( - color: AppColors.primary, + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + color: AppColors.primary, + ), + // Un saut hors de la zone transcodée relance + // l'encodeur : quelques secondes d'attente, + // autant dire pourquoi. + if (_seekReloading) ...[ + const SizedBox(height: 16), + Text( + 'Reprise à ' + '${_formatDuration(Duration(seconds: _currentPosition.toInt()))}…', + style: GoogleFonts.karla( + color: AppColors.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ], ), ), @@ -787,6 +990,11 @@ class _PlayerScreenState extends ConsumerState { activeTrackColor: AppColors.primary, inactiveTrackColor: AppColors.onSurface.withOpacity(0.2), + // Portion déjà transcodée : navigable + // instantanément, contrairement au + // reste qui redémarre l'encodeur. + secondaryActiveTrackColor: + AppColors.onSurface.withOpacity(0.4), thumbColor: AppColors.onSurface, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, @@ -795,7 +1003,12 @@ class _PlayerScreenState extends ConsumerState { AppColors.primary.withOpacity(0.2), ), child: Slider( - value: _currentPosition, + value: _currentPosition + .clamp(0.0, _totalDuration), + secondaryTrackValue: _seekableEnd > 0 + ? _seekableEnd + .clamp(0.0, _totalDuration) + : null, min: 0, max: _totalDuration, onChanged: (val) => setState( @@ -804,9 +1017,7 @@ class _PlayerScreenState extends ConsumerState { onChangeStart: (_) => setState(() => _isSeeking = true), onChangeEnd: (val) { - _sendMessage( - {'type': 'seek', 'value': val}, - ); + _seekTo(val); Future.delayed( const Duration(milliseconds: 500), () { @@ -836,68 +1047,91 @@ class _PlayerScreenState extends ConsumerState { const SizedBox(height: 12), // Buttons - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildGlassIconButton( - icon: Icons.replay_10_rounded, - onTap: () => _sendMessage({ - 'type': 'seek', - 'value': (_currentPosition - 10) - .clamp(0, _totalDuration), - }), - transparent: true, - tooltip: 'Reculer de 10 s', - ), - const SizedBox(width: 24), - _buildGlassIconButton( - icon: _isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - onTap: _togglePlayPause, - size: 56, - iconSize: 32, - tooltip: _isPlaying ? 'Pause' : 'Lecture', - ), - const SizedBox(width: 24), - _buildGlassIconButton( - icon: Icons.forward_10_rounded, - onTap: () => _sendMessage({ - 'type': 'seek', - 'value': (_currentPosition + 10) - .clamp(0, _totalDuration), - }), - transparent: true, - tooltip: 'Avancer de 10 s', - ), - const Spacer(), - QualitySelectorButton( - current: _quality, - onSelected: _changeQuality, - ), - const SizedBox(width: 16), - _buildGlassIconButton( - icon: _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - onTap: _toggleMute, - transparent: true, - tooltip: _isMuted - ? 'Activer le son' - : 'Couper le son', - ), - const SizedBox(width: 16), - _buildGlassIconButton( - icon: _isFullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - onTap: _toggleFullscreen, - transparent: true, - tooltip: _isFullscreen - ? 'Quitter le plein écran' - : 'Plein écran', - ), - ], + LayoutBuilder( + builder: (context, constraints) { + // Sous 720 px, les sauts de 60 s et le + // sélecteur de vitesse sortent de la barre + // plutôt que de la faire déborder. + final compact = constraints.maxWidth < 720; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!compact) ...[ + _buildGlassIconButton( + icon: Icons.fast_rewind_rounded, + onTap: () => _seekBy(-_bigStep), + transparent: true, + tooltip: 'Reculer de 1 min', + ), + const SizedBox(width: 16), + ], + _buildGlassIconButton( + icon: Icons.replay_10_rounded, + onTap: () => _seekBy(-_smallStep), + transparent: true, + tooltip: 'Reculer de 10 s', + ), + const SizedBox(width: 24), + _buildGlassIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 56, + iconSize: 32, + tooltip: + _isPlaying ? 'Pause' : 'Lecture', + ), + const SizedBox(width: 24), + _buildGlassIconButton( + icon: Icons.forward_10_rounded, + onTap: () => _seekBy(_smallStep), + transparent: true, + tooltip: 'Avancer de 10 s', + ), + if (!compact) ...[ + const SizedBox(width: 16), + _buildGlassIconButton( + icon: Icons.fast_forward_rounded, + onTap: () => _seekBy(_bigStep), + transparent: true, + tooltip: 'Avancer de 1 min', + ), + ], + const Spacer(), + if (!compact) ...[ + _buildRateSelector(), + const SizedBox(width: 16), + ], + QualitySelectorButton( + current: _quality, + onSelected: _changeQuality, + ), + const SizedBox(width: 16), + _buildGlassIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + transparent: true, + tooltip: _isMuted + ? 'Activer le son' + : 'Couper le son', + ), + const SizedBox(width: 16), + _buildGlassIconButton( + icon: _isFullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + onTap: _toggleFullscreen, + transparent: true, + tooltip: _isFullscreen + ? 'Quitter le plein écran' + : 'Plein écran', + ), + ], + ); + }, ), ], ), @@ -914,6 +1148,73 @@ class _PlayerScreenState extends ConsumerState { ); } + /// Sélecteur de vitesse de lecture — utile pour parcourir un + /// enregistrement long sans sauter à l'aveugle. + Widget _buildRateSelector() { + return Tooltip( + message: 'Vitesse de lecture', + child: PopupMenuButton( + initialValue: _rate, + onSelected: _setRate, + color: AppColors.surfaceContainer, + tooltip: '', + itemBuilder: (context) => [ + for (final rate in _rates) + PopupMenuItem( + value: rate, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + rate == _rate + ? Icons.check_rounded + : Icons.speed_rounded, + size: 16, + color: rate == _rate + ? AppColors.primary + : AppColors.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + _formatRate(rate), + style: TextStyle( + color: rate == _rate + ? AppColors.primary + : AppColors.onSurface, + ), + ), + ], + ), + ), + ], + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.onSurface.withOpacity(0.15)), + ), + child: Text( + _formatRate(_rate), + style: GoogleFonts.karla( + color: _rate == 1.0 ? AppColors.onSurface : AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + /// « 1× », « 1.5× » — sans décimale inutile. + static String _formatRate(double rate) { + final text = rate == rate.roundToDouble() + ? rate.toStringAsFixed(0) + : rate.toString(); + return '$text×'; + } + Widget _buildGlassIconButton({ required IconData icon, required VoidCallback onTap, diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 8225235..0836e68 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -8,6 +8,7 @@ import '../../../core/api/recording_requests.dart'; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; +import '../providers/playback_positions_provider.dart'; import '../providers/recordings_refresh.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; @@ -780,14 +781,15 @@ class _ProgrammeCard extends StatelessWidget { // ONGLET 2 — ENREGISTREMENTS // ═══════════════════════════════════════════════════════════════════════════ -class _RecordingsListView extends StatefulWidget { +class _RecordingsListView extends ConsumerStatefulWidget { final PlaylistConfig playlist; const _RecordingsListView({required this.playlist}); @override - State<_RecordingsListView> createState() => _RecordingsListViewState(); + ConsumerState<_RecordingsListView> createState() => + _RecordingsListViewState(); } -class _RecordingsListViewState extends State<_RecordingsListView> { +class _RecordingsListViewState extends ConsumerState<_RecordingsListView> { List _recordings = []; bool _isLoading = true; String? _error; @@ -917,6 +919,35 @@ class _RecordingsListViewState extends State<_RecordingsListView> { _fetchRecordings(); } + /// Clé de reprise d'un enregistrement (même convention que le lecteur). + static String _resumeKey(String recordingId) => 'rec_$recordingId'; + + /// Position sauvegardée, ou 0 s'il n'y en a pas d'exploitable. + double _savedPosition(Map rec) { + final id = rec['id'] as String?; + if (id == null || id.isEmpty) return 0; + return ref.read(playbackPositionsProvider).getPosition(_resumeKey(id)); + } + + /// Durée du média : celle mesurée par le serveur si disponible, sinon la + /// durée programmée. Un enregistrement arrêté en avance est plus court que + /// `end_time - start_time`. + Duration? _recordingDuration(Map rec) { + final probed = rec['duration_seconds']; + if (probed is num && probed > 0) { + return Duration(seconds: probed.round()); + } + try { + if (rec['start_time'] != null && rec['end_time'] != null) { + final start = DateTime.parse(rec['start_time'].toString()); + final end = DateTime.parse(rec['end_time'].toString()); + final scheduled = end.difference(start); + if (scheduled > Duration.zero) return scheduled; + } + } catch (_) {} + return null; + } + Future _playRecording(BuildContext context, Map rec) async { final recordingId = rec['id'] as String?; final title = rec['title'] as String? ?? 'Enregistrement'; @@ -928,17 +959,52 @@ class _RecordingsListViewState extends State<_RecordingsListView> { return; } - Duration? recordingDuration; - try { - if (rec['start_time'] != null && rec['end_time'] != null) { - final start = DateTime.parse(rec['start_time'].toString()); - final end = DateTime.parse(rec['end_time'].toString()); - recordingDuration = end.difference(start); + final recordingDuration = _recordingDuration(rec); + final saved = _savedPosition(rec); + + // Reprise proposée, jamais imposée : un enregistrement déjà vu se + // relance parfois depuis le début. + double? startAt; + if (saved > 30) { + if (!mounted) return; + final resume = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surfaceContainer, + title: Text( + 'Reprendre la lecture ?', + style: GoogleFonts.fraunces(color: AppColors.onSurface), + ), + content: Text( + '« $title » a été interrompu à ' + '${PlaybackPositionsNotifier.formatTime(saved)}.', + style: const TextStyle(color: AppColors.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Depuis le début'), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: const Text('Reprendre'), + ), + ], + ), + ); + if (resume == null) return; // dialogue fermé : on n'ouvre rien + if (resume) { + startAt = saved; + } else { + ref + .read(playbackPositionsProvider.notifier) + .clearPosition(_resumeKey(recordingId)); } - } catch (_) {} + } if (!mounted) return; - Navigator.of(context).push( + await Navigator.of(context).push( MaterialPageRoute( builder: (ctx) => PlayerScreen( streamId: recordingId, @@ -947,9 +1013,51 @@ class _RecordingsListViewState extends State<_RecordingsListView> { streamType: StreamType.recording, containerExtension: 'ts', duration: recordingDuration, + startTime: startAt, ), ), ); + // Au retour du lecteur, la progression affichée dans la liste doit + // refléter la position atteinte. + if (mounted) setState(() {}); + } + + /// Barre « déjà vu jusqu'ici » sous un enregistrement entamé. + List _buildResumeIndicator(Map rec) { + final saved = _savedPosition(rec); + if (saved <= 30) return const []; + + final total = _recordingDuration(rec)?.inSeconds ?? 0; + final ratio = total > 0 ? (saved / total).clamp(0.0, 1.0) : null; + + return [ + const SizedBox(height: 6), + Row( + children: [ + if (ratio != null) + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: ratio, + minHeight: 4, + backgroundColor: AppColors.onSurface.withOpacity(0.1), + valueColor: + const AlwaysStoppedAnimation(AppColors.primary), + ), + ), + ), + if (ratio != null) const SizedBox(width: 8), + Text( + 'Reprendre à ${PlaybackPositionsNotifier.formatTime(saved)}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + ), + ), + ], + ), + ]; } Future _showLogs(String id, String title) async { @@ -1037,6 +1145,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> { @override Widget build(BuildContext context) { + // Les positions de reprise arrivent de façon asynchrone (SharedPreferences) + // et changent au retour du lecteur : sans cette écoute, la mention + // « Reprendre à … » n'apparaîtrait qu'au prochain rafraîchissement. + ref.watch(playbackPositionsProvider); return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -1178,6 +1290,8 @@ class _RecordingsListViewState extends State<_RecordingsListView> { ), ), ], + if (status == 'completed') + ..._buildResumeIndicator(rec), if (rec['error_reason'] != null) Text( '⚠ ${rec['error_reason']}', @@ -1228,13 +1342,18 @@ class _RecordingsListViewState extends State<_RecordingsListView> { ), if (status == 'completed') IconButton( - icon: const Icon( - Icons.play_circle_outline, + icon: Icon( + _savedPosition(rec) > 30 + ? Icons.play_circle_fill + : Icons.play_circle_outline, color: AppColors.success, size: 20, ), - tooltip: 'Lecture', - onPressed: () => _playRecording(context, rec), + tooltip: _savedPosition(rec) > 30 + ? 'Reprendre' + : 'Lecture', + onPressed: () => + _playRecording(context, rec), ), IconButton( icon: const Icon( diff --git a/web/xf-player-core.js b/web/xf-player-core.js index d4d0a0f..3b16e91 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -161,6 +161,13 @@ this.injectedDuration = parseFloat(p.get('duration') || '0'); this.isRecording = p.get('is_recording') === 'true'; + // Décalage du média par rapport au début du contenu. Un enregistrement + // ouvert à 45 min est transcodé par FFmpeg *depuis* 45 min : le média + // commence à 0 alors que la lecture, elle, est à 2700 s. Tout ce qui + // sort du lecteur (position, recherche) est exprimé en temps absolu. + this.mediaOffset = parseFloat(p.get('offset') || '0'); + if (!isFinite(this.mediaOffset) || this.mediaOffset < 0) this.mediaOffset = 0; + // Profil de départ : forçable via ?buffer=balanced|safe var forced = p.get('buffer'); this.profile = PROFILES[forced] ? PROFILES[forced] : PROFILES.fast; @@ -535,10 +542,24 @@ v.addEventListener('ended', function () { self.send({ type: 'playback_ended', - duration: isFinite(v.duration) ? v.duration : self.injectedDuration + duration: self._totalDuration() }); }); + // Le front de transcodage avance : le parent doit pouvoir griser la + // portion pas encore navigable de la barre de progression. `progress` + // se déclenche à chaque bloc reçu — bridé à 2 s, sinon chaque segment + // provoquerait une reconstruction de l'interface Flutter. + v.addEventListener('progress', function () { + if (!self._started) return; + var now = Date.now(); + if (now - (self._lastProgressSent || 0) < 2000) return; + self._lastProgressSent = now; + self._sendPosition(); + }); + + v.addEventListener('seeked', function () { self._sendPosition(); }); + // Remontée de position (5 s) — même contrat qu'avant. this._posTimer = setInterval(function () { self._reportPosition(); }, 5000); @@ -549,29 +570,102 @@ document.addEventListener('click', activity, { passive: true }); }; + /** Fin de la plage réellement navigable, en temps média. */ + XFPlayer.prototype._seekableEnd = function () { + var v = this.video; + try { + if (v.seekable && v.seekable.length > 0) { + return v.seekable.end(v.seekable.length - 1); + } + } catch (e) {} + try { + if (v.buffered && v.buffered.length > 0) { + return v.buffered.end(v.buffered.length - 1); + } + } catch (e) {} + return isFinite(v.duration) ? v.duration : 0; + }; + + /** Durée totale du contenu (et non du seul média chargé), en secondes. */ + XFPlayer.prototype._totalDuration = function () { + var v = this.video; + var duration = v.duration; + var hlsDur = this._hlsDuration || 0; + if (!isFinite(duration) || isNaN(duration) || duration < 1) { + duration = hlsDur; + } else if (hlsDur > duration) { + duration = hlsDur; + } + + // Temps absolu : le média peut commencer après le début du contenu. + var total = duration > 0 ? this.mediaOffset + duration : 0; + + // La durée annoncée par l'app fait foi tant que le média n'en connaît + // qu'une fraction — cas d'une playlist `event` encore en cours de + // transcodage, où la durée grandirait sous la barre de progression. + // Au-delà, le média a raison : les métadonnées Xtream sont approximatives. + if (this.injectedDuration > 0 && total < this.injectedDuration * 0.98) { + return this.injectedDuration; + } + return total; + }; + XFPlayer.prototype._reportPosition = function () { var v = this.video; if (!(v.currentTime > 0) || v.paused || v.readyState <= 2) return; if (Math.abs(v.currentTime - this._reportedTime) < 1) return; this._reportedTime = v.currentTime; + this._sendPosition(); + }; - var duration = v.duration; - var hlsDur = this._hlsDuration || 0; - if (!isFinite(duration) || isNaN(duration) || duration < 1) { - duration = hlsDur > 0 ? hlsDur : (this.injectedDuration > 0 ? this.injectedDuration : 0); - } else if (hlsDur > duration) { - duration = hlsDur; - } else if (this.injectedDuration > 0 && duration < this.injectedDuration * 0.9) { - duration = this.injectedDuration; - } - + /** Envoi immédiat de l'état de lecture (position, durée, zone navigable). */ + XFPlayer.prototype._sendPosition = function () { this.send({ type: 'playback_position', - currentTime: v.currentTime, - duration: duration + currentTime: this.mediaOffset + this.video.currentTime, + duration: this._totalDuration(), + seekableEnd: this.mediaOffset + this._seekableEnd() }); }; + /** + * Recherche à un instant ABSOLU du contenu. + * + * Tant que la cible est dans la zone déjà transcodée, c'est une simple + * affectation de `currentTime`. Au-delà, aucune quantité d'attente ne la + * rendra disponible rapidement : le parent est prévenu pour relancer le + * flux à cet instant (nouvelle session FFmpeg `?start=`). + */ + XFPlayer.prototype.seekTo = function (absolute) { + if (!isFinite(absolute)) return; + var v = this.video; + var target = absolute - this.mediaOffset; + var end = this._seekableEnd(); + + if (target < 0) { + // Avant le début du média chargé : cette portion n'est pas dans cette + // session de transcodage, il faut en relancer une plus tôt. + this.send({ type: 'seek_out_of_range', value: Math.max(0, absolute) }); + return; + } + + // Marge de 10 s : juste devant le front de transcodage, attendre coûte + // moins cher que relancer un encodeur. + if (end > 0 && target > end + 10) { + this.send({ type: 'seek_out_of_range', value: absolute }); + return; + } + + try { + v.currentTime = end > 0 ? Math.min(target, Math.max(0, end - 0.5)) : target; + } catch (e) { + this.send({ type: 'seek_out_of_range', value: absolute }); + return; + } + this._reportedTime = v.currentTime; + this._sendPosition(); + }; + XFPlayer.prototype._wireParentMessages = function () { var self = this; global.addEventListener('message', function (event) { @@ -589,7 +683,12 @@ v.pause(); break; case 'seek': - if (isFinite(d.value)) v.currentTime = d.value; + self.seekTo(d.value); + break; + case 'set_rate': + if (isFinite(d.value) && d.value > 0) { + try { v.playbackRate = Math.max(0.25, Math.min(4, d.value)); } catch (e) {} + } break; case 'set_volume': v.volume = Math.max(0, Math.min(1, d.value));