diff --git a/test/web/xf_player_core_test.mjs b/test/web/xf_player_core_test.mjs index 99663c4..95f0ed5 100644 --- a/test/web/xf_player_core_test.mjs +++ b/test/web/xf_player_core_test.mjs @@ -93,12 +93,32 @@ for (const profile of ['fast', 'balanced', 'safe']) { }); } -test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts', () => { +/** Réglages du régulateur de vitesse (exposés par le moteur). */ +function liveTuning() { + const window = { + addEventListener() {}, removeEventListener() {}, + console: { log() {} }, + location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' }, + parent: { postMessage() {} }, + }; + vm.runInContext(source, vm.createContext({ + window, location: window.location, + document: { addEventListener() {}, removeEventListener() {} }, + URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, + })); + return window.XFPlayerLiveTuning; +} + +test('live : un seul régulateur de vitesse (liveSync de mpegts.js désactivé)', () => { + // liveSync ne sait qu'accélérer et remet la vitesse à 1 sous sa cible : + // il annulerait le ralenti qui reconstitue la réserve. const c = liveMpegtsConfig(); - assert.equal(c.liveSync, true); - assert.ok(c.liveSyncPlaybackRate > 1 && c.liveSyncPlaybackRate <= 1.2); - assert.ok(c.liveSyncTargetLatency < c.liveSyncMaxLatency); - assert.ok(c.liveSyncMaxLatency <= c.liveBufferLatencyMaxLatency); + assert.notEqual(c.liveSync, true); + const t = liveTuning(); + assert.ok(t.slowRate < 1 && t.slowRate >= 0.95, 'ralenti audible au-delà'); + assert.ok(t.fastRate > 1 && t.fastRate <= 1.15); + assert.ok(t.lowAhead < t.targetAhead && t.targetAhead < t.highAhead); + assert.ok(t.highAhead < c.liveBufferLatencyMaxLatency); }); test('télémétrie : une coupure (waiting → playing) est remontée avec sa durée', async () => { @@ -155,13 +175,62 @@ const PROVIDER_OUTAGE_S = 18; test('live : la réserve visée couvre un silence du panneau de ~18 s', () => { const c = liveMpegtsConfig(); - // liveSync ramène le retard vers la cible : c'est la réserve en régime - // établi. Elle doit dépasser le silence mesuré. - assert.ok(c.liveSyncTargetLatency > PROVIDER_OUTAGE_S, - `cible ${c.liveSyncTargetLatency}s < silence mesuré ${PROVIDER_OUTAGE_S}s`); + const t = liveTuning(); + // Le régulateur ramène l'avance vers la cible : c'est la réserve en + // régime établi. Elle doit dépasser le silence mesuré — et le ralenti + // doit se déclencher AVANT d'être passé sous ce seuil de sécurité. + assert.ok(t.targetAhead > PROVIDER_OUTAGE_S, + `cible ${t.targetAhead}s < silence mesuré ${PROVIDER_OUTAGE_S}s`); + assert.ok(t.lowAhead > PROVIDER_OUTAGE_S); assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S); }); +test('régulateur : ralentit sous la réserve, accélère au-dessus, sans osciller', () => { + const listeners = {}; + let ahead = 0; + const video = { + addEventListener(type, fn) { (listeners[type] ||= []).push(fn); }, + removeEventListener() {}, + canPlayType: () => '', + play: () => Promise.resolve(), + pause() {}, + get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; }, + seekable: { length: 0 }, + currentTime: 100, + playbackRate: 1, + paused: false, + seeking: false, + }; + const window = { + addEventListener() {}, removeEventListener() {}, + console: { log() {} }, + location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' }, + parent: { postMessage() {} }, + }; + vm.runInContext(source, vm.createContext({ + window, location: window.location, + document: { addEventListener() {}, removeEventListener() {} }, + URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date, + })); + const t = window.XFPlayerLiveTuning; + const p = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' }); + p.mpegts = {}; + p._started = true; + + ahead = 13; p._tuneRate(); // session réelle : 13 s + assert.equal(video.playbackRate, t.slowRate); + ahead = t.lowAhead + 2; p._tuneRate(); // remonte, pas encore la cible + assert.equal(video.playbackRate, t.slowRate, 'repasse à 1 trop tôt : oscille'); + ahead = t.targetAhead; p._tuneRate(); + assert.equal(video.playbackRate, 1); + ahead = t.highAhead + 5; p._tuneRate(); // rafale de rattrapage + assert.equal(video.playbackRate, t.fastRate); + ahead = t.highAhead - 2; p._tuneRate(); + assert.equal(video.playbackRate, t.fastRate, 'freine trop tôt : oscille'); + ahead = t.targetAhead + 2; p._tuneRate(); + assert.equal(video.playbackRate, 1); +}); + test('live : la rafale de rattrapage après un silence ne fait pas sauter le programme', () => { // Mesuré en prod : après un silence, le panneau renvoie d'un coup tout le // retard ; l'avance a dépassé 45 s et le lecteur a sauté 2 fois de 20 s @@ -170,8 +239,8 @@ test('live : la rafale de rattrapage après un silence ne fait pas sauter le pro const c = liveMpegtsConfig(); assert.ok(c.liveBufferLatencyMaxLatency >= 2 * observedPeakAhead - 10, `saut dès ${c.liveBufferLatencyMaxLatency}s d'avance : la rafale de rattrapage le déclenche`); - assert.ok(c.liveSyncMaxLatency < observedPeakAhead, - 'liveSync doit déjà accélérer avant ce niveau'); + assert.ok(liveTuning().highAhead < observedPeakAhead, + 'le régulateur doit déjà accélérer avant ce niveau'); }); test('live : la vidéo déjà vue est purgée plus tôt, l\'avance étant plus grande', () => { diff --git a/web/xf-player-core.js b/web/xf-player-core.js index 9be588e..ced173c 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -66,6 +66,24 @@ // Au-delà, on relance quand même avec ce qu'on a. var REBUFFER_MAX_WAIT_MS = 15000; + // Régulateur de vitesse du direct MPEG-TS (voir _tuneRate). + // + // La réserve ne venait que de l'avance envoyée par le panneau au + // démarrage : 28 s une fois, 18 s une autre — et avec 13-20 s, un silence + // du panneau a fini en coupure de 5,5 s (télémétrie). Sous lowAhead, on + // lit à x0,97 (hauteur du son préservée) jusqu'à reconstituer targetAhead ; + // au-delà de highAhead (rafale de rattrapage du panneau), x1,1 jusqu'à + // settleHigh. Les seuils d'entrée et de sortie diffèrent : pas + // d'oscillation de vitesse. + var LIVE_TUNING = { + lowAhead: 20, + targetAhead: 25, + settleHigh: 28, + highAhead: 35, + slowRate: 0.97, + fastRate: 1.1 + }; + // ------------------------------------------------------------------ // Chargement paresseux des libs (aucune requête inutile) // ------------------------------------------------------------------ @@ -516,16 +534,15 @@ // Saut seulement au-delà de 90 s : après un silence, le panneau // renvoie d'un coup tout le retard. Avec un seuil à 45 s, l'avance // le dépassait et le lecteur sautait 20 s de programme (2 fois en - // 1 min, mesuré). liveSync résorbe désormais cet excédent en douceur. + // 1 min, mesuré). Le régulateur de vitesse (_tuneRate) résorbe + // désormais cet excédent en douceur. liveBufferLatencyChasing: isLive, liveBufferLatencyMaxLatency: 90, liveBufferLatencyMinRemain: 25, - // Au-delà de 35 s de retard, accélérer légèrement (x1,1) jusqu'à - // revenir à 25 s : rattrapage invisible, sans saut ni coupure. - liveSync: isLive, - liveSyncMaxLatency: 35, - liveSyncTargetLatency: 25, - liveSyncPlaybackRate: 1.1, + // liveSync de mpegts.js désactivé : il ne sait qu'accélérer et remet + // la vitesse à 1 sous sa cible, ce qui annulerait le ralenti de + // _tuneRate qui reconstitue la réserve. + liveSync: false, // Purge du buffer déjà lu : sans elle, une longue séance finit par // saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS). // Avance jusqu'à 90 s : la vidéo déjà vue est purgée plus tôt pour @@ -713,6 +730,36 @@ this._holdTimer = setInterval(function () { self._checkHold(); }, 250); }; + /** + * Ajuste la vitesse du direct MPEG-TS selon l'avance (voir LIVE_TUNING). + * Appelé chaque seconde ; sans effet en pause, en attente de réserve, + * hors direct ou en HLS. + */ + XFPlayer.prototype._tuneRate = function () { + var v = this.video; + if (!this.mpegts || this.type !== 'live' || !this._started || + this._holding || v.paused) { + return; + } + var ahead = this._bufferAhead(); + var t = LIVE_TUNING; + var mode = this._rateMode || 'normal'; + if (mode === 'slow' && ahead >= t.targetAhead) mode = 'normal'; + else if (mode === 'fast' && ahead <= t.settleHigh) mode = 'normal'; + else if (mode === 'normal' && ahead < t.lowAhead) mode = 'slow'; + else if (mode === 'normal' && ahead > t.highAhead) mode = 'fast'; + // Un saut de rattrapage peut faire passer directement d'un extrême à + // l'autre. + if (mode === 'slow' && ahead > t.highAhead) mode = 'fast'; + if (mode === 'fast' && ahead < t.lowAhead) mode = 'slow'; + this._rateMode = mode; + + var rate = mode === 'slow' ? t.slowRate : (mode === 'fast' ? t.fastRate : 1); + if (v.playbackRate !== rate) { + try { v.playbackRate = rate; } catch (e) {} + } + }; + /** Relance la lecture dès que la réserve est reconstituée. */ XFPlayer.prototype._checkHold = function () { if (!this._holding) return; @@ -796,6 +843,9 @@ self._report('seek', { from: self._lastPos, to: v.currentTime }); }); + // Régulateur de vitesse du direct (voir LIVE_TUNING). + this._rateTimer = setInterval(function () { self._tuneRate(); }, 1000); + // Bilan toutes les 60 s : nombre et durée cumulée des coupures. this._beatTimer = setInterval(function () { if (!self._started) return; @@ -982,6 +1032,7 @@ clearInterval(this._posTimer); clearInterval(this._beatTimer); clearInterval(this._holdTimer); + clearInterval(this._rateTimer); this._listeners.forEach(function (l) { try { l.target.removeEventListener(l.type, l.handler); } catch (e) {} }); @@ -998,4 +1049,5 @@ global.XFPlayer = XFPlayer; global.XFPlayerProfiles = PROFILES; + global.XFPlayerLiveTuning = LIVE_TUNING; })(window);