diff --git a/web/css/components.css b/web/css/components.css index fa84507..277d9d0 100644 --- a/web/css/components.css +++ b/web/css/components.css @@ -246,7 +246,7 @@ select.field { } .card__ep { - color: var(--color-text); + color: var(--color-text-dim); font-weight: var(--font-weight-semibold); letter-spacing: 0.03em; white-space: nowrap; diff --git a/web/css/pages.css b/web/css/pages.css index 3ac7fdf..4f316c9 100644 --- a/web/css/pages.css +++ b/web/css/pages.css @@ -258,99 +258,6 @@ color: var(--color-text-faint); } -/* ---- watch: hero ------------------------------------------------------------- */ - -.watch-hero { - position: relative; - display: flex; - gap: var(--space-5); - align-items: flex-end; - margin-bottom: var(--space-5); - padding: var(--space-5); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - overflow: hidden; - background: var(--color-surface); -} - -.watch-hero__backdrop { - position: absolute; - inset: 0; - z-index: 0; - pointer-events: none; -} - -.watch-hero__backdrop img { - width: 100%; - height: 100%; - object-fit: cover; - opacity: 0.25; -} - -.watch-hero__poster { - position: relative; - z-index: 1; - flex: 0 0 180px; - width: 180px; - aspect-ratio: var(--poster-aspect); - border-radius: var(--radius-md); - overflow: hidden; - border: 1px solid var(--color-border-strong); - box-shadow: var(--shadow-2); - background: var(--color-surface-2); -} - -.watch-hero__poster img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.watch-hero__body { - position: relative; - z-index: 1; - min-width: 0; - flex: 1; -} - -.watch-hero__title { - font-size: var(--text-3xl); - letter-spacing: -0.02em; - margin-bottom: var(--space-2); -} - -.watch-hero__facts { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-3); - font-size: var(--text-sm); - color: var(--color-text-dim); -} - -.watch-hero__rating { - color: var(--color-star); - font-weight: var(--font-weight-semibold); -} - -.watch-hero__genres { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-3); -} - -.watch-hero__overview { - margin-top: var(--space-4); - max-width: 72ch; - color: var(--color-text-dim); - font-size: var(--text-sm); - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; -} - /* ---- watch: player (.player-wrap + controls live in components.css) ------------- */ .player-status { @@ -399,138 +306,6 @@ flex: 1; } -.loading-cell { - grid-column: 1 / -1; - display: flex; - justify-content: center; - padding: var(--space-8) 0; -} - -/* ---- watch: episodes --------------------------------------------------------- */ - -.episodes { - margin-top: var(--space-7); -} - -.episodes__toolbar { - display: flex; - align-items: center; - gap: var(--space-3); - flex-wrap: wrap; - margin-bottom: var(--space-4); -} - -.episodes__title { - font-size: var(--text-xl); - font-weight: var(--font-weight-semibold); - margin-right: auto; -} - -.episodes__toolbar select.field { - width: auto; -} - -.episode-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: var(--space-3); -} - -.episode { - display: flex; - gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface); - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease; -} - -.episode:hover { - border-color: var(--color-border-strong); - background: var(--color-surface-hover); -} - -.episode--current { - border-color: var(--color-accent); -} - -.episode__thumb { - position: relative; - flex: 0 0 108px; - width: 108px; - aspect-ratio: 16 / 9; - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-2); -} - -.episode__thumb img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.episode__thumb--empty { - display: grid; - place-items: center; - color: var(--color-text-faint); - font-size: var(--text-lg); - font-weight: var(--font-weight-semibold); - background: linear-gradient(135deg, var(--color-surface-2), var(--color-surface)); -} - -.episode__play { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-lg); - color: #fff; - background: rgba(0, 0, 0, 0.35); - opacity: 0; - transition: opacity 120ms ease; -} - -.episode:hover .episode__play { - opacity: 1; -} - -.episode--current .episode__play { - opacity: 1; - background: color-mix(in srgb, var(--color-accent) 35%, rgba(0, 0, 0, 0.35)); -} - -.episode__body { - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; -} - -.episode__num { - font-size: var(--text-xs); - color: var(--color-text-faint); -} - -.episode__title { - font-size: var(--text-sm); - font-weight: var(--font-weight-medium); - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.episode__meta { - margin-top: 2px; - font-size: var(--text-xs); - color: var(--color-text-faint); -} - /* ---- settings ------------------------------------------------------------------ */ .settings { @@ -677,21 +452,6 @@ display: none; } - .watch-hero { - flex-direction: column; - align-items: flex-start; - padding: var(--space-4); - } - - .watch-hero__poster { - flex-basis: auto; - width: 132px; - } - - .watch-hero__title { - font-size: var(--text-2xl); - } - .search-bar__form { flex-direction: column; } @@ -711,3 +471,307 @@ padding: var(--space-4); } } + +/* ---- watch v2: full-bleed hero + overlay player -------------------------------- */ + +.watch-page .page--watch { + padding-top: 0; +} + +.watch-page .site-header { + background: transparent; + border-bottom-color: transparent; + backdrop-filter: none; +} + +.watch-page.is-scrolled .site-header { + background: color-mix(in srgb, var(--color-bg) 85%, transparent); + border-bottom-color: var(--color-border); + backdrop-filter: blur(12px); +} + +.watch-hero { + position: relative; + isolation: isolate; + margin-top: calc(var(--header-height) * -1); + min-height: min(85svh, 900px); + display: flex; + align-items: flex-end; + overflow: hidden; + background: var(--color-surface); +} + +.watch-hero__backdrop { + position: absolute; + inset: 0; + z-index: -2; + background-size: cover; + background-position: center; + background-color: var(--color-surface); +} + +.watch-hero__scrim { + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--color-bg) 20%, transparent) 0%, + color-mix(in srgb, var(--color-bg) 55%, transparent) 55%, + var(--color-bg) 100% + ), + linear-gradient( + 90deg, + color-mix(in srgb, var(--color-bg) 85%, transparent) 0%, + transparent 65% + ); +} + +.watch-hero__inner { + width: 100%; + max-width: var(--container-max); + margin: 0 auto; + padding: calc(var(--header-height) + var(--space-8)) var(--container-pad) var(--space-8); +} + +.watch-hero__logo { + max-width: min(420px, 60vw); + max-height: 140px; + object-fit: contain; + margin-bottom: var(--space-4); +} + +.watch-hero__title { + margin: 0; + font-size: clamp(2.2rem, 6vw, 4.5rem); + line-height: 1.02; + letter-spacing: -0.035em; +} + +.watch-hero__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + margin-top: var(--space-4); + color: var(--color-text-dim); + font-size: var(--text-sm); +} + +.watch-hero__rating { + color: var(--color-star); + letter-spacing: 2px; +} + +.watch-hero__dot { + color: var(--color-text-faint); +} + +.watch-hero__genres { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-4); +} + +.watch-hero__overview { + max-width: 72ch; + margin: var(--space-4) 0 0; + color: var(--color-text-dim); + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.watch-hero__actions { + margin-top: var(--space-6); + display: flex; +} + +.watch-hero__actions .btn--primary { + padding: var(--space-3) var(--space-6); + font-size: var(--text-md); +} + +.watch-episodes { + padding: var(--space-8) 0; +} + +.watch-episodes__title { + margin: 0 0 var(--space-4); + font-size: var(--text-xl); +} + +.season-tabs { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding-bottom: var(--space-2); + margin-bottom: var(--space-5); +} + +.episode-shelf { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(220px, 1fr); + gap: var(--space-4); + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: var(--space-2); +} + +.episode-shelf__loading, +.episode-shelf__empty { + grid-column: 1 / -1; + display: grid; + place-items: center; + min-height: 180px; +} + +.episode-card { + scroll-snap-align: start; + text-align: left; + border: 1px solid var(--color-border); + background: var(--color-surface); + border-radius: var(--radius-lg); + overflow: hidden; + padding: 0; + cursor: pointer; +} + +.episode-card--current { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-color: var(--color-accent); +} + +.episode-card__thumb { + position: relative; + display: block; + aspect-ratio: 16 / 9; + width: 100%; + background: var(--color-surface-2); +} + +.episode-card__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.episode-card__thumb--empty { + display: grid; + place-items: center; + font-size: var(--text-2xl); + color: var(--color-text-faint); +} + +.episode-card__progress { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + background: var(--color-accent); +} + +.episode-card__body { + display: grid; + gap: var(--space-1); + padding: var(--space-3); +} + +.episode-card__num { + color: var(--color-text-faint); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.episode-card__title { + font-weight: 600; + color: var(--color-text-dim); +} + +.episode-card__meta { + color: var(--color-text-dim); + font-size: var(--text-xs); +} + +.player-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: #000; + display: grid; + place-items: center; +} + +.player-overlay[hidden] { + display: none; +} + +body.player-open { + overflow: hidden; +} + +body.player-open .player-overlay { + display: grid; +} + +.player-overlay__close { + position: absolute; + top: var(--space-4); + right: var(--space-4); + z-index: 20; + background: color-mix(in srgb, #000 60%, transparent); + color: #fff; + border: 1px solid rgb(255 255 255 / 20%); +} + +#playerOverlay .player-wrap { + width: 100vw; + height: 100vh; + height: 100dvh; + aspect-ratio: auto; + border: none; + border-radius: 0; +} + +#playerOverlay .watch-toolbar { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: var(--space-5); + z-index: 2; + background: color-mix(in srgb, #000 65%, transparent); + border: 1px solid rgb(255 255 255 / 10%); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); +} + +@media (max-width: 720px) { + .watch-hero { + min-height: 70svh; + } + + .watch-hero__inner { + padding-bottom: var(--space-6); + } + + .watch-hero__actions .btn--primary { + width: 100%; + } + + .episode-shelf { + grid-auto-columns: minmax(160px, 80vw); + } + + .player-overlay__close { + top: var(--space-2); + right: var(--space-2); + } +} diff --git a/web/js/cards.js b/web/js/cards.js index 7ecc986..cff28fd 100644 --- a/web/js/cards.js +++ b/web/js/cards.js @@ -151,7 +151,6 @@
- Trending now

${esc(item.name)}

${metaBits}
${item.overview ? `

${esc(item.overview)}

` : ''} diff --git a/web/js/config.js b/web/js/config.js index fe4145e..49f0ae2 100644 --- a/web/js/config.js +++ b/web/js/config.js @@ -82,7 +82,7 @@ } } - const PREFERRED_PROVIDERS = ['cineby', 'videasy', 'vidfast']; + const PREFERRED_PROVIDERS = ['cineby', 'playimdb', 'vidrock']; // Merge the server's provider lists with the user's toggles. // Returns the enabled provider names, in the server's `all` order. diff --git a/web/js/watch.js b/web/js/watch.js index 253d6ab..0357cc9 100644 --- a/web/js/watch.js +++ b/web/js/watch.js @@ -19,15 +19,29 @@ return; } + const heroEl = document.getElementById('hero'); + const heroBackdrop = document.getElementById('heroBackdrop'); + const heroTitleCard = document.getElementById('heroTitleCard'); + const heroTitle = document.getElementById('heroTitle'); + const heroMeta = document.getElementById('heroMeta'); + const heroGenres = document.getElementById('heroGenres'); + const heroOverview = document.getElementById('heroOverview'); + const playButton = document.getElementById('playButton'); + const playLabel = document.getElementById('playLabel'); + const episodeSection = document.getElementById('episodeSection'); + const seasonTabs = document.getElementById('seasonTabs'); + const episodeShelf = document.getElementById('episodeShelf'); + const playerOverlay = document.getElementById('playerOverlay'); + const playerClose = document.getElementById('playerClose'); + + // custom player controls 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'); @@ -40,10 +54,6 @@ 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, @@ -57,8 +67,13 @@ season: null, episode: null, loadedKey: null, + loadToken: 0, + overlayOpen: false, + lastFocusedElement: null, + pendingClose: false, }; + /* ---- status overlay ------------------------------------------------------ */ function closeSources() { @@ -323,7 +338,16 @@ break; case 'Escape': if (!sourcesPanel.hidden) closeSources(); + else if (state.overlayOpen) { + if (document.fullscreenElement) { + state.pendingClose = true; + document.exitFullscreen().catch(() => {}); + } else { + closePlayer(); + } + } break; + } if (video.src || video.currentSrc) showControls(); }); @@ -334,36 +358,120 @@ /* ---- hero ------------------------------------------------------------------ */ + function openPlayer() { + if (state.overlayOpen) return; + state.overlayOpen = true; + state.lastFocusedElement = document.activeElement; + playerOverlay.hidden = false; + document.body.classList.add('player-open'); + playerClose.focus(); + } + + function closePlayer() { + if (!state.overlayOpen) return; + state.overlayOpen = false; + state.pendingClose = false; + video.pause(); + playerOverlay.hidden = true; + document.body.classList.remove('player-open'); + if (state.lastFocusedElement && typeof state.lastFocusedElement.focus === 'function') { + state.lastFocusedElement.focus(); + } + } + + document.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement && state.pendingClose && state.overlayOpen) closePlayer(); + }); + + function syncHeader() { + document.body.classList.toggle('is-scrolled', window.scrollY > 8); + } + window.addEventListener('scroll', syncHeader, { passive: true }); + syncHeader(); + + function setPlayLabel() { + if (type === 'tv' && state.season != null && state.episode != null) { + playLabel.textContent = `Play S${state.season} E${state.episode}`; + } else { + playLabel.textContent = 'Play'; + } + } + 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 facts = []; + if (isTv) { + if (d.number_of_seasons) { + facts.push(`${d.number_of_seasons} season${d.number_of_seasons === 1 ? '' : 's'}`); + } + } else { + const runtime = SR.util.runtimeLabel(d.runtime); + if (runtime) facts.push(runtime); + } + if (year) facts.push(year); + + const vote = Number(d.vote_average || 0); + const filled = Math.max(0, Math.min(5, Math.round(vote / 2))); + const rating = vote + ? `${'★'.repeat(filled)}${'☆'.repeat(5 - filled)} ${vote.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)}

` : ''} -
`; + + const title = d.name || d.title || ''; + heroTitle.textContent = title; + heroMeta.innerHTML = [rating, ...facts.map((fact) => `${SR.util.escapeHtml(fact)}`)] + .filter(Boolean) + .join(''); + + heroGenres.replaceChildren(); + for (const genre of d.genres || []) { + const tag = document.createElement('span'); + tag.className = 'tag'; + tag.textContent = genre.name; + heroGenres.appendChild(tag); + } + + heroOverview.textContent = d.overview || ''; + heroOverview.hidden = !d.overview; + + const images = d.images || {}; + const backdrop = d.backdrop_url || (images.backdrops && images.backdrops[0] && images.backdrops[0].url); + heroBackdrop.style.backgroundImage = backdrop + ? `url("${SR.util.escapeHtml(SR.util.tmdbResize(backdrop, 'w1280'))}")` + : ''; + + const logo = images.logos && images.logos[0] && images.logos[0].url; + if (logo) { + heroTitleCard.alt = title; + heroTitleCard.src = SR.util.tmdbResize(logo, 'w500'); + heroTitleCard.hidden = false; + heroTitle.hidden = true; + heroTitleCard.onerror = () => { + heroTitleCard.hidden = true; + heroTitle.hidden = false; + }; + } else { + heroTitleCard.hidden = true; + heroTitle.hidden = false; + } + + playButton.hidden = false; + setPlayLabel(); } + function renderHeroError(message) { + heroTitle.textContent = 'Unavailable'; + heroTitle.hidden = false; + heroMeta.textContent = message; + heroOverview.textContent = ''; + heroBackdrop.style.backgroundImage = ''; + heroTitleCard.hidden = true; + playButton.hidden = true; + episodeSection.hidden = true; + } + + /* ---- url -------------------------------------------------------------------- */ function syncUrl() { @@ -402,7 +510,10 @@ } async function loadStreams() { + const token = ++state.loadToken; const providerInfo = await getProviderInfo(); + if (token !== state.loadToken) return; + if (!providerInfo.useServerDefaults && providerInfo.names.length === 0) { closeSources(); pcQuality.textContent = ''; @@ -411,6 +522,7 @@ showStatus('No sources are enabled. Open Settings → Sources and enable at least one provider.', true); return; } + const query = { tmdb: id, type, @@ -421,13 +533,17 @@ 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); + if (token !== state.loadToken) return; + const allStreams = data.streams || []; state.streams = allStreams.filter(SR.util.webPlayable); state.unsupported = allStreams.filter((s) => !SR.util.webPlayable(s)); @@ -449,11 +565,13 @@ } } } catch (e) { + if (token !== state.loadToken) return; 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() { @@ -584,116 +702,132 @@ async function loadSeasons() { const data = await SR.api.seasons(id); - state.seasons = data.seasons || []; + state.seasons = (data.seasons || []).slice().sort((a, b) => a.season_number - b.season_number); + renderSeasonTabs(); + } + + function renderSeasonTabs() { + seasonTabs.replaceChildren(); 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); + const b = document.createElement('button'); + b.type = 'button'; + b.role = 'tab'; + b.className = s.season_number === state.season ? 'chip chip--active' : 'chip'; + b.textContent = `S${s.season_number}`; + b.title = `${s.name || `Season ${s.season_number}`} (${s.episode_count || 0})`; + b.addEventListener('click', () => { + if (s.season_number !== state.season) loadEpisodes(s.season_number, null); + }); + seasonTabs.appendChild(b); } } - function showGridSpinner() { - episodeGrid.replaceChildren(); + function showShelfSpinner() { + episodeShelf.replaceChildren(); const cell = document.createElement('div'); - cell.className = 'loading-cell'; + cell.className = 'episode-shelf__loading'; cell.innerHTML = '
'; - episodeGrid.appendChild(cell); + episodeShelf.appendChild(cell); + } + + function progressFor(season, episode) { + const key = SR.storage.keyFor({ type, tmdbId: Number(id), season, episode }); + const rec = SR.storage.get(key); + if (!rec || !rec.t || !rec.d) return 0; + return Math.max(0, Math.min(1, rec.t / rec.d)); } function renderEpisodes() { - episodeGrid.replaceChildren(); + episodeShelf.replaceChildren(); if (!state.episodes.length) { const empty = SR.cards.emptyState('No episodes listed'); - empty.style.gridColumn = '1 / -1'; - episodeGrid.appendChild(empty); + empty.className = `${empty.className} episode-shelf__empty`; + episodeShelf.appendChild(empty); return; } + for (const ep of state.episodes) { + const current = ep.episode_number === state.episode; const b = document.createElement('button'); b.type = 'button'; - b.className = ep.episode_number === state.episode ? 'episode episode--current' : 'episode'; + b.className = current ? 'episode-card episode-card--current' : 'episode-card'; const date = ep.air_date ? ` · ${SR.util.escapeHtml(ep.air_date)}` : ''; const still = ep.still || (ep.still_path ? SR.util.tmdbResize(ep.still_path, 'w300') : ''); + const progress = progressFor(state.season, ep.episode_number); + b.innerHTML = ` - + ${still ? `` : ``} - + ${progress > 0 ? `` : ''} - - S${state.season} · E${ep.episode_number}${date} - ${SR.util.escapeHtml(ep.name || `Episode ${ep.episode_number}`)} + + S${state.season} · E${ep.episode_number}${date} + ${SR.util.escapeHtml(ep.name || `Episode ${ep.episode_number}`)} + ${SR.util.escapeHtml(SR.util.runtimeLabel(ep.runtime) || '')} `; - const img = b.querySelector('.episode__thumb img'); + + const img = b.querySelector('.episode-card__thumb img'); if (img) { img.addEventListener('error', () => { - const thumb = img.closest('.episode__thumb'); + const thumb = img.closest('.episode-card__thumb'); img.remove(); - thumb.classList.add('episode__thumb--empty'); + thumb.classList.add('episode-card__thumb--empty'); const num = document.createElement('span'); num.setAttribute('aria-hidden', 'true'); num.textContent = ep.episode_number; thumb.prepend(num); }); } + b.addEventListener('click', () => playEpisode(ep.episode_number)); - episodeGrid.appendChild(b); + episodeShelf.appendChild(b); } } async function loadEpisodes(season, autoplayEpisode) { + const token = ++state.loadToken; state.season = season; - seasonSelect.value = String(season); - showGridSpinner(); + state.episode = autoplayEpisode != null ? autoplayEpisode : null; + renderSeasonTabs(); + showShelfSpinner(); + const data = await SR.api.episodes(id, season); + if (token !== state.loadToken) return; + state.episodes = data.episodes || []; + if (autoplayEpisode != null && state.episodes.some((e) => e.episode_number === autoplayEpisode)) { + playEpisode(autoplayEpisode); + return; + } + state.episode = state.episodes.length ? state.episodes[0].episode_number : null; renderEpisodes(); syncUrl(); - if (autoplayEpisode && state.episodes.some((e) => e.episode_number === autoplayEpisode)) { - playEpisode(autoplayEpisode); - } + setPlayLabel(); } function playEpisode(n) { state.episode = n; syncUrl(); renderEpisodes(); + setPlayLabel(); + openPlayer(); 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); + playButton.addEventListener('click', () => { + if (state.episode == null) { + if (state.episodes.length) playEpisode(state.episodes[0].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'); + if (state.loadedKey === keyFor() && state.streams.length) { + openPlayer(); + return; } - } + openPlayer(); + loadStreams(); + }); - prevBtn.addEventListener('click', () => stepEpisode(-1)); - nextBtn.addEventListener('click', () => stepEpisode(1)); - - /* ---- init ------------------------------------------------------------------------------ */ + playerClose.addEventListener('click', closePlayer); (async function init() { let details; @@ -701,41 +835,55 @@ 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); + renderHeroError('Could not load this title.'); return; } + state.details = details; renderHero(details); if (type !== 'tv') { + openPlayer(); loadStreams(); return; } episodeSection.hidden = false; - showStatus('Pick an episode to start.'); + showStatus('Loading episodes…'); try { await loadSeasons(); } catch (e) { SR.app.showError(e, 'failed to load seasons'); + renderHeroError('Could not load seasons.'); return; } + if (!state.seasons.length) { - showStatus('No seasons found for this show.', true); + renderHeroError('No seasons found for this show.'); 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; + const hasSeason = (n) => state.seasons.some((s) => s.season_number === n); + const season = hasSeason(sParam) ? sParam : hasSeason(1) ? 1 : state.seasons[0].season_number; + try { - await loadEpisodes(season, eParam || null); + await loadEpisodes(season, null); } catch (e) { SR.app.showError(e, 'failed to load episodes'); - showStatus('Failed to load episodes.', true); + renderHeroError('Could not load episodes.'); + return; + } + + if (!state.episodes.length) { + renderHeroError('No episodes found for this show.'); + return; + } + + if (season === sParam && state.episodes.some((e) => e.episode_number === eParam)) { + playEpisode(eParam); } })(); })(); diff --git a/web/watch.html b/web/watch.html index b932d6c..5122472 100644 --- a/web/watch.html +++ b/web/watch.html @@ -4,12 +4,12 @@ bug.tv — watch - - - - + + + + - + -
-
+
+
+ + +
+ +

Loading…

+
+
+

+
+ +
+
+
-
-
+ +
+ +
+
- - - - - - - - + + + + + + + +