Add opt-in FFmpeg continuity slates with per-viewer localization
This commit is contained in:
@@ -34,12 +34,48 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
||||
try { appHost.getDeviceProfile = getDeviceProfile; }
|
||||
catch { /* A client exposing a read-only host keeps its native negotiation. */ }
|
||||
}
|
||||
let generation = 0, starting = null, cancelButton = null, watcher = null;
|
||||
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; };
|
||||
// Jellyfin's globalize module sets <html lang> to the current user's UI
|
||||
// language, including an explicit choice that differs from browser locale.
|
||||
const fallback = { header: 'Television', preparing: 'Preparing channel…',
|
||||
checking_stream: 'Waiting for video and audio…', waiting_for_stream: 'Still waiting for a usable stream…',
|
||||
ready: 'Stream detected. Starting player…', failed: 'Unable to prepare the stream.', buffering: 'Buffering video…',
|
||||
help: 'The receiver may need several seconds. You can cancel or press Esc.', cancel: 'Stop and return to channels',
|
||||
elapsed: 'Waiting: {seconds} s', timeout: 'The channel is not providing video. Returning to the channel list.',
|
||||
play_error: 'Unable to start the channel.' };
|
||||
let messages = fallback;
|
||||
const text = key => messages[key] || fallback[key];
|
||||
const loadMessages = async api => {
|
||||
let timer;
|
||||
try {
|
||||
const language = document.documentElement?.lang || '';
|
||||
const response = await Promise.race([
|
||||
api.ajax({ type: 'GET', dataType: 'json', url: api.getUrl('DecoderIntegration/StartupMessages', { language }) }),
|
||||
new Promise(resolve => { timer = setTimeout(() => resolve(null), 1000); })
|
||||
]);
|
||||
return response?.Texts ? { ...fallback, ...response.Texts } : fallback;
|
||||
} catch { return fallback; } finally { clearTimeout(timer); }
|
||||
};
|
||||
let generation = 0, starting = null, cancelButton = null, watcher = null, statusPanel = null, statusText = null, elapsedText = null, playbackSequence = 0;
|
||||
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; statusPanel?.remove(); statusPanel = statusText = elapsedText = null; };
|
||||
events.on(playbackManager, 'playbackstop', () => { if (!starting) clearControls(); });
|
||||
events.on(playbackManager, 'playbackstart', () => { ++playbackSequence; });
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (cancelButton) (document.fullscreenElement || document.body).append(cancelButton);
|
||||
if (statusPanel) (document.fullscreenElement || document.body).append(statusPanel);
|
||||
});
|
||||
const showStatus = (channelName) => {
|
||||
statusPanel = document.createElement('section'); statusPanel.id = 'decoderPlaybackStatus';
|
||||
statusPanel.setAttribute('role', 'status'); statusPanel.setAttribute('aria-live', 'polite');
|
||||
statusPanel.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:#101723;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:30px;box-sizing:border-box;color:#f4f7fc;text-align:center;font:18px system-ui';
|
||||
const icon = document.createElement('div'); icon.setAttribute('aria-hidden', 'true');
|
||||
icon.style.cssText = 'width:112px;height:76px;border:5px solid #58bfee;border-radius:14px;display:grid;place-items:center;color:#58bfee;font:bold 26px system-ui'; icon.textContent = 'TV';
|
||||
const title = document.createElement('h2'); title.textContent = channelName || text('header'); title.style.cssText = 'margin:6px;max-width:90vw;overflow-wrap:anywhere;font-size:28px';
|
||||
statusText = document.createElement('p'); statusText.textContent = text('preparing'); statusText.style.cssText = 'margin:0;font-size:22px';
|
||||
const help = document.createElement('p'); help.textContent = text('help'); help.style.cssText = 'max-width:560px;color:#b1bdce;margin:0;line-height:1.5';
|
||||
elapsedText = document.createElement('span'); elapsedText.setAttribute('aria-hidden', 'true'); elapsedText.style.cssText = 'color:#91a4bb;font-variant-numeric:tabular-nums';
|
||||
statusPanel.append(icon, title, statusText, help, elapsedText); document.body.append(statusPanel);
|
||||
};
|
||||
const stageText = phase => ['preparing', 'checking_stream', 'waiting_for_stream', 'ready', 'failed'].includes(phase) ? text(phase) : null;
|
||||
const leave = async () => {
|
||||
++generation; starting = null; clearControls();
|
||||
try { await playbackManager.stop(); } finally { loading.hide(); await appRouter.show('livetv?tab=2'); }
|
||||
@@ -51,29 +87,53 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
||||
request = (async () => {
|
||||
const api = window.ApiClient;
|
||||
if (!api?.getCurrentUserId() || (serverId && api.serverId() !== serverId)) return false;
|
||||
const item = await api.getItem(api.getCurrentUserId(), id);
|
||||
const [item, localized] = await Promise.all([api.getItem(api.getCurrentUserId(), id), loadMessages(api)]);
|
||||
if (ticket !== generation) return false;
|
||||
messages = localized;
|
||||
if (item.Type !== 'TvChannel') throw Error('Expected a TV channel');
|
||||
clearControls();
|
||||
cancelButton = document.createElement('button');
|
||||
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = 'Zatrzymaj i wróć do kanałów';
|
||||
cancelButton.id = 'decoderPlaybackExit'; cancelButton.textContent = text('cancel');
|
||||
cancelButton.style.cssText = 'position:fixed;right:16px;top:72px;z-index:2147483647;background:#252525;color:white;border:1px solid #aaa;border-radius:6px;padding:12px;font:16px system-ui;cursor:pointer';
|
||||
cancelButton.onclick = () => void leave(); document.body.append(cancelButton);
|
||||
showStatus(item.Name);
|
||||
// Allow the bounded metadata probe (25 s) plus HLS startup (20 s).
|
||||
let opened = false, opening = false; const deadline = Date.now() + 45000;
|
||||
let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity;
|
||||
const began = Date.now(), deadline = began + 45000, initialVideo = document.querySelector('video'), initialSource = initialVideo?.currentSrc, initialSequence = playbackSequence;
|
||||
watcher = setInterval(() => {
|
||||
if (ticket !== generation) return;
|
||||
const video = document.querySelector('video');
|
||||
const player = playbackManager.getCurrentPlayer();
|
||||
if (player?.isLocalPlayer === false) { clearControls(); return; }
|
||||
if (!opened && !opening && video?.readyState >= 1 && player) {
|
||||
const currentVideo = video && (video !== initialVideo || video.currentSrc !== initialSource || playbackSequence > initialSequence);
|
||||
if (elapsedText) elapsedText.textContent = text('elapsed').replace('{seconds}', Math.floor((Date.now() - began) / 1000));
|
||||
if (!opened && !opening && currentVideo && video.readyState >= 1 && player) {
|
||||
opening = true;
|
||||
Promise.resolve(appRouter.showVideoOsd()).then(() => { opened = true; })
|
||||
.catch(() => { /* A cancelled/unready native route can be retried. */ })
|
||||
.finally(() => { opening = false; });
|
||||
}
|
||||
if (video?.readyState >= 2 && video.currentTime > 0) return;
|
||||
if (Date.now() >= deadline) { void leave(); toast('Kanał nie przesyła obrazu. Powrót do listy kanałów.'); }
|
||||
if (currentVideo && video.readyState >= 2 && video.currentTime > 0) {
|
||||
hasPlayed = true; if (statusPanel) statusPanel.style.display = 'none'; return;
|
||||
}
|
||||
if (hasPlayed) {
|
||||
if (statusPanel) statusPanel.style.display = video?.paused ? 'none' : 'flex';
|
||||
if (statusText) statusText.textContent = text('buffering');
|
||||
return; // A later buffer underrun must not reuse the startup deadline.
|
||||
}
|
||||
if (currentVideo && video.readyState >= 1 && statusText) statusText.textContent = text('buffering');
|
||||
if (!polling && pollFailures < 3 && Date.now() - lastPoll >= 1500 && api.ajax && api.getUrl) {
|
||||
polling = true; lastPoll = Date.now();
|
||||
Promise.resolve().then(() => api.ajax({ type: 'GET', dataType: 'json',
|
||||
url: api.getUrl('DecoderIntegration/PlaybackProgress', { channelId: item.Id }) }))
|
||||
.then(snapshot => {
|
||||
if (ticket !== generation || hasPlayed || (currentVideo && video.readyState >= 1)) return;
|
||||
const text = snapshot?.Available && Date.parse(snapshot.StartedUtc) >= began - 1000 && stageText(snapshot.Phase);
|
||||
if (text && statusText) statusText.textContent = text;
|
||||
}).catch(() => { ++pollFailures; })
|
||||
.finally(() => { polling = false; });
|
||||
}
|
||||
if (Date.now() >= deadline) { void leave(); toast(text('timeout')); }
|
||||
}, 250);
|
||||
try { await playbackManager.play({ items: [item], fullscreen: true }); }
|
||||
catch (error) { if (ticket !== generation) return false; await leave(); throw error; }
|
||||
@@ -106,7 +166,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
||||
query.delete('decoderPlay');
|
||||
history.replaceState(null, '', location.pathname + location.search + route.split('?')[0] + (query.size ? '?' + query : ''));
|
||||
try { await play(id, query.get('serverId')); }
|
||||
catch (error) { console.error('Decoder channel playback failed', error); toast('Nie udało się uruchomić kanału.'); }
|
||||
catch (error) { console.error('Decoder channel playback failed', error); toast(text('play_error')); }
|
||||
};
|
||||
void attempt();
|
||||
};
|
||||
@@ -123,7 +183,7 @@ window.DecoderChannelPlayback = async () => class DecoderChannelPlayback {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
void play(id, card.dataset.serverid).catch(error => {
|
||||
console.error('Decoder channel playback failed', error);
|
||||
toast('Nie udało się uruchomić kanału.');
|
||||
toast(text('play_error'));
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user