130 lines
8.1 KiB
JavaScript
130 lines
8.1 KiB
JavaScript
// 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. */ }
|
|
}
|
|
let generation = 0, starting = null, cancelButton = null, watcher = null;
|
|
const clearControls = () => { clearInterval(watcher); watcher = null; cancelButton?.remove(); cancelButton = null; };
|
|
events.on(playbackManager, 'playbackstop', () => { if (!starting) clearControls(); });
|
|
document.addEventListener('fullscreenchange', () => {
|
|
if (cancelButton) (document.fullscreenElement || document.body).append(cancelButton);
|
|
});
|
|
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 = await api.getItem(api.getCurrentUserId(), id);
|
|
if (ticket !== generation) return false;
|
|
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.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);
|
|
let opened = false, opening = false; const deadline = Date.now() + 25000;
|
|
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) {
|
|
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.'); }
|
|
}, 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('Nie udało się uruchomić kanału.'); }
|
|
};
|
|
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('Nie udało się uruchomić kanału.');
|
|
});
|
|
}, true);
|
|
}
|
|
};
|