mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Enregistrements : corriger le démarrage de la lecture
La lecture d'un enregistrement démarrait au milieu du programme et se coupait aussitôt ; il fallait relancer une deuxième fois pour qu'elle tienne. Cause principale, introduite avec le passage de la playlist en `EVENT` : tant que le transcodage n'est pas terminé, la playlist n'a pas d'`EXT-X-ENDLIST`, et hls.js traite alors tout contenu comme du direct. Sans `startPosition` explicite il démarre au « bord du direct » — donc collé au front d'encodage, sans la moindre avance de segments. D'où un départ en plein milieu, puis une coupure immédiate. Au second essai, le transcodage était terminé (ENDLIST présent, contenu traité en VOD) et tout se passait bien. Hors direct : `startPosition: 0` (ou la position de reprise), rattrapage de latence neutralisé (il accélérait la lecture puis forçait un saut en avant vers un direct inexistant), et délai d'attente de playlist porté de 10 s à 45 s — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort. Cause de fond : la lecture ré-encodait la vidéo. Or la capture se fait en `-c copy`, donc le fichier contient le codec de la chaîne, presque toujours du H.264, directement lisible en HLS. Le ré-encodage tenait à peine le temps réel en 1080p, ce qui laissait le lecteur courir après l'encodeur en permanence. La vidéo est désormais copiée telle quelle quand elle est en H.264 (seul l'audio est converti en AAC) : la segmentation va à la vitesse du disque, l'enregistrement devient navigable en quelques secondes et les sauts ne redémarrent presque plus jamais FFmpeg. Les codecs que le navigateur ne sait pas lire (HEVC, MPEG-2…) restent ré-encodés. En complément : - le serveur attend trois segments d'avance avant de servir la playlist, et sert ce qu'il a plutôt qu'une erreur si le délai expire ; - le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le lecteur mobile le faisait déjà ; - `XFPlayer.destroy()` retire ses écouteurs : sans ça, une relance laissait l'instance précédente réagir aux commandes du parent et republier des positions périmées ; - les appels ffprobe passent par `MediaProbe`, qui mémorise durée et codec tant que le fichier ne change pas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1A6WRnoktWcT7ZG4b2rbC
This commit is contained in:
7 files changed
+277
-102
No files matched your search
@@ -3,6 +3,9 @@
|
||||
## Non publié
|
||||
|
||||
### ▶️ Lecture vidéo
|
||||
- **Fix du démarrage des enregistrements** : la lecture partait au milieu du programme et se coupait aussitôt, obligeant à relancer une deuxième fois. Une playlist encore en cours de transcodage n'a pas d'`EXT-X-ENDLIST` : hls.js la traite comme du direct et démarrait donc au « bord du direct », collé au front d'encodage, sans aucune avance de segments. `startPosition: 0` hors direct, plus la neutralisation du rattrapage de latence (qui accélérait la lecture puis forçait un saut en avant pour rejoindre un direct inexistant)
|
||||
- **Plus de ré-encodage inutile à la lecture d'un enregistrement** : la capture étant faite en copie, le fichier contient déjà du H.264 dans la quasi-totalité des cas. Il est désormais servi tel quel (`-c:v copy`, seul l'audio est converti en AAC) au lieu d'être ré-encodé à peine plus vite que le temps réel — la segmentation va maintenant à la vitesse du disque, l'enregistrement devient navigable en quelques secondes. Les codecs illisibles par le navigateur (HEVC, MPEG-2…) restent ré-encodés
|
||||
- **Démarrage plus robuste** : le serveur attend trois segments d'avance avant de servir la playlist (au lieu d'un seul, qui laissait le lecteur courir après l'encodeur), et sert ce qu'il a plutôt qu'une erreur si le délai expire. Côté navigateur, le délai d'attente de playlist passe de 10 s à 45 s hors direct — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort — et le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le faisait déjà le lecteur mobile
|
||||
- **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`, `,`/`.`
|
||||
|
||||
+10
-56
@@ -6,6 +6,7 @@ import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
@@ -97,69 +98,22 @@ class RecordingsApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminé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;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
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;
|
||||
}
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
|
||||
@@ -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 '../utils/media_probe.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
@@ -665,12 +666,31 @@ Handler createRecordingStreamHandler(
|
||||
body: 'Physical recording file not found');
|
||||
}
|
||||
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final start = parseRecordingStart(request.url.queryParameters['start']);
|
||||
final offsetKey = recordingOffsetKey(start);
|
||||
final sessionPrefix = 'rec_${streamId}_t';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
|
||||
// La capture est faite en `-c copy` : le fichier contient le codec
|
||||
// d'origine de la chaîne, presque toujours du H.264 — directement
|
||||
// lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p,
|
||||
// si bien que la lecture démarrait collée au front d'encodage et se
|
||||
// coupait à la moindre hésitation. En copie, la segmentation va à la
|
||||
// vitesse du disque : l'enregistrement devient navigable en quelques
|
||||
// secondes. Seuls les codecs que le navigateur ne sait pas lire
|
||||
// (HEVC, MPEG-2…) justifient encore un ré-encodage.
|
||||
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
||||
final canCopyVideo = videoCodec == 'h264';
|
||||
final useNvidiaGpu =
|
||||
!canCopyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled());
|
||||
|
||||
if (!sessionManager.contains(sessionId)) {
|
||||
print(
|
||||
'[Recording] $sessionId : ${canCopyVideo ? 'copie vidéo' : 'ré-encodage'}'
|
||||
' (codec source : ${videoCodec ?? 'inconnu'})',
|
||||
);
|
||||
}
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
@@ -680,9 +700,12 @@ Handler createRecordingStreamHandler(
|
||||
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.
|
||||
// En copie, la position atteinte est l'image-clé qui précède.
|
||||
if (start > 0) ...['-ss', '$start'],
|
||||
'-i', targetUrl,
|
||||
if (useNvidiaGpu) ...[
|
||||
if (canCopyVideo) ...[
|
||||
'-c:v', 'copy',
|
||||
] else if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
|
||||
'-bufsize', '6000k',
|
||||
@@ -710,7 +733,10 @@ Handler createRecordingStreamHandler(
|
||||
],
|
||||
);
|
||||
|
||||
final result = await sessionManager.waitForPlaylist(session);
|
||||
// Trois segments d'avance (~12 s) avant de rendre la main : c'est la
|
||||
// marge qui manquait au démarrage. En copie vidéo elle est produite en
|
||||
// une fraction de seconde, elle ne coûte donc que sur un ré-encodage.
|
||||
final result = await sessionManager.waitForPlaylist(session, minSegments: 3);
|
||||
if (!result.ready) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502,
|
||||
|
||||
@@ -143,22 +143,40 @@ class FfmpegSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the session's playlist references at least one segment.
|
||||
/// Fails fast when the process dies before producing output, returning
|
||||
/// the recent stderr for diagnostics.
|
||||
/// Waits until the session's playlist references at least [minSegments]
|
||||
/// segments. Fails fast when the process dies before producing output,
|
||||
/// returning the recent stderr for diagnostics.
|
||||
///
|
||||
/// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la
|
||||
/// playlist dès le premier segment le fait partir avec 4 s de marge sur un
|
||||
/// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La
|
||||
/// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel
|
||||
/// que soit son nombre de segments : rien de plus n'arrivera.
|
||||
Future<({bool ready, String? error})> waitForPlaylist(
|
||||
FfmpegSession session, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
if (playlistFile.existsSync()) {
|
||||
final content = playlistFile.readAsStringSync();
|
||||
final segments = '.ts'.allMatches(content).length;
|
||||
if (segments >= minSegments ||
|
||||
(segments > 0 && content.contains('#EXT-X-ENDLIST'))) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
}
|
||||
if (session.exited) {
|
||||
// Un encodeur qui s'est terminé proprement en ayant produit moins de
|
||||
// segments que demandé a simplement fini : c'est un succès.
|
||||
if (session.exitCode == 0 &&
|
||||
playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (
|
||||
ready: false,
|
||||
error: 'FFmpeg exited (${session.exitCode}): '
|
||||
@@ -169,6 +187,11 @@ class FfmpegSessionManager {
|
||||
// latence pure entre la disponibilité de la playlist et sa réponse.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
// Le délai est écoulé : mieux vaut servir ce qui existe que rien.
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (ready: false, error: 'Timeout waiting for transcoder');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
///
|
||||
/// Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
/// tournerait en boucle.
|
||||
class MediaProbe {
|
||||
MediaProbe._();
|
||||
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
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ée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier introuvable, conteneur sans durée).
|
||||
static Future<double?> duration(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'duration',
|
||||
args: const [
|
||||
'-show_entries', 'format=duration',
|
||||
],
|
||||
);
|
||||
final seconds = double.tryParse(value ?? '');
|
||||
if (seconds == null || !seconds.isFinite || seconds <= 0) return null;
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…),
|
||||
/// ou `null` si indéterminable.
|
||||
static Future<String?> videoCodec(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'videoCodec',
|
||||
args: const [
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
],
|
||||
);
|
||||
return (value == null || value.isEmpty) ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
static Future<String?> _probe(
|
||||
String path, {
|
||||
required String field,
|
||||
required List<String> args,
|
||||
}) async {
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = File(path).statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = '$field@$path';
|
||||
final cached = _cache[key];
|
||||
if (cached != null && cached.matches(stat)) return cached.value;
|
||||
|
||||
try {
|
||||
final result = await Process.run(ffprobePath(), [
|
||||
'-v', 'error',
|
||||
...args,
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]);
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
final output = (result.stdout as String).trim().split('\n').first.trim();
|
||||
if (output.isEmpty || output == 'N/A') return null;
|
||||
_cache[key] = _CacheEntry(stat, output);
|
||||
return output;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final int size;
|
||||
final int mtimeMs;
|
||||
final String value;
|
||||
|
||||
_CacheEntry(FileStat stat, this.value)
|
||||
: size = stat.size,
|
||||
mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
|
||||
bool matches(FileStat stat) =>
|
||||
stat.size == size &&
|
||||
stat.modified.millisecondsSinceEpoch == mtimeMs;
|
||||
}
|
||||
+48
-23
@@ -96,35 +96,60 @@
|
||||
playOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: showError,
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
// Un enregistrement ou un film démarre une session FFmpeg côté
|
||||
// serveur : le premier essai peut tomber pendant que l'encodeur
|
||||
// se met en route. Le player mobile réessayait déjà seul ; ici
|
||||
// l'utilisateur devait relancer la lecture à la main.
|
||||
var retryCount = 0;
|
||||
var maxRetries = 3;
|
||||
var player = null;
|
||||
|
||||
function buildPlayer() {
|
||||
if (player) { try { player.destroy(); } catch (e) {} }
|
||||
|
||||
player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
retryCount = 0;
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: function (msg) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
showLoading(
|
||||
'Préparation du flux ' + retryCount + '/' + maxRetries
|
||||
);
|
||||
setTimeout(buildPlayer, 1500);
|
||||
} else {
|
||||
showError(msg);
|
||||
}
|
||||
},
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
}
|
||||
|
||||
playOverlay.addEventListener('click', function () {
|
||||
video.play();
|
||||
playOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
buildPlayer();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+55
-14
@@ -181,8 +181,19 @@
|
||||
this._destroyed = false;
|
||||
this._reportedTime = 0;
|
||||
this._hlsDuration = 0;
|
||||
|
||||
// Écouteurs posés par cette instance, retirés à la destruction : sans
|
||||
// ça, une relance après échec laisserait l'ancienne instance réagir aux
|
||||
// commandes du parent et republier des positions périmées.
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
/** addEventListener + mémorisation pour un retrait propre. */
|
||||
XFPlayer.prototype._on = function (target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._listeners.push({ target: target, type: type, handler: handler });
|
||||
};
|
||||
|
||||
XFPlayer.prototype.log = function (m) {
|
||||
if (global.console && console.log) console.log(this.logPrefix + ' ' + m);
|
||||
};
|
||||
@@ -231,6 +242,8 @@
|
||||
|
||||
XFPlayer.prototype._hlsConfig = function () {
|
||||
var prof = this.profile.hls;
|
||||
var isLive = this.type === 'live';
|
||||
|
||||
return {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -248,15 +261,39 @@
|
||||
startFragPrefetch: true, // précharge le 1er fragment pendant le parse
|
||||
testBandwidth: false, // pas de mesure préalable : on joue tout de suite
|
||||
|
||||
// ---- Début de lecture ----
|
||||
// Un enregistrement (ou un film) est transcodé au fil de l'eau : sa
|
||||
// playlist n'a pas encore d'`EXT-X-ENDLIST`, donc hls.js la considère
|
||||
// comme du direct. Sans consigne explicite il démarre alors au « bord
|
||||
// du direct », c'est-à-dire collé au front d'encodage : la lecture
|
||||
// partait au milieu du programme et se coupait aussitôt, faute de
|
||||
// segments d'avance. `0` = au début, comme attendu d'un contenu
|
||||
// à la demande. En direct, -1 conserve le démarrage au bord du direct.
|
||||
startPosition: isLive ? -1 : (this.startTime > 0 ? this.startTime : 0),
|
||||
|
||||
// ---- Live ----
|
||||
liveSyncDurationCount: prof.liveSyncDurationCount,
|
||||
liveMaxLatencyDurationCount: prof.liveMaxLatencyDurationCount,
|
||||
maxLiveSyncPlaybackRate: 1.15,
|
||||
// Hors direct, ces deux réglages doivent être neutralisés : ils
|
||||
// accélèrent la lecture et finissent par forcer un saut en avant pour
|
||||
// « rattraper » un direct qui n'existe pas.
|
||||
// 86400 segments = hors d'atteinte, sans recourir à Infinity dont
|
||||
// hls.js fait ensuite des multiplications.
|
||||
liveMaxLatencyDurationCount: isLive
|
||||
? prof.liveMaxLatencyDurationCount
|
||||
: 86400,
|
||||
maxLiveSyncPlaybackRate: isLive ? 1.15 : 1,
|
||||
|
||||
// ---- Robustesse ----
|
||||
nudgeOffset: 0.6,
|
||||
nudgeMaxRetries: 20,
|
||||
abrEwmaDefaultEstimate: 2000000,
|
||||
// Hors direct, la première requête de playlist peut légitimement
|
||||
// bloquer le temps que FFmpeg démarre et produise ses premiers
|
||||
// segments. Le défaut de 10 s de hls.js transformait ce démarrage en
|
||||
// « erreur de chargement » — il fallait relancer la lecture pour
|
||||
// tomber sur une session déjà chaude.
|
||||
manifestLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
levelLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
manifestLoadingRetryDelay: 500,
|
||||
levelLoadingRetryDelay: 500,
|
||||
fragLoadingRetryDelay: 500,
|
||||
@@ -520,26 +557,26 @@
|
||||
var self = this;
|
||||
var v = this.video;
|
||||
|
||||
v.addEventListener('loadedmetadata', function () {
|
||||
this._on(v, 'loadedmetadata', function () {
|
||||
if (self.startTime > 0 && self.startTime < v.duration) {
|
||||
v.currentTime = self.startTime;
|
||||
}
|
||||
});
|
||||
|
||||
v.addEventListener('playing', function () {
|
||||
this._on(v, 'playing', function () {
|
||||
self._started = true;
|
||||
self.onReady();
|
||||
self.send({ type: 'playback_status', status: 'playing' });
|
||||
});
|
||||
|
||||
v.addEventListener('pause', function () {
|
||||
this._on(v, 'pause', function () {
|
||||
self.send({ type: 'playback_status', status: 'paused' });
|
||||
});
|
||||
|
||||
v.addEventListener('waiting', function () { self._noteStall(); });
|
||||
v.addEventListener('stalled', function () { self._noteStall(); });
|
||||
this._on(v, 'waiting', function () { self._noteStall(); });
|
||||
this._on(v, 'stalled', function () { self._noteStall(); });
|
||||
|
||||
v.addEventListener('ended', function () {
|
||||
this._on(v, 'ended', function () {
|
||||
self.send({
|
||||
type: 'playback_ended',
|
||||
duration: self._totalDuration()
|
||||
@@ -550,7 +587,7 @@
|
||||
// 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 () {
|
||||
this._on(v, 'progress', function () {
|
||||
if (!self._started) return;
|
||||
var now = Date.now();
|
||||
if (now - (self._lastProgressSent || 0) < 2000) return;
|
||||
@@ -558,16 +595,16 @@
|
||||
self._sendPosition();
|
||||
});
|
||||
|
||||
v.addEventListener('seeked', function () { self._sendPosition(); });
|
||||
this._on(v, 'seeked', function () { self._sendPosition(); });
|
||||
|
||||
// Remontée de position (5 s) — même contrat qu'avant.
|
||||
this._posTimer = setInterval(function () { self._reportPosition(); }, 5000);
|
||||
|
||||
// Activité utilisateur → masquage auto des contrôles côté Flutter.
|
||||
function activity() { self.send({ type: 'user_activity' }); }
|
||||
document.addEventListener('mousemove', activity, { passive: true });
|
||||
document.addEventListener('touchstart', activity, { passive: true });
|
||||
document.addEventListener('click', activity, { passive: true });
|
||||
this._on(document, 'mousemove', activity, { passive: true });
|
||||
this._on(document, 'touchstart', activity, { passive: true });
|
||||
this._on(document, 'click', activity, { passive: true });
|
||||
};
|
||||
|
||||
/** Fin de la plage réellement navigable, en temps média. */
|
||||
@@ -668,7 +705,7 @@
|
||||
|
||||
XFPlayer.prototype._wireParentMessages = function () {
|
||||
var self = this;
|
||||
global.addEventListener('message', function (event) {
|
||||
this._on(global, 'message', function (event) {
|
||||
// N'accepter que les commandes émises par notre propre origine
|
||||
// (le côté Flutter filtre déjà les messages entrants de la même façon).
|
||||
if (event.origin !== global.location.origin) return;
|
||||
@@ -705,6 +742,10 @@
|
||||
XFPlayer.prototype.destroy = function () {
|
||||
this._destroyed = true;
|
||||
clearInterval(this._posTimer);
|
||||
this._listeners.forEach(function (l) {
|
||||
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
||||
});
|
||||
this._listeners = [];
|
||||
try { if (this.hls) this.hls.destroy(); } catch (e) {}
|
||||
try {
|
||||
if (this.mpegts) {
|
||||
|
||||
Reference in new issue
Block a user