Files
TV/js/player.js

794 lines
27 KiB
JavaScript

let currentHls = null;
let currentBgHls = null;
let currentSources = [];
let resolvedStreams = [];
let selectedSourceKey = null;
let selectedStreamUrl = null;
let expandedSources = new Set();
let currentQualityLevels = [];
let selectedQualityIndex = -1;
let activeSubtitles = [];
let mediaSegments = [];
let activeSegment = null;
let plPrev = null;
let plT = null;
let plItem = null;
let pmK = null;
let plCtx = null;
let viewS = null;
let drag = 0;
let discoverySession = 0;
let userHasManuallySelectedSource = false;
let subSettings = {
size: 24,
color: '#ffffff',
bg: 'rgba(0,0,0,0.75)',
offset: 0
};
const pi = (p, f) => `<svg viewBox="0 0 24 24" fill="${f ? 'currentColor' : 'none'}" stroke="${f ? 'none' : 'currentColor'}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const SPK = '<path d="M4 9v6h4l5 4V5L8 9z"/>';
const PI = {
x: pi('<path d="M5 5l14 14M19 5 5 19"/>'),
play: pi('<path d="M7 4.5v15L19.5 12z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>', 1),
pause: pi('<rect x="6" y="4.5" width="4" height="15" rx="1.2"/><rect x="14" y="4.5" width="4" height="15" rx="1.2"/>', 1),
cc: pi('<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10a2 2 0 1 0 0 4M17 10a2 2 0 1 0 0 4"/>'),
vol: pi(SPK + '<path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a9 9 0 0 1 0 12"/>'),
mute: pi(SPK + '<path d="m17 9.5 4 5m0-5-4 5"/>'),
qlt: pi('<path d="M4 6h16M4 12h16M4 18h7"/>'),
set: pi('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
more: pi('<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.4"/>'),
fs: pi('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>'),
fx: pi('<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>'),
eps: pi('<rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="3" y="14" width="7" height="6" rx="1.5"/><rect x="14" y="14" width="7" height="6" rx="1.5"/>'),
chk: pi('<path d="m5 12.5 4.5 4.5L19 7"/>')
};
$('#ptop').innerHTML = `<div class="pic f" id="pback">${PI.x}</div><img class="av" id="pav" alt=""><div class="pt"><b id="pt"></b><small id="ps"></small></div><div class="pr"><div class="pic f" id="pqlt" title="Quality">${PI.qlt}</div><div class="pic f" id="pcc">${PI.cc}</div><div class="pic f" id="pvol">${PI.vol}</div><div class="pic f" id="pset">${PI.set}</div><div class="pic f" id="pmore">${PI.more}</div></div>`;
$('#pbot').innerHTML = `<span class="bt" id="pcur">0:00</span><div class="sk f" id="pseek"><u id="pbuf"></u><i id="pfill"></i><b id="phd"></b></div><span class="bt" id="pdur">0:00</span><div class="pic f" id="peps">${PI.eps}</div><div class="pic f" id="pfs">${PI.fs}</div>`;
$('#pmid').innerHTML = PI.play;
const pm = $('#pm');
const sk = $('#pseek');
const pbg = $('#pbg');
const fmt = t => {
t = Math.max(0, Math.floor(t || 0));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const c = t % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(c).padStart(2, '0');
};
const tgl = () => vd.paused ? vd.play() : vd.pause();
const pico = () => {
$('#pmid').innerHTML = vd.paused ? PI.play : PI.pause;
};
function showCtl() {
pl.classList.add('ui');
clearTimeout(plT);
plT = setTimeout(() => {
if (!vd.paused && !pmK && !pl.classList.contains('eps')) {
pl.classList.remove('ui');
}
}, 3800);
}
function closeMenu(rf) {
if (!pmK) return;
const t = {
qlt: '#pqlt',
cc: '#pcc',
vol: '#pvol',
set: '#pset',
more: '#pmore'
}[pmK];
pmK = null;
pm.classList.remove('open');
pm.innerHTML = '';
if (rf) setFocus($(t));
}
function updateSubStyles() {
vd.classList.add('sub-active');
vd.style.setProperty('--sub-size', `${subSettings.size}px`);
vd.style.setProperty('--sub-color', subSettings.color);
vd.style.setProperty('--sub-bg', subSettings.bg);
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue && !cue._initialOffsetApplied) {
cue.startTime += subSettings.offset;
cue.endTime += subSettings.offset;
}
}
}
});
}
function applySubOffset(delta) {
subSettings.offset += delta;
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue) {
cue.startTime += delta;
cue.endTime += delta;
}
}
}
});
}
function buildProxyUrl(rawUrl, headersObj = {}) {
return `${API_BASE}/api/proxy?url=${encodeURIComponent(rawUrl)}&headers=${encodeURIComponent(JSON.stringify(headersObj))}`;
}
async function fetchSources() {
currentSources = Vyla.sources();
}
async function startConcurrentDiscovery(it, s = null, e = null) {
const session = ++discoverySession;
pl.classList.add('buf');
resolvedStreams = [];
selectedStreamUrl = null;
let foundFirstPlayable = false;
if (!currentSources.length) {
await fetchSources();
}
if (session !== discoverySession) return;
const sourcePromises = currentSources.map(async (src) => {
try {
const data = await Vyla.stream(src.key, it.id, s, e);
if (session !== discoverySession) return;
if (data.success && data.stream) {
const streamData = data.stream;
const headers = streamData.headers || {};
let playable = [];
if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) {
playable = streamData.allUrls;
} else if (streamData.url) {
playable = [{ url: streamData.url, quality: streamData.quality || 'Auto', isM3U8: streamData.isM3U8 }];
}
if (playable.length > 0) {
const resolvedEntry = {
provider: src.key,
label: src.label || src.key,
headers,
primaryUrl: playable[0].url,
allUrls: playable
};
resolvedStreams.push(resolvedEntry);
if (pmK === 'set') {
renderSourcesMenu();
}
if (!foundFirstPlayable && !userHasManuallySelectedSource) {
foundFirstPlayable = true;
selectedSourceKey = src.key;
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
} else if (userHasManuallySelectedSource && selectedSourceKey === src.key && !selectedStreamUrl) {
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
}
}
}
} catch (_) { }
});
Promise.allSettled(sourcePromises).then(() => {
if (session !== discoverySession) return;
if (!resolvedStreams.length) {
pl.classList.remove('buf');
toast('No playable sources available.');
}
});
loadSubtitles(it.id, s, e);
loadTimestamps(it.id, s, e);
}
function playSpecificStream(streamUrl, headers = {}) {
pl.classList.add('buf');
selectedStreamUrl = streamUrl;
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
const finalUrl = buildProxyUrl(streamUrl, headers);
if (streamUrl.includes('.m3u8')) {
if (Hls.isSupported()) {
currentHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentHls.loadSource(finalUrl);
currentHls.attachMedia(vd);
currentHls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
currentQualityLevels = data.levels || [];
vd.play().catch(() => { });
});
currentHls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
selectedQualityIndex = data.level;
});
if (pbg) {
currentBgHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentBgHls.loadSource(finalUrl);
currentBgHls.attachMedia(pbg);
currentBgHls.on(Hls.Events.MANIFEST_PARSED, () => {
pbg.muted = true;
if (!vd.paused) pbg.play().catch(() => { });
});
}
} else if (vd.canPlayType('application/vnd.apple.mpegurl')) {
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
} else {
currentQualityLevels = [];
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
}
function setPlaybackQuality(levelIndex) {
selectedQualityIndex = levelIndex;
if (currentHls) {
currentHls.currentLevel = levelIndex;
}
}
async function loadSubtitles(id, s, e) {
Array.from(vd.querySelectorAll('track')).forEach(t => t.remove());
activeSubtitles = [];
try {
activeSubtitles = await Vyla.subtitles(id, s, e);
activeSubtitles.forEach((sub, idx) => {
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = `${sub.label} (${sub.source || 'mirror'})`;
track.srclang = sub.label.slice(0, 2).toLowerCase();
track.src = buildProxyUrl(sub.file);
track.default = idx === 0;
vd.appendChild(track);
track.track.mode = 'disabled';
});
updateSubStyles();
} catch (_) { }
}
async function loadTimestamps(tmdbId, season = null, episode = null, durationSec = null) {
mediaSegments = [];
activeSegment = null;
$('#pskip').classList.remove('v');
try {
let q = `${API_BASE}/api/timestamps?tmdb_id=${tmdbId}`;
if (season) q += `&season=${season}`;
if (episode) q += `&episode=${episode}`;
if (durationSec) q += `&duration_ms=${Math.round(durationSec * 1000)}`;
const res = await fetch(q);
const data = await res.json();
mediaSegments = data.segments || [];
} catch (_) {
mediaSegments = [];
}
}
function renderSourcesMenu() {
let container = $('#pm-src-list');
if (!container) return;
container.innerHTML = '';
if (!resolvedStreams.length) {
container.innerHTML = '<div style="padding:10px 14px;color:rgba(255,255,255,0.4);font-size:13px;">Probing providers...</div>';
return;
}
resolvedStreams.forEach(s => {
const grp = document.createElement('div');
grp.className = 'src-group';
const hdr = document.createElement('div');
hdr.className = 'src-header f' + (s.provider === selectedSourceKey ? ' on' : '');
const hasMulti = s.allUrls && s.allUrls.length > 1;
const countLabel = hasMulti ? ` (${s.allUrls.length})` : '';
const expandIcon = hasMulti ? `<span class="src-expand-btn">${expandedSources.has(s.provider) ? '▲' : '▼'}</span>` : '';
hdr.innerHTML = `<span>${s.label}${countLabel}</span><div>${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}</div>`;
hdr._a = () => {
userHasManuallySelectedSource = true;
if (hasMulti && s.provider === selectedSourceKey) {
if (expandedSources.has(s.provider)) expandedSources.delete(s.provider);
else expandedSources.add(s.provider);
renderSourcesMenu();
} else {
selectedSourceKey = s.provider;
playSpecificStream(s.primaryUrl, s.headers);
renderSourcesMenu();
}
};
grp.appendChild(hdr);
if (hasMulti) {
const sub = document.createElement('div');
sub.className = 'src-sublist' + (expandedSources.has(s.provider) ? ' open' : '');
s.allUrls.forEach((u, i) => {
const item = document.createElement('div');
item.className = 'src-subitem f' + (u.url === selectedStreamUrl ? ' on' : '');
item.innerHTML = `<span>${u.quality || 'Stream ' + (i + 1)}</span>${u.url === selectedStreamUrl ? PI.chk : ''}`;
item._a = () => {
userHasManuallySelectedSource = true;
selectedSourceKey = s.provider;
playSpecificStream(u.url, s.headers);
renderSourcesMenu();
};
sub.appendChild(item);
});
grp.appendChild(sub);
}
container.appendChild(grp);
});
}
function openMenu(k) {
if (pmK === k) {
closeMenu(1);
return;
}
closeEps();
pm.innerHTML = '';
pmK = k;
pm.classList.add('open');
let first = null;
const h = t => {
const e = document.createElement('h4');
e.textContent = t;
pm.appendChild(e);
};
const r = (l, on, fn) => {
const e = mkf('pi', `<span>${l}</span>${on ? PI.chk : ''}`, () => {
fn();
closeMenu(1);
});
pm.appendChild(e);
if (on && !first) first = e;
return e;
};
if (k === 'qlt') {
h('Stream Quality');
if (currentHls && currentQualityLevels.length > 0) {
r('Auto', selectedQualityIndex === -1, () => setPlaybackQuality(-1));
currentQualityLevels.forEach((lvl, idx) => {
const label = lvl.height ? `${lvl.height}p` : `${Math.round(lvl.bitrate / 1000)} kbps`;
r(label, selectedQualityIndex === idx, () => setPlaybackQuality(idx));
});
} else {
const activeProv = resolvedStreams.find(s => s.provider === selectedSourceKey);
if (activeProv && activeProv.allUrls.length > 1) {
activeProv.allUrls.forEach((u, idx) => {
const label = u.quality || `Stream ${idx + 1}`;
r(label, u.url === selectedStreamUrl, () => {
userHasManuallySelectedSource = true;
playSpecificStream(u.url, activeProv.headers);
});
});
} else {
r('Auto', true, () => { });
}
}
} else if (k === 'cc') {
h('Subtitles');
const T = [...vd.textTracks];
r('Off', !T.some(t => t.mode === 'showing'), () => {
T.forEach(t => t.mode = 'disabled');
});
T.forEach(t => r(t.label, t.mode === 'showing', () => {
T.forEach(u => u.mode = u === t ? 'showing' : 'disabled');
updateSubStyles();
}));
h('Subtitle Size');
[18, 22, 26, 32].forEach(sz => {
r(`${sz}px`, subSettings.size === sz, () => {
subSettings.size = sz;
updateSubStyles();
});
});
h('Subtitle Color');
[['White', '#ffffff'], ['Yellow', '#ffd700'], ['Cyan', '#00ffff']].forEach(([col, code]) => {
r(col, subSettings.color === code, () => {
subSettings.color = code;
updateSubStyles();
});
});
h('Subtitle Sync');
const syncRow = document.createElement('div');
syncRow.className = 'sub-ctrl-row';
syncRow.innerHTML = `<span>Timing Offset</span><div class="sub-btn-group"><button id="sub-min">-0.5s</button><button id="sub-plus">+0.5s</button></div>`;
pm.appendChild(syncRow);
$('#sub-min').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(-0.5);
};
$('#sub-plus').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(0.5);
};
} else if (k === 'vol') {
h('Volume');
const w = document.createElement('div');
w.className = 'vrow';
w.innerHTML = '<div class="pic f" id="vm"></div><div class="vs f" id="vsl"><i></i></div><span id="vpc"></span>';
pm.appendChild(w);
const vm = $('#vm');
const vs = $('#vsl');
const vu = () => {
const v = vd.muted ? 0 : vd.volume;
vs.firstChild.style.width = v * 100 + '%';
$('#vpc').textContent = Math.round(v * 100);
vm.innerHTML = $('#pvol').innerHTML = v ? PI.vol : PI.mute;
};
vm._a = () => {
vd.muted = !vd.muted;
vu();
};
vs._a = () => { };
vs._v = vu;
vs.addEventListener('click', e => {
const b = vs.getBoundingClientRect();
vd.muted = false;
vd.volume = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width));
vu();
});
vu();
first = vs;
} else if (k === 'set') {
h('Available Sources');
const srcContainer = document.createElement('div');
srcContainer.id = 'pm-src-list';
pm.appendChild(srcContainer);
renderSourcesMenu();
h('Playback Speed');
[.5, 1, 1.25, 1.5, 2].forEach(v => r(v === 1 ? 'Normal' : v + '\u00d7', vd.playbackRate === v, () => vd.playbackRate = v));
h('Aspect Ratio');
r('Fit Screen', !pl.classList.contains('cover'), () => {
pl.classList.remove('cover');
if (pbg) {
pbg.currentTime = vd.currentTime;
if (!vd.paused) pbg.play().catch(() => { });
}
});
r('Fill Screen', pl.classList.contains('cover'), () => {
pl.classList.add('cover');
if (pbg) pbg.pause();
});
} else {
h('Library');
r('Watch Later', has('wl', plItem), () => toggleLib('wl', plItem));
r('Favorites', has('fav', plItem), () => toggleLib('fav', plItem));
}
showCtl();
setFocus(first || pm.querySelector('.f'));
}
async function openPlayer(it, ep, ctx) {
if (!it) return;
plPrev = cur;
plItem = it;
plCtx = ctx || null;
userHasManuallySelectedSource = false;
$('#peps').style.display = plCtx ? '' : 'none';
pl.classList.remove('eps');
$('#pnext').classList.remove('v');
$('#pt').textContent = it.title || it.name || '';
$('#ps').textContent = ep || [yr(it), it._t === 'tv' ? 'Series' : 'Movie'].filter(Boolean).join(' \u2022 ');
$('#pav').src = it.poster_path ? I + 'w185' + it.poster_path : PH;
vd.poster = it.backdrop_path ? I + 'w1280' + it.backdrop_path : '';
pl.classList.add('show');
showCtl();
pico();
setFocus($('#pmid'));
if (plCtx) {
const epData = plCtx.eps[plCtx.i];
startConcurrentDiscovery(it, plCtx.s, epData.episode_number);
} else {
startConcurrentDiscovery(it);
}
}
function closePlayer() {
closeMenu();
closeEps();
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
plCtx = null;
vd.pause();
vd.removeAttribute('src');
vd.load();
if (pbg) {
pbg.pause();
pbg.removeAttribute('src');
pbg.load();
}
if (document.fullscreenElement) document.exitFullscreen();
pl.classList.remove('show', 'ui');
if (plPrev && document.body.contains(plPrev)) setFocus(plPrev);
}
$('#pback')._a = closePlayer;
$('#pmid')._a = tgl;
$('#pskip')._a = () => {
if (activeSegment) {
const target = activeSegment.end != null ? activeSegment.end + 0.5 : (vd.duration || vd.currentTime + 30);
vd.currentTime = target;
$('#pskip').classList.remove('v');
activeSegment = null;
} else {
vd.currentTime += 85;
}
};
$('#pqlt')._a = () => openMenu('qlt');
$('#pcc')._a = () => openMenu('cc');
$('#pvol')._a = () => openMenu('vol');
$('#pset')._a = () => openMenu('set');
$('#pmore')._a = () => openMenu('more');
$('#pfs')._a = () => document.fullscreenElement ? document.exitFullscreen() : pl.requestFullscreen && pl.requestFullscreen();
document.addEventListener('fullscreenchange', () => {
$('#pfs').innerHTML = document.fullscreenElement ? PI.fx : PI.fs;
});
const sp = e => {
const b = sk.getBoundingClientRect();
vd.currentTime = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width)) * (vd.duration || 0);
};
sk.addEventListener('pointerdown', e => {
drag = 1;
sk._m = 1;
sk.setPointerCapture(e.pointerId);
sp(e);
});
sk.addEventListener('pointermove', e => {
if (drag) sp(e);
});
sk.addEventListener('pointerup', () => drag = 0);
sk._a = () => {
if (sk._m) {
sk._m = 0;
return;
}
tgl();
};
pl.querySelectorAll('.f').forEach(e => {
if (!e._a) e._a = () => { };
});
vd.addEventListener('click', () => {
if (pmK) closeMenu();
else if (pl.classList.contains('eps')) closeEps();
else tgl();
showCtl();
});
vd.addEventListener('play', () => {
if (!pl.classList.contains('cover') && pbg) {
pbg.muted = true;
pbg.currentTime = vd.currentTime;
pbg.play().catch(() => { });
}
});
vd.addEventListener('pause', () => {
if (pbg) pbg.pause();
});
vd.addEventListener('seeking', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('seeked', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('ratechange', () => {
if (pbg) pbg.playbackRate = vd.playbackRate;
});
vd.addEventListener('durationchange', () => {
$('#pdur').textContent = fmt(vd.duration);
if (vd.duration && plItem) {
const c = plCtx;
loadTimestamps(plItem.id, c ? c.s : null, c ? c.eps[c.i].episode_number : null, vd.duration);
}
});
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const t = vd.currentTime;
const p = d ? (t / d) * 100 : 0;
$('#pfill').style.width = p + '%';
$('#phd').style.left = p + '%';
$('#pcur').textContent = fmt(t);
if (d && vd.buffered.length) {
$('#pbuf').style.width = (vd.buffered.end(vd.buffered.length - 1) / d * 100) + '%';
}
if (!pl.classList.contains('cover') && pbg && Math.abs(pbg.currentTime - t) > 0.35) {
pbg.currentTime = t;
}
const currentSeg = mediaSegments.find(seg => {
const afterStart = t >= seg.start;
const beforeEnd = seg.end == null ? (d ? t < d - 5 : true) : t < seg.end;
return afterStart && beforeEnd;
});
if (currentSeg) {
activeSegment = currentSeg;
$('#pskip').textContent = currentSeg.label;
$('#pskip').classList.add('v');
} else {
activeSegment = null;
$('#pskip').classList.remove('v');
}
});
['play', 'pause', 'ended'].forEach(n => vd.addEventListener(n, pico));
['loadstart', 'waiting', 'seeking'].forEach(n => vd.addEventListener(n, () => pl.classList.add('buf')));
['playing', 'canplay', 'seeked', 'error'].forEach(n => vd.addEventListener(n, () => pl.classList.remove('buf')));
const hasNext = () => !!plCtx && (plCtx.i + 1 < plCtx.eps.length || plCtx.seasons.some(x => x.season_number > plCtx.s));
const getSeason = async s => plCtx.cache[s] || (plCtx.cache[s] = (await api(`/tv/${plCtx.it.id}/season/${s}`)).episodes || []);
async function playEp(s, i) {
const c = plCtx;
if (!c) return;
const eps = await getSeason(s);
const p = eps[i];
if (!p || plCtx !== c) return;
c.s = s;
c.i = i;
c.eps = eps;
$('#ps').textContent = `S${s} E${p.episode_number} \u2022 ${p.name || ''}`;
$('#pnext').classList.remove('v');
closeEps();
showCtl();
setFocus($('#pmid'));
userHasManuallySelectedSource = false;
startConcurrentDiscovery(c.it, s, p.episode_number);
}
function nextEp() {
const c = plCtx;
if (!hasNext()) return;
if (c.i + 1 < c.eps.length) {
playEp(c.s, c.i + 1);
} else {
playEp(c.seasons.find(x => x.season_number > c.s).season_number, 0);
}
}
function closeEps(rf) {
if (!pl.classList.contains('eps')) return;
pl.classList.remove('eps');
if (rf) setFocus($('#peps'));
}
function openEps() {
const c = plCtx;
if (!c) return;
closeMenu();
$('#peT').innerHTML = `Episodes<small>${esc(c.it.title || c.it.name || '')}</small>`;
const sr = $('#pseas');
sr.innerHTML = '';
c.seasons.forEach(x => {
sr.appendChild(mkf('chip', esc(x.name || 'Season ' + x.season_number), () => showSeason(x.season_number)));
});
pl.classList.add('eps');
showCtl();
showSeason(c.s, true);
}
async function showSeason(sn, focus) {
const c = plCtx;
const car = $('#pcar');
if (!c) return;
viewS = sn;
[...$('#pseas').children].forEach((e, i) => e.classList.toggle('act', c.seasons[i].season_number === sn));
car.innerHTML = SK.pep(8);
const eps = await getSeason(sn);
if (viewS !== sn || plCtx !== c) return;
car.innerHTML = '';
eps.forEach((p, i) => {
const n = p.episode_number;
const cur = c.s === sn && c.i === i;
const img = p.still_path ? I + 'w300' + p.still_path : (c.it.backdrop_path ? I + 'w300' + c.it.backdrop_path : PH);
car.appendChild(mkf('pe' + (cur ? ' cur' : ''), `<div class="et"><img loading="lazy" src="${img}" alt=""><i>${n}</i>${cur ? '<span class="eq"><u></u><u></u><u></u></span><span class="pg"><u></u></span>' : ''}</div><span class="pn">${esc(p.name || 'Episode ' + n)}</span><small>${p.runtime ? p.runtime + 'm' : 'Episode ' + n}</small>`, () => playEp(sn, i)));
});
const t = car.querySelector('.cur') || car.firstChild;
if (focus && t) setFocus(t);
}
$('#pep').innerHTML = `<div class="peh"><h3 id="peT"></h3><div class="row" id="pseas"></div><div class="pic f" id="pepx">${PI.x}</div></div><div class="row pcar" id="pcar"></div>`;
$('#pnext').innerHTML = 'Next Episode' + pi('<path d="M6 5.5v13l9-6.5z"/><path d="M18 5.5v13"/>');
$('#pnext')._a = nextEp;
$('#pepx')._a = () => closeEps(1);
$('#peps')._a = () => pl.classList.contains('eps') ? closeEps(1) : openEps();
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const r = d - vd.currentTime;
$('#pnext').classList.toggle('v', !!d && hasNext() && r <= 300 && r > 0);
$('#pep').style.setProperty('--p', d ? (vd.currentTime / d) * 100 + '%' : '0%');
});
vd.addEventListener('ended', () => {
if (hasNext()) nextEp();
});