Files
xtremflow/web/xf-player-core.js
T
MichaelandClaude Opus 5.5 a739d6451c Diagnostic : le lecteur remonte ses freezes dans les logs du serveur
Les freezes (image et son figés puis reprise) se produisent dans le
navigateur de l'utilisateur ; le serveur n'en voyait rien. Le lecteur
envoie désormais chaque coupure (durée, buffer), saut (de → vers),
erreur MPEG-TS/HLS, recréation, repli HLS et changement de profil, plus
un bilan toutes les 60 s, à POST /api/player-log (authentifié). Le
serveur écrit une ligne « [Player] … » par événement.

Entrée filtrée : événements et champs connus seulement, texte borné,
URL masquées (une erreur réseau peut recopier les identifiants Xtream),
pas d'injection de lignes. Envoi jamais bloquant pour la lecture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:38:17 +02:00

943 lines
34 KiB
JavaScript

/* =====================================================================
* XtremFlow — Moteur de lecture partagé
* ---------------------------------------------------------------------
* Objectifs :
* 1. ZAP RAPIDE — premier frame le plus tôt possible.
* 2. ADAPTATIF — si le réseau décroche, le buffer s'élargit tout seul.
* 3. ZÉRO CDN — hls.js / mpegts.js servis en local, chargés à la demande.
*
* Utilisé par player.html, player_lite.html et player_mobile.html.
* ===================================================================== */
(function (global) {
'use strict';
// ------------------------------------------------------------------
// Profils de buffer — escalade automatique en cas de micro-coupures
// ------------------------------------------------------------------
var PROFILES = {
// Démarrage : le plus agressif possible.
fast: {
name: 'fast',
hls: {
maxBufferLength: 24,
maxMaxBufferLength: 60,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 8,
initialLiveManifestSize: 1
},
mpegtsStash: 64 * 1024
},
// 2 coupures : on respire.
balanced: {
name: 'balanced',
hls: {
maxBufferLength: 60,
maxMaxBufferLength: 120,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 14,
initialLiveManifestSize: 2
},
mpegtsStash: 192 * 1024
},
// 4 coupures : priorité absolue à la stabilité.
safe: {
name: 'safe',
hls: {
maxBufferLength: 120,
maxMaxBufferLength: 240,
liveSyncDurationCount: 8,
liveMaxLatencyDurationCount: 24,
initialLiveManifestSize: 3
},
mpegtsStash: 512 * 1024
}
};
var ORDER = ['fast', 'balanced', 'safe'];
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
var MPEGTS_MAX_ERRORS = 3;
// ------------------------------------------------------------------
// Chargement paresseux des libs (aucune requête inutile)
// ------------------------------------------------------------------
var _scriptCache = {};
function _delay(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function _injectScript(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = function () { resolve(); };
s.onerror = function () {
// Laisser une balise morte dans le <head> ferait échouer une
// éventuelle nouvelle tentative sur le même src.
if (s.parentNode) s.parentNode.removeChild(s);
reject(new Error('script error'));
};
document.head.appendChild(s);
});
}
/**
* Précise la cause d'un échec pour le message affiché et les logs :
* 404 = lib absente de l'image déployée, 429 = quota du rate limiter,
* échec réseau = lien coupé. Sans cela, toutes les causes se
* ressemblaient à l'écran.
*/
function _describeFailure(src) {
if (typeof fetch !== 'function') return Promise.resolve('');
return fetch(src, { credentials: 'same-origin', cache: 'no-store' })
.then(function (r) { return r.ok ? '' : ' (HTTP ' + r.status + ')'; })
.catch(function () { return ' (réseau injoignable)'; });
}
/**
* Charge une lib vendorisée, avec une seconde tentative qui contourne le
* cache HTTP : les libs sont servies en `max-age=86400`, donc une entrée
* tronquée par une coupure réseau condamnait le lecteur jusqu'au vidage
* manuel du cache navigateur. Seuls les succès sont mémorisés — une
* promesse rejetée gardée en cache rendait tout réessai impossible pour
* le reste de la session.
*/
function loadScript(src) {
if (_scriptCache[src]) return _scriptCache[src];
var attempt = _injectScript(src)
.catch(function () {
return _delay(400).then(function () {
return _injectScript(
src + (src.indexOf('?') === -1 ? '?' : '&') + 'reload=' + Date.now()
);
});
})
.catch(function () {
return _describeFailure(src).then(function (why) {
throw new Error('Échec du chargement : ' + src + why);
});
});
_scriptCache[src] = attempt;
attempt.catch(function () { delete _scriptCache[src]; });
return attempt;
}
/** Ouvre la connexion vers l'hôte du flux avant même de connaître l'URL finale. */
function preconnect(url) {
try {
var origin = new URL(url, location.href).origin;
if (origin === location.origin) return;
['preconnect', 'dns-prefetch'].forEach(function (rel) {
var l = document.createElement('link');
l.rel = rel;
l.href = origin;
l.crossOrigin = 'anonymous';
document.head.appendChild(l);
});
} catch (e) { /* URL relative ou invalide : rien à préconnecter */ }
}
// ------------------------------------------------------------------
// Moteur
// ------------------------------------------------------------------
function XFPlayer(options) {
this.video = options.video;
this.vendorPath = options.vendorPath || 'vendor/';
this.logPrefix = options.logPrefix || '[XFPlayer]';
// Callbacks UI (toutes optionnelles)
this.onLoading = options.onLoading || function () {};
this.onReady = options.onReady || function () {};
this.onError = options.onError || function () {};
this.onBlocked = options.onBlocked || function () {};
this.onProfileChange = options.onProfileChange || function () {};
var p = new URLSearchParams(location.search);
this.params = p;
this.url = options.url || p.get('url');
this.type = options.type || p.get('type') || '';
this.startTime = parseFloat(p.get('t') || '0');
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;
this.profileLocked = !!PROFILES[forced];
this.hls = null;
this.mpegts = null;
this._started = false;
this._stalls = 0;
this._stallTimes = [];
this._mpegtsErrorTimes = [];
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 = [];
// Télémétrie des incidents (voir _report) : chaque freeze vécu par
// l'utilisateur finit dans les logs du serveur, avec sa cause.
this._t0 = Date.now();
this._session = Math.random().toString(36).slice(2, 8);
var idMatch = /\/api\/(?:live|vod|recordings\/stream)\/([^/?#.]+)/.exec(this.url || '');
this._channel = idMatch ? idMatch[1] : '';
this._stallSince = null;
this._stats = { stalls: 0, stallMs: 0, seeks: 0, errors: 0 };
this._lastPos = 0;
var self = this;
var userOnError = this.onError;
this.onError = function (msg) {
self._report('error_shown', { detail: String(msg) });
userOnError(msg);
};
}
// ---------------- Télémétrie ----------------
/** Secondes d'avance dans le buffer à la position courante. */
XFPlayer.prototype._bufferAhead = function () {
var v = this.video;
try {
for (var i = 0; i < v.buffered.length; i++) {
if (v.buffered.start(i) <= v.currentTime + 0.5 &&
v.buffered.end(i) >= v.currentTime) {
return v.buffered.end(i) - v.currentTime;
}
}
} catch (e) {}
return 0;
};
/**
* Remonte un incident au serveur (`POST /api/player-log`, écrit dans les
* logs `[Player] …`). Jamais bloquant : erreurs ignorées, aucun envoi
* sans session.
*/
XFPlayer.prototype._report = function (event, data) {
try {
var token = null;
try { token = global.localStorage.getItem('auth_token'); } catch (e) {}
if (!token || typeof fetch !== 'function') return;
var payload = {
event: event,
session: this._session,
ch: this._channel,
t: (Date.now() - this._t0) / 1000,
mode: this.hls ? 'hls' : (this.mpegts ? 'ts' : 'direct'),
profile: this.profile.name,
buffer: this._bufferAhead(),
pos: this.video.currentTime || 0
};
for (var k in data) {
if (Object.prototype.hasOwnProperty.call(data, k)) payload[k] = data[k];
}
fetch('/api/player-log', {
method: 'POST',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
}).catch(function () {});
} catch (e) {}
};
/** 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);
};
XFPlayer.prototype.send = function (msg) {
// Cible restreinte à notre origine : l'iframe est toujours même-origine
// que le parent Flutter, un wildcard '*' livrerait l'état du player à
// n'importe quelle page qui embarquerait player.html.
try { global.parent.postMessage(msg, global.location.origin); } catch (e) {}
};
// ---------------- Démarrage ----------------
XFPlayer.prototype.start = function () {
var self = this;
if (!this.url) { this.onError('Aucun flux fourni'); return Promise.resolve(); }
preconnect(this.url);
this._wireVideoEvents();
this._wireParentMessages();
var lower = this.url.toLowerCase();
var isHls = lower.indexOf('.m3u8') !== -1;
var isLive = this.type === 'live';
this.onLoading('Ouverture du flux');
this._report('start', {});
// Safari / iOS : HLS natif = le chemin le plus rapide, aucune lib à charger.
if (isHls && this._canPlayNativeHls()) {
this.log('HLS natif (Safari/iOS)');
return Promise.resolve(this._playDirect(this.url));
}
if (isHls) return this._startHls();
if (isLive || lower.indexOf('.ts') !== -1) return this._startMpegts(isLive || true);
// VOD progressif (mp4, mkv…) : lecture directe.
return Promise.resolve(this._playDirect(this.url));
};
XFPlayer.prototype._canPlayNativeHls = function () {
return this.video.canPlayType('application/vnd.apple.mpegurl') !== '';
};
// ---------------- HLS ----------------
XFPlayer.prototype._hlsConfig = function () {
var prof = this.profile.hls;
var isLive = this.type === 'live';
return {
enableWorker: true,
lowLatencyMode: false,
// ---- Fenêtre de buffer (pilotée par le profil) ----
maxBufferLength: prof.maxBufferLength,
maxMaxBufferLength: prof.maxMaxBufferLength,
backBufferLength: 30,
maxBufferSize: 30 * 1000 * 1000,
// ---- Démarrage rapide ----
// 1 seul segment suffit pour commencer (au lieu de 3) : ~2 segments
// de latence en moins au zap, soit souvent 4 à 12 secondes gagnées.
initialLiveManifestSize: prof.initialLiveManifestSize,
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,
// 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,
manifestLoadingMaxRetry: 4,
levelLoadingMaxRetry: 4,
fragLoadingMaxRetry: 6
};
};
XFPlayer.prototype._startHls = function () {
var self = this;
return loadScript(this.vendorPath + 'hls.min.js').then(function () {
if (!global.Hls || !global.Hls.isSupported()) {
if (self._canPlayNativeHls()) return self._playDirect(self.url);
self.onError('HLS non supporté par ce navigateur');
return;
}
var Hls = global.Hls;
var hls = new Hls(self._hlsConfig());
self.hls = hls;
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance
// Faux tant que la première playlist n'a pas été obtenue : voir le
// traitement des erreurs réseau plus bas.
var manifestParsed = false;
hls.loadSource(self.url);
hls.attachMedia(self.video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
manifestParsed = true;
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms.
// Si le réseau ne suit pas, l'escalade de profil s'en chargera.
self._attemptPlay();
});
hls.on(Hls.Events.LEVEL_LOADED, function (e, d) {
if (d.details && d.details.totalduration > self._hlsDuration) {
self._hlsDuration = d.details.totalduration;
global.currentHlsDuration = self._hlsDuration;
}
});
hls.on(Hls.Events.ERROR, function (e, d) {
if (!d.fatal) return;
self.log('Erreur HLS fatale : ' + d.details);
self._stats.errors++;
self._report('hls_error', { detail: d.type + '/' + d.details });
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) {
// Playlist jamais obtenue (transcodeur en échec → 502, serveur trop
// chargé → délai dépassé), après les relances internes de hls.js.
// `startLoad()` ne relance pas ce chargement-là : le lecteur
// restait muet sur un spinner infini. On remonte l'erreur pour que
// la page relance un lecteur neuf ou l'affiche.
if (!manifestParsed) {
self.onError('Flux indisponible : le serveur n\'a pas pu le préparer');
return;
}
self._escalate('erreur réseau');
hls.startLoad();
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
self._escalate('erreur média');
hls.recoverMediaError();
} else {
self.onError('Erreur HLS : ' + d.details);
}
});
}).catch(function (err) {
// hls.js indisponible : plutôt que d'échouer, tenter les chemins qui
// ne demandent aucune lib (HLS natif Safari/iOS, sinon lecture directe
// si le navigateur sait décoder le conteneur).
self.log(err.message);
if (self._canPlayNativeHls()) { self._playDirect(self.url); return; }
self.onError(err.message);
});
};
// ---------------- MPEG-TS ----------------
XFPlayer.prototype._startMpegts = function (isLive) {
var self = this;
return loadScript(this.vendorPath + 'mpegts.min.js').then(function () {
if (!global.mpegts || !global.mpegts.isSupported()) {
return self._playDirect(self.url);
}
self._createMpegts(isLive);
}).catch(function (err) {
// mpegts.js indisponible (build incomplet, coupure réseau, quota du
// rate limiter…) : le même flux reste lisible par la route HLS, où
// FFmpeg réencode déjà l'audio en AAC. Une seconde de latence en plus
// vaut mieux qu'un écran d'erreur sur une chaîne parfaitement lisible.
var hlsUrl = self._hlsEquivalent(self.url);
self.log(err.message + (hlsUrl ? ' — bascule sur le chemin HLS' : ''));
if (hlsUrl) {
self.url = hlsUrl;
self.onLoading('Ouverture du flux');
if (self._canPlayNativeHls()) { self._playDirect(hlsUrl); return; }
return self._startHls();
}
// Aucun équivalent HLS (flux direct fourni par la playlist) : dernier
// recours, laisser le navigateur décoder lui-même le conteneur.
if (self.video.canPlayType('video/mp2t') !== '') {
self._playDirect(self.url);
return;
}
self.onError(err.message);
});
};
XFPlayer.prototype._createMpegts = function (isLive) {
var self = this;
var mpegts = global.mpegts;
var player = mpegts.createPlayer(
{ type: 'mpegts', isLive: isLive, url: this.url },
{
enableWorker: true,
enableStashBuffer: true,
// Le stash initial conditionne directement le délai avant la 1re image.
// 64 Ko en profil « fast » contre 512 Ko auparavant.
stashInitialSize: this.profile.mpegtsStash,
maxStashSize: 30 * 1024 * 1024,
// POURQUOI ces seuils : les panneaux Xtream relaient le plus souvent
// une source HLS et livrent le flux par RAFALES (~6 s de contenu d'un
// coup, puis plusieurs secondes de silence total — mesuré en prod).
// L'ancien rattrapage (5 s max, 1 s de marge) sautait en avant à
// chaque rafale puis tombait à sec pendant le silence suivant :
// coupure toutes les 3-4 s et contenu sauté (12 sauts et 14 coupures
// en 30 s sur une chaîne FHD). La marge de 12 s couvre deux silences ;
// le saut ne sert plus qu'à borner un retard réellement accumulé.
liveBufferLatencyChasing: isLive,
liveBufferLatencyMaxLatency: 30,
liveBufferLatencyMinRemain: 12,
// Au-delà de 20 s de retard, accélérer légèrement (x1,1) jusqu'à
// revenir à 12 s : rattrapage invisible, sans saut ni coupure.
liveSync: isLive,
liveSyncMaxLatency: 20,
liveSyncTargetLatency: 12,
liveSyncPlaybackRate: 1.1,
// Purge du buffer déjà lu : sans elle, une longue séance finit par
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 60,
autoCleanupMinBackwardDuration: 30,
lazyLoad: false
}
);
this.mpegts = player;
this._mpegtsIsLive = isLive;
// mpegts.js ne démuxe que l'AAC et le MP3. Une chaîne diffusée en E-AC3
// (stream_type 0x87) ou en AC-3 (0x81) est donc lue sans aucune piste
// audio, sans erreur ni avertissement : l'image passe, le son est
// simplement absent. On bascule alors sur le chemin HLS, où FFmpeg
// réencode systématiquement l'audio en AAC.
player.on(mpegts.Events.MEDIA_INFO, function (mediaInfo) {
if (self._destroyed || self._audioFallbackDone) return;
if (mediaInfo && mediaInfo.hasAudio) return;
var hlsUrl = self._hlsEquivalent(self.url);
if (!hlsUrl) return;
self._audioFallbackDone = true;
self.log('Aucune piste audio démuxée (codec non supporté) — bascule HLS');
self._report('fallback_hls', { detail: 'no_audio' });
self.onLoading('Piste audio incompatible — réencodage');
try {
player.unload();
player.detachMediaElement();
player.destroy();
} catch (e) {}
self.mpegts = null;
self.url = hlsUrl;
if (self._canPlayNativeHls()) self._playDirect(hlsUrl);
else self._startHls();
});
player.on(mpegts.Events.ERROR, function (type, detail) {
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
if (self._destroyed) return;
self._stats.errors++;
self._report('mpegts_error', { detail: type + '/' + detail });
// Chaque recréation relance un FFmpeg côté serveur : sur un serveur
// déjà saturé, recréer sans fin toutes les 1,2 s ne faisait
// qu'aggraver la charge. Au-delà de quelques échecs rapprochés, on
// passe par la route HLS, qui renvoie un vrai code d'erreur.
var now = Date.now();
self._mpegtsErrorTimes = self._mpegtsErrorTimes.filter(function (t) {
return now - t < 60000;
});
self._mpegtsErrorTimes.push(now);
if (self._mpegtsErrorTimes.length > MPEGTS_MAX_ERRORS) {
try {
player.unload();
player.detachMediaElement();
player.destroy();
} catch (e) {}
self.mpegts = null;
var hlsUrl = self._hlsEquivalent(self.url);
if (!hlsUrl) {
self.onError('Flux indisponible');
return;
}
self.log('Erreurs MPEG-TS répétées — bascule HLS');
self._report('fallback_hls', { detail: 'repeated_mpegts_errors' });
self.onLoading('Ouverture du flux');
self.url = hlsUrl;
if (self._canPlayNativeHls()) self._playDirect(hlsUrl);
else self._startHls();
return;
}
self._escalate('erreur MPEG-TS');
// Recréation propre avec le nouveau profil.
setTimeout(function () {
if (self._destroyed) return;
try {
player.unload();
player.detachMediaElement();
player.destroy();
} catch (e) {}
self._report('recreate', {});
self._createMpegts(self._mpegtsIsLive);
}, 1200);
});
player.attachMediaElement(this.video);
player.load();
this._attemptPlay();
};
/// `/api/live/<id>/turbo.ts` (ou l'ancien `/api/live/<id>.ts`)
/// → `/api/live/<id>/source/playlist.m3u8`.
///
/// Filet de sécurité : la route turbo réencode déjà l'audio en AAC côté
/// serveur, donc ce fallback ne devrait plus jamais se déclencher — il
/// reste pour couvrir un flux sans piste audio exploitable du tout.
/// Renvoie `null` si l'URL n'est pas un flux live direct — rien à tenter.
XFPlayer.prototype._hlsEquivalent = function (url) {
var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url);
if (!match) return null;
return match[1] + match[2] + '/source/playlist.m3u8';
};
// ---------------- Lecture directe ----------------
XFPlayer.prototype._playDirect = function (url) {
var self = this;
this.video.src = url;
this.video.load();
this._attemptPlay();
this.video.addEventListener('error', function () {
self.onError('Format non supporté');
}, { once: true });
return true;
};
XFPlayer.prototype._attemptPlay = function () {
var self = this;
var p = this.video.play();
if (p && typeof p.catch === 'function') {
p.catch(function () {
// Autoplay refusé (Safari/iOS) : on tente en sourdine, sinon overlay.
self.video.muted = true;
var q = self.video.play();
if (q && typeof q.catch === 'function') {
q.catch(function () { self.onBlocked(); });
}
});
}
};
// ---------------- Escalade adaptative ----------------
/**
* Passe au profil supérieur après des coupures répétées.
* Fenêtre glissante de 60 s : deux stalls rapprochés déclenchent l'escalade,
* des stalls isolés dans le temps ne pénalisent pas la latence.
*/
XFPlayer.prototype._escalate = function (reason) {
if (this.profileLocked || this._destroyed) return;
var idx = ORDER.indexOf(this.profile.name);
if (idx >= ORDER.length - 1) return; // déjà au maximum
var next = PROFILES[ORDER[idx + 1]];
this.profile = next;
this.log('Escalade buffer → ' + next.name + ' (' + reason + ')');
this._report('profile', { detail: reason });
this.onProfileChange(next.name, reason);
// HLS : les fenêtres de buffer sont modifiables à chaud, pas besoin de
// recharger le flux — donc aucune coupure visible pour l'utilisateur.
if (this.hls) {
try {
this.hls.config.maxBufferLength = next.hls.maxBufferLength;
this.hls.config.maxMaxBufferLength = next.hls.maxMaxBufferLength;
this.hls.config.liveSyncDurationCount = next.hls.liveSyncDurationCount;
this.hls.config.liveMaxLatencyDurationCount =
next.hls.liveMaxLatencyDurationCount;
} catch (e) {}
}
// MPEG-TS : le stash est figé à la création — appliqué à la prochaine
// recréation du lecteur (déclenchée par le handler d'erreur).
};
XFPlayer.prototype._noteStall = function () {
if (!this._started) return; // les attentes d'amorçage ne comptent pas
var now = Date.now();
this._stallTimes.push(now);
this._stallTimes = this._stallTimes.filter(function (t) {
return now - t < 60000;
});
if (this._stallTimes.length >= 2) {
this._stallTimes = [];
this._escalate('coupures répétées');
}
};
// ---------------- Événements vidéo & pont Flutter ----------------
XFPlayer.prototype._wireVideoEvents = function () {
var self = this;
var v = this.video;
this._on(v, 'loadedmetadata', function () {
if (self.startTime > 0 && self.startTime < v.duration) {
v.currentTime = self.startTime;
}
});
this._on(v, 'playing', function () {
if (!self._started) {
self._report('first_frame', { ms: Date.now() - self._t0 });
}
if (self._stallSince !== null) {
var stallMs = Date.now() - self._stallSince;
self._stallSince = null;
// < 250 ms : imperceptible, ne pas noyer les logs.
if (stallMs >= 250) {
self._stats.stalls++;
self._stats.stallMs += stallMs;
self._report('stall', { ms: stallMs });
}
}
self._started = true;
self.onReady();
self.send({ type: 'playback_status', status: 'playing' });
});
this._on(v, 'pause', function () {
self.send({ type: 'playback_status', status: 'paused' });
});
this._on(v, 'waiting', function () {
if (self._started && self._stallSince === null) {
self._stallSince = Date.now();
}
self._noteStall();
});
this._on(v, 'stalled', function () { self._noteStall(); });
// Sauts (rattrapage du direct, recherche) : d'où, vers où.
this._on(v, 'timeupdate', function () {
if (!v.seeking) self._lastPos = v.currentTime;
});
this._on(v, 'seeking', function () {
if (!self._started) return;
self._stats.seeks++;
self._report('seek', { from: self._lastPos, to: v.currentTime });
});
// Bilan toutes les 60 s : nombre et durée cumulée des coupures.
this._beatTimer = setInterval(function () {
if (!self._started) return;
self._report('heartbeat', {
stalls: self._stats.stalls,
stallMs: self._stats.stallMs,
seeks: self._stats.seeks,
errors: self._stats.errors,
rate: v.playbackRate
});
}, 60000);
this._on(v, 'ended', function () {
self.send({
type: 'playback_ended',
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.
this._on(v, 'progress', function () {
if (!self._started) return;
var now = Date.now();
if (now - (self._lastProgressSent || 0) < 2000) return;
self._lastProgressSent = now;
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' }); }
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. */
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();
};
/** Envoi immédiat de l'état de lecture (position, durée, zone navigable). */
XFPlayer.prototype._sendPosition = function () {
this.send({
type: 'playback_position',
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;
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;
var d = event.data;
if (!d || !d.type) return;
var v = self.video;
switch (d.type) {
case 'play':
self._attemptPlay();
break;
case 'pause':
v.pause();
break;
case 'seek':
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));
v.muted = v.volume === 0;
break;
case 'unmute':
v.muted = false;
if (v.volume === 0) v.volume = 1;
break;
}
});
};
XFPlayer.prototype.destroy = function () {
this._destroyed = true;
clearInterval(this._posTimer);
clearInterval(this._beatTimer);
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) {
this.mpegts.unload();
this.mpegts.detachMediaElement();
this.mpegts.destroy();
}
} catch (e) {}
};
global.XFPlayer = XFPlayer;
global.XFPlayerProfiles = PROFILES;
})(window);