/* WATCH — hero, player, source list (switch mid-watch), episodes. URL params: ?id=&type= TV adds &s=&e= as playback progresses. */ (function () { 'use strict'; const params = new URLSearchParams(location.search); const id = (params.get('id') || '').trim(); const type = params.get('type') === 'tv' ? 'tv' : 'movie'; SR.app.initChrome(); const content = document.getElementById('content'); if (!id || !/^\d+$/.test(id)) { content.replaceChildren(SR.cards.emptyState('Missing ?id=', 'This page needs a TMDB id, e.g. watch.html?id=1396&type=tv')); return; } const video = document.getElementById('video'); const statusEl = document.getElementById('playerStatus'); const sourceLabel = document.getElementById('sourceLabel'); const resumeNote = document.getElementById('resumeNote'); const sourcesPanel = document.getElementById('sourcesPanel'); const sourcesClose = document.getElementById('sourcesClose'); const episodeSection = document.getElementById('episodes'); // custom player controls const playerWrap = document.getElementById('playerWrap'); const bigPlay = document.getElementById('bigPlay'); const pcSeek = document.getElementById('pcSeek'); const pcPlay = document.getElementById('pcPlay'); const pcMute = document.getElementById('pcMute'); const pcVol = document.getElementById('pcVol'); const pcTime = document.getElementById('pcTime'); const pcCc = document.getElementById('pcCc'); const pcQuality = document.getElementById('pcQuality'); const pcGear = document.getElementById('pcGear'); const pcPip = document.getElementById('pcPip'); const pcFull = document.getElementById('pcFull'); const seasonSelect = document.getElementById('seasonSelect'); const episodeGrid = document.getElementById('episodeGrid'); const prevBtn = document.getElementById('prevEpisode'); const nextBtn = document.getElementById('nextEpisode'); const state = { details: null, seasons: [], episodes: [], streams: [], unsupported: [], providerErrors: {}, loadErrors: {}, sourceIndex: -1, season: null, episode: null, loadedKey: null, }; /* ---- status overlay ------------------------------------------------------ */ function closeSources() { sourcesPanel.hidden = true; pcGear.classList.remove('is-on'); } function showStatus(msg, isError) { closeSources(); statusEl.hidden = false; playerWrap.classList.remove('player-wrap--controls', 'player-wrap--paused'); statusEl.innerHTML = isError ? `

Playback problem

${SR.util.escapeHtml(msg)}

` : `

${SR.util.escapeHtml(msg)}

`; } function hideStatus() { statusEl.hidden = true; syncPaused(); } /* ---- player --------------------------------------------------------------- */ const player = SR.player.create(video, { onStatus: (msg) => showStatus(msg), onReady: (resumeAt) => { hideStatus(); if (resumeAt > 10) resumeNote.textContent = `Resuming at ${SR.util.formatSeconds(resumeAt)}`; }, onError: (msg) => { showStatus(msg, true); SR.app.toast(msg, true); }, }); /* ---- custom player controls -------------------------------------------------- */ const ICON_PLAY = ''; const ICON_PAUSE = ''; const ICON_VOL = ''; const ICON_VOL_X = ''; let hideTimer = null; let scrubbing = false; function paintProgress(el, ratio) { const pct = Math.max(0, Math.min(1, ratio)) * 100; el.style.setProperty('--pc-progress', pct + '%'); } function timeLabel() { const d = video.duration; const ds = d && isFinite(d) ? SR.util.formatSeconds(d) : '0:00'; return `${SR.util.formatSeconds(video.currentTime)} / ${ds}`; } function syncPlayIcon() { const playing = !video.paused && !video.ended; pcPlay.innerHTML = playing ? ICON_PAUSE : ICON_PLAY; pcPlay.setAttribute('aria-label', playing ? 'Pause' : 'Play'); } function syncVolIcon() { const muted = video.muted || video.volume === 0; pcMute.innerHTML = muted ? ICON_VOL_X : ICON_VOL; pcMute.setAttribute('aria-label', muted ? 'Unmute' : 'Mute'); pcMute.classList.toggle('is-on', muted); pcVol.value = String(video.muted ? 0 : video.volume); paintProgress(pcVol, video.muted ? 0 : video.volume); } function syncTimeUI() { if (scrubbing) return; const d = video.duration; if (d && isFinite(d) && d > 0) { pcSeek.value = String(Math.round((video.currentTime / d) * 1000)); paintProgress(pcSeek, video.currentTime / d); } pcTime.textContent = timeLabel(); } function syncPaused() { playerWrap.classList.toggle('player-wrap--paused', video.paused); } function togglePlay() { if (!video.src && !video.currentSrc) return; if (video.paused || video.ended) { video.play().catch(() => {}); } else { video.pause(); } } function showControls() { playerWrap.classList.add('player-wrap--controls'); clearTimeout(hideTimer); hideTimer = setTimeout(() => { if (!video.paused && !video.ended && !scrubbing && sourcesPanel.hidden) { playerWrap.classList.remove('player-wrap--controls'); } }, 3000); } function toggleFullscreen() { const el = playerWrap; const doc = document; if (doc.fullscreenElement || doc.webkitFullscreenElement) { (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc); } else { (el.requestFullscreen || el.webkitRequestFullscreen).call(el); } } function togglePip() { if (!document.pictureInPictureEnabled) { SR.app.toast('Picture-in-picture is not supported in this browser', true); return; } if (document.pictureInPictureElement) { document.exitPictureInPicture().catch(() => {}); } else { video.requestPictureInPicture().catch(() => {}); } } function toggleCc() { const tracks = video.textTracks; let target = null; for (let i = 0; i < tracks.length; i++) { if (tracks[i].kind === 'subtitles') { target = tracks[i]; break; } } if (!target) { SR.app.toast('This source has no subtitles', true); return; } target.mode = target.mode === 'showing' ? 'hidden' : 'showing'; pcCc.classList.toggle('is-on', target.mode === 'showing'); pcCc.setAttribute('aria-pressed', String(target.mode === 'showing')); } // video events video.addEventListener('click', () => { if (!sourcesPanel.hidden) { closeSources(); return; } togglePlay(); }); video.addEventListener('dblclick', toggleFullscreen); video.addEventListener('play', () => { syncPlayIcon(); syncPaused(); showControls(); }); video.addEventListener('pause', () => { syncPlayIcon(); syncPaused(); showControls(); }); video.addEventListener('loadedmetadata', () => { syncPlayIcon(); syncPaused(); syncTimeUI(); }); video.addEventListener('timeupdate', syncTimeUI); video.addEventListener('volumechange', syncVolIcon); // buttons bigPlay.addEventListener('click', (e) => { e.stopPropagation(); togglePlay(); }); pcPlay.addEventListener('click', togglePlay); pcMute.addEventListener('click', () => { video.muted = !video.muted; }); pcCc.addEventListener('click', toggleCc); pcGear.addEventListener('click', () => { if (sourcesPanel.hidden) { sourcesPanel.hidden = false; pcGear.classList.add('is-on'); showControls(); } else { closeSources(); } }); sourcesClose.addEventListener('click', closeSources); pcPip.addEventListener('click', togglePip); pcFull.addEventListener('click', toggleFullscreen); // seek + volume scrubbing pcSeek.addEventListener('pointerdown', () => { scrubbing = true; showControls(); }); pcSeek.addEventListener('input', () => { const d = video.duration; if (d && isFinite(d) && d > 0) { paintProgress(pcSeek, pcSeek.value / 1000); pcTime.textContent = `${SR.util.formatSeconds((pcSeek.value / 1000) * d)} / ${SR.util.formatSeconds(d)}`; } }); pcSeek.addEventListener('change', () => { const d = video.duration; if (d && isFinite(d) && d > 0) { video.currentTime = (pcSeek.value / 1000) * d; } scrubbing = false; syncTimeUI(); }); pcVol.addEventListener('input', () => { video.volume = Number(pcVol.value); video.muted = video.volume === 0; }); // reveal controls on hover/interaction playerWrap.addEventListener('pointermove', showControls); playerWrap.addEventListener('pointerdown', showControls); // keyboard shortcuts document.addEventListener('keydown', (e) => { const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; const d = video.duration; switch (e.key) { case ' ': case 'k': e.preventDefault(); togglePlay(); break; case 'ArrowRight': if (d && isFinite(d)) video.currentTime = Math.min(d, video.currentTime + 10); break; case 'ArrowLeft': if (d && isFinite(d)) video.currentTime = Math.max(0, video.currentTime - 10); break; case 'ArrowUp': e.preventDefault(); video.volume = Math.min(1, video.volume + 0.05); video.muted = false; break; case 'ArrowDown': e.preventDefault(); video.volume = Math.max(0, video.volume - 0.05); break; case 'm': video.muted = !video.muted; break; case 'f': toggleFullscreen(); break; case 'c': toggleCc(); break; case 'Escape': if (!sourcesPanel.hidden) closeSources(); break; } if (video.src || video.currentSrc) showControls(); }); // initial icon state syncPlayIcon(); syncVolIcon(); /* ---- hero ------------------------------------------------------------------ */ function renderHero(d) { document.title = `${d.name || d.title} — bug.tv`; const isTv = type === 'tv'; const year = String((isTv ? d.first_air_date : d.release_date) || '').slice(0, 4); const facts = [ isTv ? `${d.number_of_seasons || 0} seasons` : SR.util.runtimeLabel(d.runtime), year, ] .filter(Boolean) .map(SR.util.escapeHtml) .join('·'); const rating = d.vote_average ? `★ ${Number(d.vote_average).toFixed(1)}` : ''; const genres = (d.genres || []) .map((g) => `${SR.util.escapeHtml(g.name)}`) .join(''); const poster = SR.util.tmdbResize(d.poster_url, 'w500'); const backdrop = SR.util.tmdbResize(d.backdrop_url, 'w1280'); document.getElementById('hero').innerHTML = ` ${backdrop ? `
` : ''} ${poster ? `
` : ''}

${SR.util.escapeHtml(d.name || d.title)}

${rating}${rating && facts ? '·' : ''}${facts}
${genres ? `
${genres}
` : ''} ${d.overview ? `

${SR.util.escapeHtml(d.overview)}

` : ''}
`; } /* ---- url -------------------------------------------------------------------- */ function syncUrl() { const p = new URLSearchParams(); p.set('id', id); p.set('type', type); if (type === 'tv') { if (state.season != null) p.set('s', state.season); if (state.episode != null) p.set('e', state.episode); } history.replaceState(null, '', `?${p.toString()}`); } /* ---- streams ------------------------------------------------------------------ */ function keyFor() { return SR.storage.keyFor({ type, tmdbId: Number(id), season: state.season, episode: state.episode }); } let providerCatalog = null; // cached per page load; enabled names are re-read from localStorage every lookup async function getProviderCatalog() { if (providerCatalog) return providerCatalog; try { const data = await SR.api.providers(); providerCatalog = { all: data.all || [], enabled: data.enabled || [] }; } catch (e) { return { all: [], enabled: [] }; } return providerCatalog; } async function getProviderInfo() { if (SR.config.getProviders()) return SR.config.resolveProviders(null); return SR.config.resolveProviders(await getProviderCatalog()); } async function loadStreams() { const providerInfo = await getProviderInfo(); if (!providerInfo.useServerDefaults && providerInfo.names.length === 0) { closeSources(); pcQuality.textContent = ''; sourceLabel.textContent = ''; resumeNote.textContent = ''; showStatus('No sources are enabled. Open Settings → Sources and enable at least one provider.', true); return; } const query = { tmdb: id, type, providers: providerInfo.useServerDefaults ? null : providerInfo.names, }; if (type === 'tv') { if (state.season == null || state.episode == null) return; query.season = state.season; query.episode = state.episode; } closeSources(); pcQuality.textContent = ''; showStatus('Finding streams…'); sourceLabel.textContent = ''; resumeNote.textContent = ''; try { const data = await SR.api.streams(query); const allStreams = data.streams || []; state.streams = allStreams.filter(SR.util.webPlayable); state.unsupported = allStreams.filter((s) => !SR.util.webPlayable(s)); state.providerErrors = data.providerErrors || {}; state.loadErrors = data.loadErrors || {}; renderSources(); pcGear.hidden = state.streams.length === 0; if (state.streams.length) playSource(0); else { const failed = Object.keys({ ...state.loadErrors, ...state.providerErrors }); const hidden = state.unsupported.length; if (failed.length || hidden) { const parts = ['No web-playable streams found.']; if (failed.length) parts.push(`Provider failures: ${failed.join(', ')}.`); if (hidden) parts.push(`Hidden ${hidden} unsupported source(s).`); showStatus(parts.join(' '), true); } else { showStatus('No streams found for this title.', true); } } } catch (e) { SR.app.showError(e, 'stream lookup failed'); showStatus('Stream lookup failed.', true); } } // Sort the popup by provider: provider name is the primary key (A→Z), // original rank is the tie-breaker so order inside a provider is stable. function sortedStreams() { return state.streams .map((s, i) => ({ s, i })) .sort((a, b) => { const pa = String(a.s.provider || '').toLowerCase(); const pb = String(b.s.provider || '').toLowerCase(); if (pa !== pb) return pa < pb ? -1 : 1; return a.i - b.i; }); } function renderSources() { const list = document.getElementById('sourceList'); list.replaceChildren(); let lastProvider = null; for (const { s, i } of sortedStreams()) { if (s.provider !== lastProvider) { lastProvider = s.provider; const h = document.createElement('div'); h.className = 'player-sources__group'; h.textContent = s.provider || 'Other'; list.appendChild(h); } const b = document.createElement('button'); b.type = 'button'; b.className = i === state.sourceIndex ? 'source-item source-item--active' : 'source-item'; const meta = [s.quality, s.audio].filter(Boolean).join(' · '); b.innerHTML = ` ${i + 1} ${SR.util.escapeHtml(s.name)} ${meta ? `${SR.util.escapeHtml(meta)}` : ''}`; b.addEventListener('click', () => playSource(i)); list.appendChild(b); } const failures = { ...state.loadErrors, ...state.providerErrors }; const failed = Object.keys(failures); if (failed.length) { const note = document.createElement('div'); note.className = 'player-sources__group player-sources__group--error'; note.textContent = `Failed: ${failed.join(', ')}`; note.title = failed.map((name) => `${name}: ${failures[name] || 'no streams'}`).join('\n'); list.appendChild(note); } if (state.unsupported.length) { const types = [ ...new Set(state.unsupported.map((s) => String(s.type || 'unknown').toLowerCase())), ]; const note = document.createElement('div'); note.className = 'player-sources__group player-sources__group--error'; note.textContent = `Hidden unsupported: ${state.unsupported.length} (${types.join(', ')})`; note.title = state.unsupported .map((s) => `${s.provider || 'Other'}: ${s.name || 'stream'} (${s.type || 'unknown'})`) .join('\n'); list.appendChild(note); } const count = `${state.streams.length} source${state.streams.length === 1 ? '' : 's'}`; document.getElementById('sourcesCount').textContent = state.unsupported.length ? `${count} · ${state.unsupported.length} hidden` : count; } function playSource(i) { const s = state.streams[i]; if (!s) return; const key = keyFor(); // Same episode as the one currently loaded → source switch: carry the // playhead over (resumeAt: null). A different episode → fresh resume: // pick up its own saved record, or start from the top. const resumeAt = state.loadedKey === key ? null : (() => { const rec = SR.storage.get(key); return rec && rec.t > 10 ? rec.t : 0; })(); state.sourceIndex = i; state.loadedKey = key; closeSources(); renderSources(); sourceLabel.textContent = `${s.name} — ${[s.quality, s.provider].filter(Boolean).join(', ')}`; pcQuality.textContent = s.quality || ''; player.load(s, { resumeAt }); } /* ---- resume persistence ---------------------------------------------------------- */ let lastPersist = 0; function persistProgress(force) { const d = video.duration; if (!d || !isFinite(d)) return; const t = video.currentTime; if (t < 10) return; const key = keyFor(); if (t >= d - 15 || t / d >= 0.95) { SR.storage.remove(key); return; } const now = Date.now(); if (!force && now - lastPersist < 3000) return; lastPersist = now; const ep = state.episodes.find((e) => e.episode_number === state.episode); SR.storage.save({ key, type, tmdbId: Number(id), season: state.season, episode: state.episode, show: state.details ? state.details.name || state.details.title : '', title: ep ? ep.name || '' : '', poster: state.details ? SR.util.tmdbResize(state.details.poster_url, 'w342') : '', t, d, at: now, provider: state.streams[state.sourceIndex] ? state.streams[state.sourceIndex].provider : '', }); } video.addEventListener('timeupdate', () => persistProgress(false)); video.addEventListener('pause', () => persistProgress(true)); video.addEventListener('seeked', () => persistProgress(true)); video.addEventListener('ended', () => SR.storage.remove(keyFor())); window.addEventListener('pagehide', () => persistProgress(true)); document.addEventListener('visibilitychange', () => { if (document.hidden) persistProgress(true); }); /* ---- episodes ------------------------------------------------------------------------ */ async function loadSeasons() { const data = await SR.api.seasons(id); state.seasons = data.seasons || []; for (const s of state.seasons) { const opt = document.createElement('option'); opt.value = s.season_number; opt.textContent = `${s.name} (${s.episode_count})`; seasonSelect.appendChild(opt); } } function showGridSpinner() { episodeGrid.replaceChildren(); const cell = document.createElement('div'); cell.className = 'loading-cell'; cell.innerHTML = '
'; episodeGrid.appendChild(cell); } function renderEpisodes() { episodeGrid.replaceChildren(); if (!state.episodes.length) { const empty = SR.cards.emptyState('No episodes listed'); empty.style.gridColumn = '1 / -1'; episodeGrid.appendChild(empty); return; } for (const ep of state.episodes) { const b = document.createElement('button'); b.type = 'button'; b.className = ep.episode_number === state.episode ? 'episode episode--current' : 'episode'; const date = ep.air_date ? ` · ${SR.util.escapeHtml(ep.air_date)}` : ''; b.innerHTML = ` S${state.season} · E${ep.episode_number}${date} ${SR.util.escapeHtml(ep.name || `Episode ${ep.episode_number}`)} `; b.addEventListener('click', () => playEpisode(ep.episode_number)); episodeGrid.appendChild(b); } } async function loadEpisodes(season, autoplayEpisode) { state.season = season; seasonSelect.value = String(season); showGridSpinner(); const data = await SR.api.episodes(id, season); state.episodes = data.episodes || []; renderEpisodes(); syncUrl(); if (autoplayEpisode && state.episodes.some((e) => e.episode_number === autoplayEpisode)) { playEpisode(autoplayEpisode); } } function playEpisode(n) { state.episode = n; syncUrl(); renderEpisodes(); loadStreams(); document.getElementById('playerSection').scrollIntoView({ behavior: 'smooth', block: 'start' }); } seasonSelect.addEventListener('change', async () => { const season = parseInt(seasonSelect.value, 10); if (!season) return; try { await loadEpisodes(season); } catch (e) { SR.app.showError(e, 'failed to load episodes'); } }); async function stepEpisode(delta) { if (type !== 'tv') return; const idx = state.episodes.findIndex((e) => e.episode_number === state.episode); if (idx >= 0 && idx + delta >= 0 && idx + delta < state.episodes.length) { playEpisode(state.episodes[idx + delta].episode_number); return; } const sIdx = state.seasons.findIndex((s) => s.season_number === state.season); if (sIdx < 0 || sIdx + delta < 0 || sIdx + delta >= state.seasons.length) return; try { await loadEpisodes(state.seasons[sIdx + delta].season_number); const first = state.episodes[0]; if (first) playEpisode(first.episode_number); } catch (e) { SR.app.showError(e, 'failed to load episodes'); } } prevBtn.addEventListener('click', () => stepEpisode(-1)); nextBtn.addEventListener('click', () => stepEpisode(1)); /* ---- init ------------------------------------------------------------------------------ */ (async function init() { let details; try { details = await SR.api.details(type, id); } catch (e) { SR.app.showError(e, 'failed to load this title'); showStatus('Could not load this title.', true); return; } state.details = details; renderHero(details); if (type !== 'tv') { loadStreams(); return; } episodeSection.hidden = false; showStatus('Pick an episode to start.'); try { await loadSeasons(); } catch (e) { SR.app.showError(e, 'failed to load seasons'); return; } if (!state.seasons.length) { showStatus('No seasons found for this show.', true); return; } const sParam = parseInt(params.get('s'), 10); const eParam = parseInt(params.get('e'), 10); const season = state.seasons.some((s) => s.season_number === sParam) ? sParam : state.seasons[0].season_number; try { await loadEpisodes(season, eParam || null); } catch (e) { SR.app.showError(e, 'failed to load episodes'); showStatus('Failed to load episodes.', true); } })(); })();