diff --git a/mod/scrape.js b/mod/scrape.js index e76e8d1..c10d321 100644 --- a/mod/scrape.js +++ b/mod/scrape.js @@ -45,8 +45,9 @@ async function scrapeAll(providers, media, timeoutMs) { const seen = new Set(); const unique = []; for (const s of streams) { - if (seen.has(s.url)) continue; - seen.add(s.url); + const key = `${s._provider}|${s.url}|${s.quality || ''}|${s.name || s.title || ''}`; + if (seen.has(key)) continue; + seen.add(key); unique.push(s); } return { streams: unique, errors }; diff --git a/web/css/components.css b/web/css/components.css index 04dc759..3987073 100644 --- a/web/css/components.css +++ b/web/css/components.css @@ -173,58 +173,6 @@ select.field { color: var(--color-accent-text); } -/* ---- toggle switch --------------------------------------------------------- */ - -.switch { - position: relative; - display: inline-flex; - flex: 0 0 auto; - cursor: pointer; -} - -.switch input { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.switch__track { - position: relative; - width: 44px; - height: 24px; - border-radius: var(--radius-full); - background: var(--color-surface-2); - border: 1px solid var(--color-border-strong); - transition: background 140ms ease, border-color 140ms ease; -} - -.switch__thumb { - position: absolute; - top: 2px; - left: 2px; - width: 18px; - height: 18px; - border-radius: var(--radius-full); - background: var(--color-text-dim); - transition: transform 140ms ease, background 140ms ease; -} - -.switch input:checked + .switch__track { - background: var(--color-accent); - border-color: var(--color-accent); -} - -.switch input:checked + .switch__track .switch__thumb { - transform: translateX(20px); - background: var(--color-accent-text); -} - -.switch input:focus-visible + .switch__track { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - /* ---- media cards (poster) --------------------------------------------------- */ .card { @@ -652,3 +600,141 @@ select.field { opacity: 1; pointer-events: auto; } + +/* in-player source switcher (gear popup) */ +.player-sources { + position: absolute; + right: var(--space-3); + bottom: calc(var(--space-4) + 64px); + z-index: 7; + display: flex; + flex-direction: column; + width: min(380px, calc(100% - var(--space-6))); + max-height: calc(100% - 112px); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: var(--radius-md); + background: rgba(10, 10, 10, 0.94); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-2); + overflow: hidden; +} + +.player-sources__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); +} + +.player-sources__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex: 0 0 auto; + border: none; + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + transition: background 120ms ease; +} + +.player-sources__close:hover { + background: rgba(255, 255, 255, 0.22); +} + +.player-sources__close svg { + width: 18px; + height: 18px; +} + +.player-sources__list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--space-2); + scrollbar-width: thin; + scrollbar-color: var(--color-border-strong) transparent; +} + +.player-sources__group { + padding: var(--space-2) var(--space-3) var(--space-1); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); +} + +.source-item { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + text-align: left; + cursor: pointer; + font-size: var(--text-sm); +} + +.source-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.source-item--active { + background: color-mix(in srgb, var(--color-accent) 20%, transparent); +} + +.source-item__rank { + flex: 0 0 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + font-size: var(--text-xs); + color: rgba(255, 255, 255, 0.75); +} + +.source-item--active .source-item__rank { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-text); +} + +.source-item__name { + font-weight: var(--font-weight-medium); +} + +.source-item__meta { + margin-left: auto; + color: rgba(255, 255, 255, 0.5); + font-size: var(--text-xs); + white-space: nowrap; +} + +@media (max-width: 720px) { + .player-sources { + left: var(--space-3); + right: var(--space-3); + bottom: calc(var(--space-3) + 64px); + width: auto; + max-height: calc(100% - 88px); + } +} diff --git a/web/css/pages.css b/web/css/pages.css index 567d679..9b176ad 100644 --- a/web/css/pages.css +++ b/web/css/pages.css @@ -4,34 +4,6 @@ page-level blocks and handles responsive behavior. */ -/* ---- home ------------------------------------------------------------------ */ - -.home-hero { - margin: 0 0 var(--space-6); - padding: var(--space-5); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: - radial-gradient(80% 120% at 10% 0%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 60%), - var(--color-surface); -} - -.home-hero h1 { - font-size: var(--text-2xl); - letter-spacing: -0.02em; -} - -.home-hero p { - margin-top: var(--space-2); - max-width: 56ch; - color: var(--color-text-dim); - font-size: var(--text-sm); -} - -.home-hero .chips { - margin-top: var(--space-4); -} - /* ---- search ------------------------------------------------------------------ */ .search-bar { @@ -183,7 +155,7 @@ margin-bottom: var(--space-1); } -/* ---- watch: toolbar + sources ---------------------------------------------------- */ +/* ---- watch: toolbar (source popup lives in components.css with the player) ---------- */ .watch-toolbar { display: flex; @@ -204,78 +176,6 @@ flex: 1; } -.sources { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface); - margin-bottom: var(--space-6); - overflow: hidden; -} - -.sources__head { - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--color-border); - font-size: var(--text-sm); - font-weight: var(--font-weight-semibold); -} - -.source-item { - display: flex; - align-items: center; - gap: var(--space-3); - width: 100%; - padding: var(--space-3) var(--space-4); - border: none; - border-bottom: 1px solid var(--color-border); - background: transparent; - color: var(--color-text); - text-align: left; - cursor: pointer; - font-size: var(--text-sm); -} - -.source-item:last-child { - border-bottom: none; -} - -.source-item:hover { - background: var(--color-surface-hover); -} - -.source-item--active { - background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface)); -} - -.source-item__rank { - flex: 0 0 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-full); - background: var(--color-surface-2); - border: 1px solid var(--color-border); - font-size: var(--text-xs); - color: var(--color-text-dim); -} - -.source-item--active .source-item__rank { - background: var(--color-accent); - border-color: var(--color-accent); - color: var(--color-accent-text); -} - -.source-item__name { - font-weight: var(--font-weight-medium); -} - -.source-item__meta { - margin-left: auto; - color: var(--color-text-faint); - font-size: var(--text-xs); - white-space: nowrap; -} - .loading-cell { grid-column: 1 / -1; display: flex; @@ -483,6 +383,7 @@ .provider-list { display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-2); margin-top: var(--space-4); } @@ -490,9 +391,8 @@ .provider { display: flex; align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface-2); @@ -504,14 +404,21 @@ border-color: var(--color-border-strong); } +.provider input[type="checkbox"] { + flex: 0 0 auto; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-accent); + cursor: pointer; +} + .provider__name { font-size: var(--text-sm); font-weight: var(--font-weight-medium); -} - -.provider__note { - font-size: var(--text-xs); - color: var(--color-text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* ---- responsive ---------------------------------------------------------------- */ @@ -536,10 +443,6 @@ font-size: var(--text-2xl); } - .home-hero h1 { - font-size: var(--text-xl); - } - .search-bar__form { flex-direction: column; } diff --git a/web/css/tokens.css b/web/css/tokens.css index 220a49e..37e4bcd 100644 --- a/web/css/tokens.css +++ b/web/css/tokens.css @@ -17,11 +17,11 @@ --color-text: #f2f2f2; /* primary text */ --color-text-dim: #9a9a9a; /* secondary text */ --color-text-faint: #5c5c5c; /* tertiary text */ - --color-accent: #00e08b; /* brand / primary action color */ - --color-accent-hover: #33ecab; - --color-accent-text: #04110a; /* text on accent buttons */ + --color-accent: #c084fc; /* brand / primary action color */ + --color-accent-hover: #d8b4fe; + --color-accent-text: #12071f; /* text on accent buttons */ --color-danger: #ff6b6b; - --color-success: #00e08b; + --color-success: #c084fc; --color-star: #ffc94d; /* rating stars */ /* ---- typography ------------------------------------------------------ */ diff --git a/web/index.html b/web/index.html index 510d16a..31d9957 100644 --- a/web/index.html +++ b/web/index.html @@ -29,19 +29,6 @@
-
-

What do you want to watch?

-

- One catalog, many sources. Pick a stream, and if it dies mid-episode, - switch source — your place is remembered. -

- -
-
-
diff --git a/web/js/home.js b/web/js/home.js index 664f6df..15eb747 100644 --- a/web/js/home.js +++ b/web/js/home.js @@ -1,37 +1,13 @@ /* - HOME — hero with search + continue-watching row + trending rows. + HOME — continue-watching row + trending rows. */ (function () { 'use strict'; - // quick-search chips in the hero — edit freely - const QUICK_SEARCHES = ['Action', 'Comedy', 'Sci-Fi', 'Drama', 'Horror', 'Animation', 'Crime']; - SR.app.initChrome(); const sections = document.getElementById('sections'); - const form = document.getElementById('heroForm'); - const input = document.getElementById('heroInput'); - if (form && input) { - form.addEventListener('submit', (e) => { - e.preventDefault(); - const q = input.value.trim(); - location.href = q ? `search.html?q=${encodeURIComponent(q)}` : 'search.html'; - }); - } - - const chips = document.getElementById('heroChips'); - if (chips) { - for (const term of QUICK_SEARCHES) { - const a = document.createElement('a'); - a.className = 'chip'; - a.href = `search.html?q=${encodeURIComponent(term)}`; - a.textContent = term; - chips.appendChild(a); - } - } - const cont = SR.cards.continueRow(); if (cont) sections.appendChild(cont); diff --git a/web/js/settings.js b/web/js/settings.js index c16a834..5b44ae5 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -58,19 +58,13 @@ const checked = overrides ? overrides[name] !== false : serverEnabled.includes(name); const label = document.createElement('label'); label.className = 'provider'; - const nameEl = document.createElement('span'); - nameEl.className = 'provider__name'; - nameEl.textContent = name; - const sw = document.createElement('span'); - sw.className = 'switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!checked; - const track = document.createElement('span'); - track.className = 'switch__track'; - track.innerHTML = ''; - sw.append(input, track); - label.append(nameEl, sw); + const nameEl = document.createElement('span'); + nameEl.className = 'provider__name'; + nameEl.textContent = name; + label.append(input, nameEl); input.addEventListener('change', () => persist(currentMap(), input.checked ? `${name} enabled` : `${name} disabled`)); boxes.set(name, input); @@ -78,7 +72,9 @@ } const on = boxes.size ? Array.from(boxes.values()).filter((b) => b.checked).length : 0; const source = overrides ? 'your selection' : 'server defaults'; - setStatus(providersStatus, `${on} of ${allProviders.length} enabled — ${source}`); + let host; + try { host = new URL(SR.config.getBase()).host; } catch (e) { host = SR.config.getBase(); } + setStatus(providersStatus, `${on} of ${allProviders.length} enabled — ${source} · ${host}`); } function resetProviders(message) { diff --git a/web/js/watch.js b/web/js/watch.js index 3931d82..18f88ef 100644 --- a/web/js/watch.js +++ b/web/js/watch.js @@ -24,6 +24,7 @@ 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 @@ -57,7 +58,13 @@ /* ---- 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 @@ -151,7 +158,7 @@ playerWrap.classList.add('player-wrap--controls'); clearTimeout(hideTimer); hideTimer = setTimeout(() => { - if (!video.paused && !video.ended && !scrubbing) { + if (!video.paused && !video.ended && !scrubbing && sourcesPanel.hidden) { playerWrap.classList.remove('player-wrap--controls'); } }, 3000); @@ -198,7 +205,13 @@ } // video events - video.addEventListener('click', togglePlay); + video.addEventListener('click', () => { + if (!sourcesPanel.hidden) { + closeSources(); + return; + } + togglePlay(); + }); video.addEventListener('dblclick', toggleFullscreen); video.addEventListener('play', () => { syncPlayIcon(); @@ -229,9 +242,15 @@ }); pcCc.addEventListener('click', toggleCc); pcGear.addEventListener('click', () => { - sourcesPanel.hidden = !sourcesPanel.hidden; - pcGear.classList.toggle('is-on', !sourcesPanel.hidden); + 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); @@ -299,6 +318,9 @@ case 'c': toggleCc(); break; + case 'Escape': + if (!sourcesPanel.hidden) closeSources(); + break; } if (video.src || video.currentSrc) showControls(); }); @@ -378,8 +400,7 @@ query.season = state.season; query.episode = state.episode; } - sourcesPanel.hidden = true; - pcGear.classList.remove('is-on'); + closeSources(); pcQuality.textContent = ''; showStatus('Finding streams…'); sourceLabel.textContent = ''; @@ -397,21 +418,42 @@ } } + // 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(); - state.streams.forEach((s, i) => { + 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.provider, s.quality, s.audio].filter(Boolean).join(' · '); + const meta = [s.quality, s.audio].filter(Boolean).join(' · '); b.innerHTML = ` ${i + 1} ${SR.util.escapeHtml(s.name)} - ${SR.util.escapeHtml(meta)}`; + ${meta ? `${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'}`; } @@ -429,6 +471,7 @@ })(); state.sourceIndex = i; state.loadedKey = key; + closeSources(); renderSources(); sourceLabel.textContent = `${s.name} — ${[s.quality, s.provider].filter(Boolean).join(', ')}`; pcQuality.textContent = s.quality || ''; diff --git a/web/watch.html b/web/watch.html index 9dfbc20..e7e50f4 100644 --- a/web/watch.html +++ b/web/watch.html @@ -57,7 +57,7 @@ - + +
+
@@ -81,11 +91,6 @@
- -