Add opt-in FFmpeg continuity slates with per-viewer localization
This commit is contained in:
+42
-11
@@ -3,7 +3,11 @@ const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const listeners = {}, documentListeners = {}, elements = [];
|
||||
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false;
|
||||
let clock = 0, interval, starts = 0, stops = 0, osd = 0, returned = 0, readyState = 0, remote = false, currentTime = 0, paused = false;
|
||||
let phase = 'checking_stream', progressCalls = 0, languageQueries = [], progressDeferred;
|
||||
const catalogue = JSON.parse(fs.readFileSync(__dirname + '/Jellyfin.Plugin.Enigma2/Localization/startup-messages.json', 'utf8'));
|
||||
const video = { get readyState() { return readyState; }, get currentTime() { return currentTime; }, get paused() { return paused; }, currentSrc: 'stream' };
|
||||
const playerListeners = {};
|
||||
let policy = { IsEnigmaChannel: true, PreferH264: true }, policyCalls = 0, policyError = false;
|
||||
const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }],
|
||||
TranscodingProfiles: [{ Type: 'Video', Context: 'Streaming', VideoCodec: 'av1,h264', AudioCodec: 'aac', Protocol: 'hls', MaxWidth: 1920 },
|
||||
@@ -12,36 +16,47 @@ const profile = { DirectPlayProfiles: [{ Type: 'Video', VideoCodec: 'av1,h264' }
|
||||
const pending = [];
|
||||
const location = { hash: '#/livetv', pathname: '/web/index.html', search: '', replace(value) { this.hash = value; } };
|
||||
const document = {
|
||||
fullscreenElement: null,
|
||||
fullscreenElement: null, documentElement: { lang: "de-DE" },
|
||||
body: { append(element) { elements.push(element); } },
|
||||
createElement() { return { style: {}, remove() { elements.splice(elements.indexOf(this), 1); } }; },
|
||||
querySelector() { return { readyState, currentTime: 0 }; },
|
||||
createElement() { return { style: {}, children: [], attributes: {}, setAttribute(key, value) { this.attributes[key] = value; },
|
||||
append(...children) { this.children.push(...children); }, remove() { const i = elements.indexOf(this); if (i >= 0) elements.splice(i, 1); } }; },
|
||||
querySelector() { return video; },
|
||||
addEventListener(name, callback) { documentListeners[name] = callback; }
|
||||
};
|
||||
const window = {
|
||||
ApiClient: { getCurrentUserId: () => 'user', serverId: () => 'server', getItem: async (user, id) => ({ Id: id, Type: 'TvChannel' }),
|
||||
getUrl: (path, params) => path + '?' + new URLSearchParams(params),
|
||||
ajax: async () => { ++policyCalls; if (policyError) throw Error('offline'); return policy; } },
|
||||
ajax: async options => {
|
||||
if (options.url.startsWith('DecoderIntegration/StartupMessages')) {
|
||||
const language = new URLSearchParams(options.url.split('?')[1]).get('language'); languageQueries.push(language);
|
||||
return { Language: language.split('-')[0], Texts: catalogue[language.split('-')[0]] };
|
||||
}
|
||||
if (options.url.startsWith('DecoderIntegration/PlaybackProgress')) {
|
||||
++progressCalls; if (progressDeferred) return progressDeferred;
|
||||
return { Available: true, Phase: phase, StartedUtc: new Date(clock).toISOString() };
|
||||
}
|
||||
++policyCalls; if (policyError) throw Error('offline'); return policy;
|
||||
} },
|
||||
addEventListener(name, callback) { listeners[name] = callback; }
|
||||
};
|
||||
const context = {
|
||||
window, document, location, console, URLSearchParams,
|
||||
history: { replaceState(a, b, url) { location.hash = url.slice(url.indexOf('#')); } },
|
||||
Date: { now: () => clock }, setTimeout, clearTimeout,
|
||||
Date: { now: () => clock, parse: Date.parse }, setTimeout, clearTimeout,
|
||||
setInterval(callback) { interval = callback; return callback; },
|
||||
clearInterval(callback) { if (interval === callback) interval = null; }
|
||||
};
|
||||
const manager = {
|
||||
getCurrentPlayer: () => ({ isLocalPlayer: !remote }),
|
||||
play() { ++starts; return new Promise(resolve => pending.push(resolve)); },
|
||||
play() { ++starts; playerListeners.playbackstart?.(); return new Promise(resolve => pending.push(resolve)); },
|
||||
stop: async () => { ++stops; }
|
||||
};
|
||||
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||
(async () => {
|
||||
vm.runInNewContext(fs.readFileSync(__dirname + '/web/decoder-channel-playback.js', 'utf8'), context);
|
||||
const Plugin = await window.DecoderChannelPlayback();
|
||||
const appHost = { getDeviceProfile: async () => profile };
|
||||
const dependencies = { playbackManager: manager, appHost, toast() {}, loading: { hide() {} }, events: { on() {} },
|
||||
const dependencies = { playbackManager: manager, appHost, toast() {}, loading: { hide() {} }, events: { on(target, name, callback) { playerListeners[name] = callback; } },
|
||||
appRouter: { showVideoOsd: async () => { ++osd; }, show: async () => { ++returned; location.hash = '#/livetv?tab=2'; } } };
|
||||
new Plugin(dependencies);
|
||||
const channel = { Id: 'channel', Type: 'TvChannel', ServerId: 'server' };
|
||||
@@ -70,17 +85,33 @@ const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
const click = () => documentListeners.click({ button: 0, target: { closest: () => ({ closest: () => ({ dataset: { id: 'channel', serverid: 'server' } }) }) }, preventDefault() {}, stopImmediatePropagation() {} });
|
||||
click(); click(); await flush();
|
||||
assert.equal(starts, 1); assert.equal(elements[0].id, 'decoderPlaybackExit');
|
||||
assert.equal(languageQueries.at(-1), 'de-DE', 'use chosen Jellyfin UI language, not browser metadata language');
|
||||
assert.equal(elements[0].textContent, catalogue.de.cancel); assert.equal(elements[1].attributes.role, 'status');
|
||||
interval(); await flush(); assert.equal(osd, 0, 'OSD must wait for actual video metadata');
|
||||
assert.equal(progressCalls, 1); assert.equal(elements[1].children[2].textContent, catalogue.de.checking_stream);
|
||||
phase = 'waiting_for_stream'; clock = 2000; interval(); await flush();
|
||||
assert.equal(elements[1].children[2].textContent, catalogue.de.waiting_for_stream);
|
||||
readyState=1;interval(); await flush(); assert.equal(osd, 1);
|
||||
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} });
|
||||
await flush(); assert.equal(stops, 1); assert.equal(returned, 1); assert.equal(elements.length, 0);
|
||||
click(); await flush(); assert.equal(starts, 2);
|
||||
readyState = 0; clock = 0; click(); await flush(); assert.equal(starts, 2);
|
||||
pending[0](); await flush(); assert.equal(stops, 1, 'late old completion must not stop the new channel');
|
||||
clock = 35000; interval(); await flush();
|
||||
assert.equal(stops, 1, 'pending startup must survive 35 seconds');
|
||||
assert.equal(returned, 1); assert.equal(elements.length, 1, 'cancel control remains available during slow startup');
|
||||
assert.equal(returned, 1); assert.equal(elements.length, 2, 'status and cancel remain available during slow startup');
|
||||
clock = 45001; interval(); await flush();
|
||||
assert.equal(stops, 2); assert.equal(returned, 2); assert.equal(elements.length, 0);
|
||||
pending[1](); await flush();
|
||||
readyState = 0; clock = 50000; click(); await flush();
|
||||
readyState = 2; currentTime = 1; interval(); await flush();
|
||||
assert.equal(elements[1].style.display, 'none', 'first decoded frame hides waiting panel');
|
||||
readyState = 1; currentTime = 2; clock = 500000; interval(); await flush();
|
||||
assert.equal(stops, 2, 'a stream already playing must not be killed by the startup timeout');
|
||||
assert.equal(elements[1].style.display, 'flex'); assert.equal(elements[1].children[2].textContent, catalogue.de.buffering);
|
||||
paused = true; interval(); assert.equal(elements[1].style.display, 'none', 'pause is not a buffering failure');
|
||||
paused = false;
|
||||
listeners.keydown({ key: 'Escape', preventDefault() {}, stopImmediatePropagation() {} }); await flush();
|
||||
assert.equal(elements.length, 0); pending[2](); await flush();
|
||||
console.log('PASS per-device UI locale, real progress, first frame, later buffering and pause');
|
||||
console.log('PASS default channels, explicit programmes, one start, native OSD, Escape, late completion and startup timeout');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
Reference in New Issue
Block a user