mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Lecture TV : traverser les silences du panneau sans figer
Télémétrie d'un freeze en prod : le panneau s'est tu ~15-20 s sur une connexion restée ouverte (aucune reconnexion FFmpeg dans les logs). Le lecteur n'avait gardé que 11-16 s d'avance — il « consommait » à x1,1 celle que le panneau envoie au démarrage pour revenir à 12 s — puis, après la coupure de 6,3 s, il repartait avec 1 s de stock et recoupait aussitôt (1,7 s puis 2,0 s). - réserve visée 25 s (liveSync 25/35, saut au-delà de 45 s) : ~25 s de retard sur le direct en échange de la traversée de ces silences - après une coupure, attente de 4 s de réserve avant de relire (comme mpv cache-pause), 15 s au plus ; une pause utilisateur l'annule ; le lecteur lite affiche « Reconstitution de la réserve » au lieu du bouton lecture Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a739d6451c
commit
0539ad857f
3 files changed
+123
-6
No files matched your search
@@ -149,6 +149,65 @@ test('télémétrie : une coupure (waiting → playing) est remontée avec sa du
|
|||||||
assert.equal(sent[0].url, '/api/player-log');
|
assert.equal(sent[0].url, '/api/player-log');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Freeze mesuré en prod (télémétrie) : le panneau s'est tu ~15-20 s sur une
|
||||||
|
// connexion restée ouverte, alors que le lecteur n'avait gardé que 11-16 s.
|
||||||
|
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`);
|
||||||
|
assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reprise après coupure : attendre une vraie réserve avant de relire', () => {
|
||||||
|
const calls = [];
|
||||||
|
const listeners = {};
|
||||||
|
let ahead = 0;
|
||||||
|
const video = {
|
||||||
|
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
||||||
|
removeEventListener() {},
|
||||||
|
canPlayType: () => '',
|
||||||
|
play: () => { calls.push('play'); return Promise.resolve(); },
|
||||||
|
pause: () => { calls.push('pause'); },
|
||||||
|
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
|
||||||
|
seekable: { length: 0 },
|
||||||
|
currentTime: 100,
|
||||||
|
playbackRate: 1,
|
||||||
|
seeking: false,
|
||||||
|
};
|
||||||
|
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
|
||||||
|
const window = {
|
||||||
|
addEventListener() {}, removeEventListener() {},
|
||||||
|
console: { log() {} },
|
||||||
|
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
||||||
|
parent: { postMessage() {} },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window, location: window.location,
|
||||||
|
document: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
const player = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
|
||||||
|
player.mpegts = {}; // flux MPEG-TS en cours
|
||||||
|
player._wireVideoEvents();
|
||||||
|
|
||||||
|
fire('playing');
|
||||||
|
fire('waiting'); // le panneau se tait
|
||||||
|
assert.deepEqual(calls.slice(-1), ['pause'], 'le lecteur doit se mettre en attente');
|
||||||
|
|
||||||
|
ahead = 1; // 1 s arrive : trop peu, ne pas repartir
|
||||||
|
player._checkHold();
|
||||||
|
assert.ok(!calls.slice(1).includes('play'), 'reparti avec 1 s : recoupera aussitôt');
|
||||||
|
|
||||||
|
ahead = 5; // vraie réserve reconstituée
|
||||||
|
player._checkHold();
|
||||||
|
assert.equal(calls.at(-1), 'play');
|
||||||
|
});
|
||||||
|
|
||||||
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
|
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
|
||||||
const c = liveMpegtsConfig();
|
const c = liveMpegtsConfig();
|
||||||
assert.equal(c.autoCleanupSourceBuffer, true);
|
assert.equal(c.autoCleanupSourceBuffer, true);
|
||||||
|
|||||||
@@ -141,6 +141,12 @@
|
|||||||
|
|
||||||
// Contrat historique du lite : mise en pause manuelle → overlay lecture.
|
// Contrat historique du lite : mise en pause manuelle → overlay lecture.
|
||||||
video.addEventListener('pause', function () {
|
video.addEventListener('pause', function () {
|
||||||
|
// Pause technique : le lecteur attend que la réserve se
|
||||||
|
// reconstitue après une coupure, il repartira seul.
|
||||||
|
if (player && player._holding) {
|
||||||
|
showLoading('Reconstitution de la réserve');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (video.currentTime === 0 || video.paused) {
|
if (video.currentTime === 0 || video.paused) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
playOverlay.style.display = 'flex';
|
playOverlay.style.display = 'flex';
|
||||||
|
|||||||
+58
-6
@@ -58,6 +58,14 @@
|
|||||||
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
|
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
|
||||||
var MPEGTS_MAX_ERRORS = 3;
|
var MPEGTS_MAX_ERRORS = 3;
|
||||||
|
|
||||||
|
// Après une coupure en direct, réserve à reconstituer avant de relire.
|
||||||
|
// Repartir dès la première seconde reçue transformait un silence du
|
||||||
|
// panneau en cascade de micro-coupures (6,3 s puis 1,7 s puis 2,0 s,
|
||||||
|
// mesuré) ; comme mpv (cache-pause), on attend une vraie réserve.
|
||||||
|
var REBUFFER_SECONDS = 4;
|
||||||
|
// Au-delà, on relance quand même avec ce qu'on a.
|
||||||
|
var REBUFFER_MAX_WAIT_MS = 15000;
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Chargement paresseux des libs (aucune requête inutile)
|
// Chargement paresseux des libs (aucune requête inutile)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -497,14 +505,21 @@
|
|||||||
// coupure toutes les 3-4 s et contenu sauté (12 sauts et 14 coupures
|
// 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 ;
|
// 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é.
|
// le saut ne sert plus qu'à borner un retard réellement accumulé.
|
||||||
|
//
|
||||||
|
// 25 s et non plus 12 : la télémétrie a mesuré un silence du panneau
|
||||||
|
// de ~15-20 s sur une connexion restée ouverte, alors que le lecteur
|
||||||
|
// n'avait gardé que 11-16 s — il venait de « consommer » à x1,1
|
||||||
|
// l'avance que le panneau envoie au démarrage. On la garde : ~25 s
|
||||||
|
// de retard sur le direct, en échange d'une lecture qui traverse ces
|
||||||
|
// silences sans figer.
|
||||||
liveBufferLatencyChasing: isLive,
|
liveBufferLatencyChasing: isLive,
|
||||||
liveBufferLatencyMaxLatency: 30,
|
liveBufferLatencyMaxLatency: 45,
|
||||||
liveBufferLatencyMinRemain: 12,
|
liveBufferLatencyMinRemain: 25,
|
||||||
// Au-delà de 20 s de retard, accélérer légèrement (x1,1) jusqu'à
|
// Au-delà de 35 s de retard, accélérer légèrement (x1,1) jusqu'à
|
||||||
// revenir à 12 s : rattrapage invisible, sans saut ni coupure.
|
// revenir à 25 s : rattrapage invisible, sans saut ni coupure.
|
||||||
liveSync: isLive,
|
liveSync: isLive,
|
||||||
liveSyncMaxLatency: 20,
|
liveSyncMaxLatency: 35,
|
||||||
liveSyncTargetLatency: 12,
|
liveSyncTargetLatency: 25,
|
||||||
liveSyncPlaybackRate: 1.1,
|
liveSyncPlaybackRate: 1.1,
|
||||||
// Purge du buffer déjà lu : sans elle, une longue séance finit par
|
// Purge du buffer déjà lu : sans elle, une longue séance finit par
|
||||||
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
|
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
|
||||||
@@ -678,6 +693,32 @@
|
|||||||
// recréation du lecteur (déclenchée par le handler d'erreur).
|
// recréation du lecteur (déclenchée par le handler d'erreur).
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct MPEG-TS à sec : se mettre en pause jusqu'à [REBUFFER_SECONDS] de
|
||||||
|
* réserve (voir la constante). HLS gère déjà sa propre reprise.
|
||||||
|
*/
|
||||||
|
XFPlayer.prototype._holdUntilBuffered = function () {
|
||||||
|
if (this._holding || !this.mpegts || this.type !== 'live') return;
|
||||||
|
var self = this;
|
||||||
|
this._holding = true;
|
||||||
|
this._holdSince = Date.now();
|
||||||
|
try { this.video.pause(); } catch (e) {}
|
||||||
|
this._holdTimer = setInterval(function () { self._checkHold(); }, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Relance la lecture dès que la réserve est reconstituée. */
|
||||||
|
XFPlayer.prototype._checkHold = function () {
|
||||||
|
if (!this._holding) return;
|
||||||
|
if (this._destroyed) { clearInterval(this._holdTimer); return; }
|
||||||
|
var waited = Date.now() - this._holdSince;
|
||||||
|
if (this._bufferAhead() < REBUFFER_SECONDS && waited < REBUFFER_MAX_WAIT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(this._holdTimer);
|
||||||
|
this._holding = false;
|
||||||
|
this._attemptPlay();
|
||||||
|
};
|
||||||
|
|
||||||
XFPlayer.prototype._noteStall = function () {
|
XFPlayer.prototype._noteStall = function () {
|
||||||
if (!this._started) return; // les attentes d'amorçage ne comptent pas
|
if (!this._started) return; // les attentes d'amorçage ne comptent pas
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
@@ -723,6 +764,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
this._on(v, 'pause', function () {
|
this._on(v, 'pause', function () {
|
||||||
|
// Pause posée par l'attente de réserve : pas une pause utilisateur,
|
||||||
|
// l'interface ne doit pas basculer sur « lecture ».
|
||||||
|
if (self._holding) return;
|
||||||
self.send({ type: 'playback_status', status: 'paused' });
|
self.send({ type: 'playback_status', status: 'paused' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -731,6 +775,7 @@
|
|||||||
self._stallSince = Date.now();
|
self._stallSince = Date.now();
|
||||||
}
|
}
|
||||||
self._noteStall();
|
self._noteStall();
|
||||||
|
if (self._started) self._holdUntilBuffered();
|
||||||
});
|
});
|
||||||
this._on(v, 'stalled', function () { self._noteStall(); });
|
this._on(v, 'stalled', function () { self._noteStall(); });
|
||||||
|
|
||||||
@@ -897,6 +942,12 @@
|
|||||||
self._attemptPlay();
|
self._attemptPlay();
|
||||||
break;
|
break;
|
||||||
case 'pause':
|
case 'pause':
|
||||||
|
// Pause voulue par l'utilisateur : l'attente de réserve ne doit
|
||||||
|
// pas relancer la lecture derrière son dos.
|
||||||
|
if (self._holding) {
|
||||||
|
clearInterval(self._holdTimer);
|
||||||
|
self._holding = false;
|
||||||
|
}
|
||||||
v.pause();
|
v.pause();
|
||||||
break;
|
break;
|
||||||
case 'seek':
|
case 'seek':
|
||||||
@@ -923,6 +974,7 @@
|
|||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
clearInterval(this._posTimer);
|
clearInterval(this._posTimer);
|
||||||
clearInterval(this._beatTimer);
|
clearInterval(this._beatTimer);
|
||||||
|
clearInterval(this._holdTimer);
|
||||||
this._listeners.forEach(function (l) {
|
this._listeners.forEach(function (l) {
|
||||||
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user