/* 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 changeSourceBtn = document.getElementById('changeSourceBtn'); const episodeSection = document.getElementById('episodes'); 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: [], sourceIndex: -1, season: null, episode: null, }; /* ---- status overlay ------------------------------------------------------ */ function showStatus(msg, isError) { statusEl.hidden = false; statusEl.innerHTML = isError ? `

Playback problem

${SR.util.escapeHtml(msg)}

` : `

${SR.util.escapeHtml(msg)}

`; } function hideStatus() { statusEl.hidden = true; } /* ---- 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); }, }); /* ---- 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 }); } async function loadStreams() { const query = { tmdb: id, type }; if (type === 'tv') { if (state.season == null || state.episode == null) return; query.season = state.season; query.episode = state.episode; } sourcesPanel.hidden = true; changeSourceBtn.textContent = 'Change source'; showStatus('Finding streams…'); sourceLabel.textContent = ''; resumeNote.textContent = ''; try { const data = await SR.api.streams(query); state.streams = data.streams || []; renderSources(); changeSourceBtn.hidden = state.streams.length === 0; if (state.streams.length) playSource(0); else showStatus('No streams found for this title.', true); } catch (e) { SR.app.showError(e, 'stream lookup failed'); showStatus('Stream lookup failed.', true); } } function renderSources() { const list = document.getElementById('sourceList'); list.replaceChildren(); state.streams.forEach((s, i) => { const b = document.createElement('button'); b.type = 'button'; b.className = i === state.sourceIndex ? 'source-item source-item--active' : 'source-item'; const meta = [s.provider, s.quality, s.audio].filter(Boolean).join(' · '); b.innerHTML = ` ${i + 1} ${SR.util.escapeHtml(s.name)} ${SR.util.escapeHtml(meta)}`; b.addEventListener('click', () => playSource(i)); list.appendChild(b); }); document.getElementById('sourcesCount').textContent = `${state.streams.length} source${state.streams.length === 1 ? '' : 's'}`; } function playSource(i) { const s = state.streams[i]; if (!s) return; state.sourceIndex = i; renderSources(); sourceLabel.textContent = `${s.name} — ${[s.quality, s.provider].filter(Boolean).join(', ')}`; const rec = SR.storage.get(keyFor()); player.load(s, { resumeAt: rec && rec.t > 10 ? rec.t : null }); } changeSourceBtn.addEventListener('click', () => { sourcesPanel.hidden = !sourcesPanel; changeSourceBtn.textContent = sourcesPanel.hidden ? 'Change source' : 'Hide sources'; }); /* ---- 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); } })(); })();