diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1472434..06708d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: # fatal; errors and warnings still fail the build. - run: flutter analyze --no-fatal-infos - run: flutter test + # Moteur de lecture web (web/xf-player-core.js) : Node est préinstallé + # sur les runners ubuntu, aucune dépendance npm. + - run: node --test "test/web/*_test.mjs" - run: flutter build web --release backend: diff --git a/test/web/xf_player_core_test.mjs b/test/web/xf_player_core_test.mjs new file mode 100644 index 0000000..c499fed --- /dev/null +++ b/test/web/xf_player_core_test.mjs @@ -0,0 +1,110 @@ +// Tests du moteur de lecture web (web/xf-player-core.js). +// +// Lancement : node --test "test/web/*_test.mjs" +// +// Le moteur est un script navigateur (IIFE sur `window`) : on l'exécute dans +// un contexte vm avec un DOM minimal et un faux mpegts.js qui capture la +// configuration reçue. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync( + new URL('../../web/xf-player-core.js', import.meta.url), + 'utf8', +); + +function fakeEventTarget() { + return { addEventListener() {}, removeEventListener() {} }; +} + +/** Charge le moteur et renvoie la config passée à mpegts.createPlayer. */ +function liveMpegtsConfig({ profile } = {}) { + const created = []; + const video = { + ...fakeEventTarget(), + canPlayType: () => '', + play: () => Promise.resolve(), + buffered: { length: 0 }, + seekable: { length: 0 }, + }; + const window = { + ...fakeEventTarget(), + console: { log() {} }, + location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' }, + parent: { postMessage() {} }, + mpegts: { + isSupported: () => true, + Events: { MEDIA_INFO: 'media_info', ERROR: 'error' }, + createPlayer(mediaDataSource, config) { + created.push({ mediaDataSource, config }); + return { on() {}, attachMediaElement() {}, load() {} }; + }, + }, + }; + const context = vm.createContext({ + window, + location: window.location, + document: fakeEventTarget(), + URLSearchParams, + setInterval: () => 0, + clearInterval() {}, + setTimeout, + }); + vm.runInContext(source, context); + + const XFPlayer = window.XFPlayer; + const player = new XFPlayer({ + video, + url: '/api/live/1/turbo.ts', + type: 'live', + }); + if (profile) player.profile = window.XFPlayerProfiles[profile]; + player._createMpegts(true); + assert.equal(created.length, 1); + return created[0].config; +} + +// Les panneaux Xtream relaient souvent une source HLS : le flux arrive par +// rafales d'environ 6 s séparées de silences complets (mesuré en prod). +const UPSTREAM_BURST_GAP_S = 7; + +for (const profile of ['fast', 'balanced', 'safe']) { + test(`live (${profile}) : la marge gardée après rattrapage couvre un silence amont`, () => { + const c = liveMpegtsConfig({ profile }); + if (!c.liveBufferLatencyChasing) return; // pas de saut = rien à vérifier + assert.ok( + c.liveBufferLatencyMinRemain >= UPSTREAM_BURST_GAP_S, + `MinRemain ${c.liveBufferLatencyMinRemain}s < silence amont ${UPSTREAM_BURST_GAP_S}s : ` + + 'chaque rafale déclenche un saut puis une coupure', + ); + }); + + test(`live (${profile}) : une rafale normale ne déclenche pas de saut`, () => { + const c = liveMpegtsConfig({ profile }); + if (!c.liveBufferLatencyChasing) return; + // Après une rafale, le buffer vaut la marge + une rafale entière. + assert.ok( + c.liveBufferLatencyMaxLatency >= + c.liveBufferLatencyMinRemain + 2 * UPSTREAM_BURST_GAP_S, + 'seuil de rattrapage trop proche de la marge : sauts à chaque rafale', + ); + }); +} + +test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts', () => { + 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); +}); + +test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => { + const c = liveMpegtsConfig(); + assert.equal(c.autoCleanupSourceBuffer, true); + assert.ok( + c.autoCleanupMinBackwardDuration < c.autoCleanupMaxBackwardDuration, + ); +}); diff --git a/web/xf-player-core.js b/web/xf-player-core.js index c37103f..744e1c9 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -419,13 +419,28 @@ // 64 Ko en profil « fast » contre 512 Ko auparavant. stashInitialSize: this.profile.mpegtsStash, maxStashSize: 30 * 1024 * 1024, - // Rattrapage du direct : sans lui, chaque micro-coupure fait dériver - // la lecture derrière le direct (latence qui s'accumule). Activé - // uniquement en profil « fast » — les profils élargis privilégient - // la stabilité du buffer. - liveBufferLatencyChasing: isLive && this.profile.name === 'fast', - liveBufferLatencyMaxLatency: 5, - liveBufferLatencyMinRemain: 1, + // 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 } );