Enregistrements : lecteur avancé, navigation et reprise

La lecture d'un enregistrement ne permettait ni de reculer, ni d'avancer,
ni de reprendre où on s'était arrêté.

Cause côté serveur : la playlist était servie en
`EXT-X-PLAYLIST-TYPE:VOD`. hls.js la considère alors 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. Elle passe en `EVENT` : la zone navigable
grandit au rythme de l'encodage.

FFmpeg transcodant séquentiellement, sauter à 45 min imposait malgré tout
d'attendre qu'il y arrive. La playlist accepte donc `?start=<secondes>` et
démarre une session dédiée (`-ss` avant `-i`). Les segments y sont
préfixés par l'offset (`t2700/segment_000.ts`) : la query string ne
survit pas aux URLs relatives de la playlist, deux sessions du même
enregistrement se seraient mélangées. Les sessions d'offsets abandonnés
sont fermées dès qu'aucun lecteur ne les consomme (killIdleSiblings).

Côté lecteur, tout ce qui sort du moteur est désormais en temps absolu
(`offset` réinjecté) : une recherche dans la zone déjà transcodée est un
simple `currentTime`, au-delà le lecteur répond `seek_out_of_range` et
l'app relance le flux à cet instant.

Ajouts visibles :
- reprise proposée à l'ouverture d'un enregistrement entamé, et mention
  « Reprendre à … » avec sa progression dans la liste ;
- durée réelle mesurée par ffprobe (`duration_seconds`), mise en cache
  tant que le fichier ne bouge pas — `end_time - start_time` n'est que la
  durée programmée ;
- sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà
  transcodée visible sur la barre ;
- raccourcis K/espace, J/L, Maj+←/→, 0-9, F, M, virgule/point.

L'identifiant de vue de plateforme porte maintenant un numéro de
génération : sans lui, réutiliser le même identifiant laissait l'ancienne
iframe en place (`registerViewFactory` conserve la première fabrique
enregistrée) — ce qui affectait déjà le retour sur une chaîne
précédemment zappée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
This commit is contained in:
Claude committed 2026-09-10 16:00:13 +00:00
1 parent d6c7184475
commit b95d72fe80
9 files changed
+905 -133

No files matched your search

+3
View File
@@ -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/<id>/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)
+43
View File
@@ -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=<secondes>`), 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]}',
);
}
+78 -2
View File
@@ -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<Response> 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 = <Map<String, dynamic>>[];
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<String, ({int size, int mtimeMs, double duration})>
_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<double?> _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<String, dynamic> _enrich(Recording r, DateTime now) {
final map = r.toMap();
+47 -7
View File
@@ -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('/<streamId>/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('/<streamId>/<segment>',
(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('/<streamId>/<offsetKey>/<segment>',
(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('/<streamId>/<segment>',
(Request request, String streamId, String segment) {
return serveSegment(streamId, recordingOffsetKey(0), segment);
});
return router.call;
+22
View File
@@ -185,6 +185,28 @@ class FfmpegSessionManager {
}
}
/// Tue les sessions frères d'un même contenu (`rec_<id>_t0`, `rec_<id>_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()) {
+69
View File
@@ -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);
});
});
}
+396 -95
View File
@@ -61,6 +61,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
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<PlayerScreen> {
/// 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<PlayerScreen> {
}
// 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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
}
}
/// 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<PlayerScreen> {
// 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<PlayerScreen> {
}
}
/// 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 = <double>[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<PlayerScreen> {
_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<PlayerScreen> {
_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<LogicalKeyboardKey, int> _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<PlayerScreen> {
// 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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
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<PlayerScreen> {
);
}
/// 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<double>(
initialValue: _rate,
onSelected: _setRate,
color: AppColors.surfaceContainer,
tooltip: '',
itemBuilder: (context) => [
for (final rate in _rates)
PopupMenuItem<double>(
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,
+134 -15
View File
@@ -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<dynamic> _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<String, dynamic> 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<String, dynamic> 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<void> _playRecording(BuildContext context, Map<String, dynamic> 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<bool>(
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<Widget> _buildResumeIndicator(Map<String, dynamic> 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<void> _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(
+113 -14
View File
@@ -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));