/* ===================================================================== * 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
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/