mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
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>
943 lines
34 KiB
JavaScript
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);
|