// Jellyfin Web's supported window plugin API supplies the native playback manager. window.DecoderChannelPlayback = async () => class DecoderChannelPlayback { constructor({ playbackManager, toast, appRouter, loading, events, appHost }) { this.id = 'decoder-channel-playback'; this.name = 'Decoder channel playback'; this.type = 'integration'; // Limit only this provider's web live TV negotiation, preserving the shared client profile. if (appHost?.getDeviceProfile && !appHost.getDeviceProfile.decoderH264Policy) { const originalProfile = appHost.getDeviceProfile; const getDeviceProfile = async function (item, options) { const profile = await originalProfile.call(this, item, options); const api = window.ApiClient; if (!profile || item?.Type !== 'TvChannel' || window.NativeShell || playbackManager.getCurrentPlayer()?.isLocalPlayer === false || !api?.getCurrentUserId() || (item.ServerId && item.ServerId !== api.serverId())) return profile; try { // Fresh policy per negotiation; an unavailable endpoint preserves native behavior. let timeout; const request = api.ajax({ type: 'GET', dataType: 'json', url: api.getUrl('DecoderIntegration/WebPlaybackPolicy', { channelId: item.Id }) }); const policy = await Promise.race([request, new Promise(resolve => { timeout = setTimeout(() => resolve(null), 2000); })]) .finally(() => clearTimeout(timeout)); if (!policy?.IsEnigmaChannel || !policy.PreferH264) return profile; const video = p => p.Type === 'Video' && (!p.Context || p.Context === 'Streaming'); const h264 = p => (p.VideoCodec || '').split(',').some(c => c.trim().toLowerCase() === 'h264'); if (!profile.TranscodingProfiles?.some(p => video(p) && h264(p))) return profile; const copy = JSON.parse(JSON.stringify(profile)); copy.TranscodingProfiles = copy.TranscodingProfiles.filter(p => !video(p) || h264(p)) .map(p => video(p) ? { ...p, VideoCodec: 'h264' } : p); return copy; } catch { return profile; } }; getDeviceProfile.decoderH264Policy = true; try { appHost.getDeviceProfile = getDeviceProfile; } catch { /* A client exposing a read-only host keeps its native negotiation. */ } } // Jellyfin's globalize module sets 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, StartupTimeoutSeconds: response.StartupTimeoutSeconds } : 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'); } }; const play = (id, serverId) => { if (starting) return starting; const ticket = ++generation; let request; request = (async () => { const api = window.ApiClient; if (!api?.getCurrentUserId() || (serverId && api.serverId() !== serverId)) return false; 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 = 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); // Use this provider's bounded startup budget; retain compatibility with older DLLs. let opened = false, opening = false, hasPlayed = false, polling = false, pollFailures = 0, lastPoll = -Infinity; const configuredWait = Number(messages.StartupTimeoutSeconds); const waitMs = Number.isFinite(configuredWait) && configuredWait >= 45 && configuredWait <= 225 ? configuredWait * 1000 : 45000; const began = Date.now(), deadline = began + waitMs, 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; } 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 (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; } return true; })().finally(() => { if (starting === request) starting = null; }); starting = request; return request; }; window.addEventListener('keydown', event => { if (event.key === 'Escape' && cancelButton) { event.preventDefault(); event.stopImmediatePropagation(); void leave(); } }, true); let pending; const handleRoute = () => { clearTimeout(pending); const route = location.hash; const query = new URLSearchParams(route.split('?')[1] || ''); if (route.split('?')[0].toLowerCase() === '#/livetv' && !query.has('tab')) { query.set('tab', '2'); location.replace('#/livetv?' + query); return; } const id = query.get('decoderPlay'); if (!id || !/^[a-f\d]{32}$|^[a-f\d-]{36}$/i.test(id)) return; const deadline = Date.now() + 30000; const attempt = async () => { if (location.hash !== route) return; if (!window.ApiClient?.getCurrentUserId()) { if (Date.now() < deadline) pending = setTimeout(attempt, 250); return; } 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(text('play_error')); } }; void attempt(); }; window.addEventListener('hashchange', handleRoute); handleRoute(); // Channel image/name clicks in native channel cards should also play immediately. document.addEventListener('click', event => { if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; const link = event.target.closest('a.cardImageContainer, a.itemAction.textActionButton'); const card = link?.closest('[data-type="TvChannel"]'); if (!card) return; const id = card.dataset.id; if (!id) return; event.preventDefault(); event.stopImmediatePropagation(); void play(id, card.dataset.serverid).catch(error => { console.error('Decoder channel playback failed', error); toast(text('play_error')); }); }, true); } };