From 145851a32e048adfa16bc39cb90e5286514ffb54 Mon Sep 17 00:00:00 2001 From: 4DBug <4DBug@github.com> Date: Wed, 26 Aug 2026 18:28:49 -0500 Subject: [PATCH] push --- web/css/base.css | 76 ++++++++- web/css/components.css | 377 +++++++++++++++++++++++++++++++++-------- web/css/pages.css | 148 ++++++++++++++-- web/css/tokens.css | 36 ++-- web/index.html | 39 ++--- web/js/api.js | 10 +- web/js/app.js | 42 +---- web/js/config.js | 66 +++++++- web/js/player.js | 8 +- web/js/settings.js | 158 +++++++++++++++++ web/js/storage.js | 6 +- web/js/watch.js | 279 ++++++++++++++++++++++++++++-- web/search.html | 39 ++--- web/settings.html | 92 ++++++++++ web/watch.html | 111 +++++++----- 15 files changed, 1215 insertions(+), 272 deletions(-) create mode 100644 web/js/settings.js create mode 100644 web/settings.html diff --git a/web/css/base.css b/web/css/base.css index 27d6a2e..05b0b39 100644 --- a/web/css/base.css +++ b/web/css/base.css @@ -52,6 +52,14 @@ p { margin: 0; } +code { + padding: 1px var(--space-1); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + font-size: 0.9em; +} + /* ---- page frame ---------------------------------------------------------- */ .container { @@ -66,6 +74,8 @@ p { padding-bottom: var(--space-8); } +/* ---- header ---------------------------------------------------------------- */ + .site-header { position: sticky; top: 0; @@ -77,7 +87,8 @@ p { } .site-header__inner { - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; gap: var(--space-4); height: var(--header-height); @@ -94,22 +105,67 @@ p { } .site-header__nav { + display: flex; + gap: var(--space-1); +} + +.site-header__nav a { + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-full); font-size: var(--text-sm); font-weight: var(--font-weight-medium); color: var(--color-text-dim); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); + transition: background 120ms ease, color 120ms ease; } -.site-header__nav:hover { +.site-header__nav a:hover { color: var(--color-text); background: var(--color-surface); } -.site-header__spacer { - flex: 1; +.site-header__nav a.is-active { + color: var(--color-accent-text); + background: var(--color-accent); } +.site-header__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-1); +} + +/* ---- icon buttons ----------------------------------------------------------- */ + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: none; + border-radius: var(--radius-full); + background: transparent; + color: var(--color-text-dim); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.icon-btn:hover { + background: var(--color-surface); + color: var(--color-text); +} + +.icon-btn.is-active { + color: var(--color-accent); +} + +.icon-btn svg { + width: 20px; + height: 20px; +} + +/* ---- footer ------------------------------------------------------------------ */ + .site-footer { border-top: 1px solid var(--color-border); padding: var(--space-4) 0 var(--space-5); @@ -130,3 +186,11 @@ p { .site-footer a:hover { color: var(--color-text); } + +/* ---- responsive ---------------------------------------------------------------- */ + +@media (max-width: 720px) { + .site-header__nav a { + padding: var(--space-2) var(--space-3); + } +} diff --git a/web/css/components.css b/web/css/components.css index a2aedf2..04dc759 100644 --- a/web/css/components.css +++ b/web/css/components.css @@ -3,7 +3,7 @@ after the thing it styles (BEM-ish: block, block__element, block--modifier). Theme values come from tokens.css; never hardcode colors/sizes here if a token exists. -*/ + */ /* ---- buttons ------------------------------------------------------------- */ @@ -12,9 +12,9 @@ align-items: center; justify-content: center; gap: var(--space-2); - padding: var(--space-2) var(--space-4); + padding: var(--space-2) var(--space-5); border: 1px solid var(--color-border-strong); - border-radius: var(--radius-sm); + border-radius: var(--radius-full); background: var(--color-surface-2); color: var(--color-text); font-size: var(--text-sm); @@ -22,7 +22,7 @@ line-height: 1.3; cursor: pointer; user-select: none; - transition: background 120ms ease, border-color 120ms ease; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; } .btn:hover { @@ -97,7 +97,7 @@ select.field { appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2398a2b0' stroke-width='2'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%239a9a9a' stroke-width='2'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; @@ -135,7 +135,7 @@ select.field { .tag { display: inline-block; padding: 2px var(--space-2); - border-radius: var(--radius-sm); + border-radius: var(--radius-full); background: var(--color-surface-2); border: 1px solid var(--color-border); font-size: var(--text-xs); @@ -151,7 +151,7 @@ select.field { } .chip { - padding: var(--space-1) var(--space-3); + padding: var(--space-1) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-full); background: transparent; @@ -168,9 +168,61 @@ select.field { } .chip--active { - background: var(--color-surface); + background: var(--color-accent); border-color: var(--color-accent); - color: var(--color-text); + 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) --------------------------------------------------- */ @@ -184,23 +236,29 @@ select.field { .card__poster { position: relative; + display: block; aspect-ratio: var(--poster-aspect); border-radius: var(--radius-md); overflow: hidden; background: var(--color-surface); border: 1px solid var(--color-border); - transition: border-color 120ms ease, transform 120ms ease; + transition: border-color 120ms ease, box-shadow 120ms ease; } .card:hover .card__poster { - border-color: var(--color-border-strong); - transform: translateY(-2px); + border-color: var(--color-accent); + box-shadow: 0 0 0 1px var(--color-accent); } .card__poster img { width: 100%; height: 100%; object-fit: cover; + transition: transform 220ms ease; +} + +.card:hover .card__poster img { + transform: scale(1.04); } .card__poster--empty { @@ -236,8 +294,8 @@ select.field { left: 0; right: 0; bottom: 0; - height: 3px; - background: rgba(255, 255, 255, 0.15); + height: 4px; + background: rgba(0, 0, 0, 0.6); } .card__progress i { @@ -301,62 +359,6 @@ select.field { width: auto; } -/* ---- modal ----------------------------------------------------------------- */ - -.modal-overlay { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-4); - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); -} - -.modal { - width: 100%; - max-width: 460px; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - box-shadow: var(--shadow-2); -} - -.modal__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--color-border); -} - -.modal__title { - font-size: var(--text-lg); - font-weight: var(--font-weight-semibold); -} - -.modal__body { - padding: var(--space-5); - display: grid; - gap: var(--space-4); -} - -.modal__hint { - font-size: var(--text-xs); - color: var(--color-text-faint); -} - -.modal__actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-4) var(--space-5); - border-top: 1px solid var(--color-border); -} - /* ---- toast ----------------------------------------------------------------- */ .toast { @@ -366,9 +368,9 @@ select.field { z-index: 110; transform: translateX(-50%); max-width: min(90vw, 480px); - padding: var(--space-2) var(--space-4); + padding: var(--space-2) var(--space-5); border: 1px solid var(--color-border-strong); - border-radius: var(--radius-md); + border-radius: var(--radius-full); background: var(--color-surface); box-shadow: var(--shadow-2); font-size: var(--text-sm); @@ -419,3 +421,234 @@ select.field { color: var(--color-text-dim); margin-bottom: var(--space-2); } + +/* ---- player controls --------------------------------------------------------- */ + +.player-wrap { + --pc-progress: 0%; + position: relative; + aspect-ratio: var(--player-aspect); + border-radius: var(--radius-md); + overflow: hidden; + background: #000; + border: 1px solid var(--color-border); + cursor: default; +} + +.player-wrap video { + width: 100%; + height: 100%; + display: block; + background: #000; +} + +/* top-left back button */ +.player-back { + position: absolute; + top: var(--space-3); + left: var(--space-3); + z-index: 5; + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.45); + color: #fff; + backdrop-filter: blur(6px); + opacity: 0; + transform: translateY(-4px); + transition: opacity 160ms ease, transform 160ms ease, background 120ms ease; +} + +.player-back:hover { + background: rgba(0, 0, 0, 0.7); +} + +.player-back svg { + width: 22px; + height: 22px; +} + +/* centered big play (shown when paused) */ +.player-bigplay { + position: absolute; + top: 50%; + left: 50%; + z-index: 4; + transform: translate(-50%, -50%); + width: 76px; + height: 76px; + border: none; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--color-accent) 90%, transparent); + color: var(--color-accent-text); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: var(--shadow-2); + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease, transform 160ms ease; +} + +.player-bigplay:hover { + transform: translate(-50%, -50%) scale(1.05); +} + +.player-bigplay svg { + width: 34px; + height: 34px; + margin-left: 4px; +} + +/* bottom controls bar */ +.player-controls { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 6; + display: flex; + flex-direction: column; + padding: var(--space-4) var(--space-4) var(--space-3); + background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0)); + opacity: 0; + transform: translateY(6px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.player-controls__bar { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.player-controls__spacer { + flex: 1; +} + +.pc-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex: 0 0 auto; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: #fff; + cursor: pointer; + transition: background 120ms ease; +} + +.pc-btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.pc-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.pc-btn svg { + width: 20px; + height: 20px; +} + +.pc-btn--text { + width: auto; + padding: 0 var(--space-2); + font-size: var(--text-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.04em; +} + +.pc-btn.is-on { + color: var(--color-accent); +} + +.pc-time { + flex: 0 0 auto; + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; +} + +.pc-quality { + flex: 0 0 auto; + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-accent); + white-space: nowrap; +} + +/* range inputs (seek + volume) */ +.pc-seek, +.pc-vol { + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: var(--radius-full); + background: linear-gradient( + to right, + var(--color-accent) var(--pc-progress), + rgba(255, 255, 255, 0.28) var(--pc-progress) + ); + cursor: pointer; +} + +.pc-seek { + width: 100%; + margin-bottom: var(--space-2); +} + +.pc-vol { + width: 72px; + flex: 0 0 auto; +} + +.pc-seek::-webkit-slider-thumb, +.pc-vol::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--color-accent); + border: none; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); +} + +.pc-seek::-moz-range-thumb, +.pc-vol::-moz-range-thumb { + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--color-accent); + border: none; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); +} + +/* show back + controls when hovered or flagged */ +.player-wrap:hover .player-controls, +.player-wrap:hover .player-back, +.player-wrap--controls .player-controls, +.player-wrap--controls .player-back { + opacity: 1; + transform: none; +} + +.player-wrap:hover .player-bigplay, +.player-wrap--controls .player-bigplay { + /* big play only when paused — handled by .player-wrap--paused below */ +} + +.player-wrap--paused .player-bigplay { + opacity: 1; + pointer-events: auto; +} diff --git a/web/css/pages.css b/web/css/pages.css index 8b22cba..567d679 100644 --- a/web/css/pages.css +++ b/web/css/pages.css @@ -156,23 +156,7 @@ overflow: hidden; } -/* ---- watch: player ------------------------------------------------------------- */ - -.player-wrap { - position: relative; - aspect-ratio: var(--player-aspect); - border-radius: var(--radius-md); - overflow: hidden; - background: #000; - border: 1px solid var(--color-border); -} - -.player-wrap video { - width: 100%; - height: 100%; - display: block; - background: #000; -} +/* ---- watch: player (.player-wrap + controls live in components.css) ------------- */ .player-status { position: absolute; @@ -415,6 +399,121 @@ color: var(--color-text-faint); } +/* ---- settings ------------------------------------------------------------------ */ + +.settings { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} + +.settings__nav { + position: sticky; + top: calc(var(--header-height) + var(--space-5)); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.settings__navlink { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-dim); + text-align: left; +} + +.settings__navlink:hover { + color: var(--color-text); + background: var(--color-surface); +} + +.settings__navlink.is-active { + color: var(--color-accent-text); + background: var(--color-accent); +} + +.settings__content { + display: grid; + gap: var(--space-5); + min-width: 0; +} + +.settings__card { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + padding: var(--space-5); + scroll-margin-top: calc(var(--header-height) + var(--space-4)); +} + +.settings__card h2 { + font-size: var(--text-xl); + margin-bottom: var(--space-2); +} + +.settings__hint { + color: var(--color-text-dim); + font-size: var(--text-sm); + margin-bottom: var(--space-4); + max-width: 60ch; +} + +.settings__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: var(--space-4) 0; +} + +.settings__status { + font-size: var(--text-sm); + min-height: 1.2em; +} + +.settings__status--ok { + color: var(--color-success); +} + +.settings__status--err { + color: var(--color-danger); +} + +.provider-list { + display: grid; + gap: var(--space-2); + margin-top: var(--space-4); +} + +.provider { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-2); + cursor: pointer; + transition: border-color 120ms ease; +} + +.provider:hover { + border-color: var(--color-border-strong); +} + +.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); +} + /* ---- responsive ---------------------------------------------------------------- */ @media (max-width: 720px) { @@ -444,4 +543,19 @@ .search-bar__form { flex-direction: column; } + + .settings { + grid-template-columns: 1fr; + gap: var(--space-4); + } + + .settings__nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + } + + .settings__card { + padding: var(--space-4); + } } diff --git a/web/css/tokens.css b/web/css/tokens.css index d5305f7..220a49e 100644 --- a/web/css/tokens.css +++ b/web/css/tokens.css @@ -8,20 +8,20 @@ :root { /* ---- palette (retheme here) ----------------------------------------- */ - --color-bg: #0b0d10; /* page background */ - --color-surface: #14171c; /* cards, panels */ - --color-surface-hover: #1c2129; /* hover state for surfaces */ - --color-surface-2: #191d24; /* inputs, alt surfaces */ - --color-border: #262c35; /* default borders */ - --color-border-strong: #39424f; /* visible borders / hover borders */ - --color-text: #eceff3; /* primary text */ - --color-text-dim: #98a2b0; /* secondary text */ - --color-text-faint: #5f6b7a; /* tertiary text */ - --color-accent: #4f8cff; /* brand / primary action color */ - --color-accent-hover: #6ea1ff; - --color-accent-text: #ffffff; /* text on accent buttons */ + --color-bg: #0a0a0a; /* page background */ + --color-surface: #141414; /* cards, panels */ + --color-surface-hover: #1d1d1d; /* hover state for surfaces */ + --color-surface-2: #1a1a1a; /* inputs, alt surfaces */ + --color-border: #262626; /* default borders */ + --color-border-strong: #3a3a3a; /* visible borders / hover borders */ + --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-danger: #ff6b6b; - --color-success: #46c98f; + --color-success: #00e08b; --color-star: #ffc94d; /* rating stars */ /* ---- typography ------------------------------------------------------ */ @@ -51,12 +51,12 @@ --space-8: 64px; /* ---- shape ------------------------------------------------------------- */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; --radius-full: 999px; - --shadow-1: 0 2px 8px rgba(0, 0, 0, 0.35); - --shadow-2: 0 12px 40px rgba(0, 0, 0, 0.5); + --shadow-1: 0 2px 8px rgba(0, 0, 0, 0.45); + --shadow-2: 0 12px 40px rgba(0, 0, 0, 0.6); /* ---- layout ------------------------------------------------------------ */ --container-max: 1280px; diff --git a/web/index.html b/web/index.html index 8fc95b2..510d16a 100644 --- a/web/index.html +++ b/web/index.html @@ -13,10 +13,18 @@ @@ -44,29 +52,6 @@ - - diff --git a/web/js/api.js b/web/js/api.js index b20cd34..6c4081f 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -45,6 +45,14 @@ details: (type, id) => request(`/${type}/${id}`), seasons: (id) => request(`/tv/${id}/seasons`), episodes: (id, season) => request(`/tv/${id}/seasons/${season}`), - streams: ({ tmdb, type, season, episode }) => request('/streams', { tmdb, type, season, episode }), + streams: ({ tmdb, type, season, episode, providers }) => + request('/streams', { + tmdb, + type, + season, + episode, + providers: Array.isArray(providers) && providers.length ? providers.join(',') : '', + }), + providers: () => request('/providers'), }; })(); diff --git a/web/js/app.js b/web/js/app.js index 79ae310..5a20e6d 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,49 +1,11 @@ /* - APP — shared chrome for every page: settings dialog, footer API label, - toast notifications. + APP — shared chrome for every page: footer API label, toast notifications. + (Settings moved to its own page: settings.html) */ (function () { 'use strict'; function initChrome() { - const modal = document.getElementById('settingsModal'); - const openBtn = document.getElementById('settingsBtn'); - const input = document.getElementById('apiBaseInput'); - - if (openBtn && modal && input) { - const close = () => { - modal.hidden = true; - }; - openBtn.addEventListener('click', () => { - input.value = SR.config.getBase(); - modal.hidden = false; - input.focus(); - input.select(); - }); - const closeBtn = document.getElementById('settingsClose'); - if (closeBtn) closeBtn.addEventListener('click', close); - modal.addEventListener('click', (e) => { - if (e.target === modal) close(); - }); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && !modal.hidden) close(); - }); - const saveBtn = document.getElementById('apiBaseSave'); - if (saveBtn) { - saveBtn.addEventListener('click', () => { - SR.config.setBase(input.value); - location.reload(); - }); - } - const resetBtn = document.getElementById('apiBaseReset'); - if (resetBtn) { - resetBtn.addEventListener('click', () => { - SR.config.clearBase(); - location.reload(); - }); - } - } - const label = document.getElementById('apiBaseLabel'); if (label) { const base = SR.config.getBase(); diff --git a/web/js/config.js b/web/js/config.js index a3a3c6e..875260d 100644 --- a/web/js/config.js +++ b/web/js/config.js @@ -1,14 +1,21 @@ /* - CONFIG — where the API lives. Resolution order: + CONFIG — where the API lives + which providers to query. + + API base resolution order: 1. ?api= query param on any page (session override) - 2. localStorage (saved from the Settings dialog) + 2. localStorage (saved from Settings) 3. built-in default + + Providers: until the user toggles anything in Settings, the server's own + enabled list is used. Once customized, a {name: bool} map is stored in + localStorage; names missing from the map default to enabled. */ (function () { 'use strict'; - const DEFAULT_BASE = 'https://bug-api-test.tuns.sh'; + const DEFAULT_BASE = 'https://api-tv.bug.tools'; const STORAGE_KEY = 'streamreverse.apiBase.v1'; + const PROVIDER_KEY = 'streamreverse.providers.v1'; function normalize(value) { let v = String(value || '').trim(); @@ -47,6 +54,57 @@ } } + // null → user has not customized; caller should fall back to server defaults + function getProviders() { + try { + const raw = localStorage.getItem(PROVIDER_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch (e) { + return null; + } + } + + function setProviders(map) { + try { + localStorage.setItem(PROVIDER_KEY, JSON.stringify(map)); + } catch (e) { + /* ignore */ + } + } + + function clearProviders() { + try { + localStorage.removeItem(PROVIDER_KEY); + } catch (e) { + /* ignore */ + } + } + + // Merge the server's provider lists with the user's toggles. + // Returns the enabled provider names, in the server's `all` order. + function enabledProviders(allNames, serverEnabled) { + const all = Array.isArray(allNames) ? allNames : []; + const overrides = getProviders(); + if (!overrides) { + const base = Array.isArray(serverEnabled) && serverEnabled.length ? serverEnabled : all; + return all.filter((n) => base.includes(n)); + } + return all.filter((n) => overrides[n] !== false); + } + window.SR = window.SR || {}; - SR.config = { DEFAULT_BASE, STORAGE_KEY, getBase, setBase, clearBase }; + SR.config = { + DEFAULT_BASE, + STORAGE_KEY, + PROVIDER_KEY, + getBase, + setBase, + clearBase, + getProviders, + setProviders, + clearProviders, + enabledProviders, + }; })(); diff --git a/web/js/player.js b/web/js/player.js index cb09e60..8f86028 100644 --- a/web/js/player.js +++ b/web/js/player.js @@ -89,7 +89,9 @@ function load(stream, opts = {}) { const st = capture(); - if (typeof opts.resumeAt === 'number' && opts.resumeAt > 10) st.time = opts.resumeAt; + const explicitResume = typeof opts.resumeAt === 'number' && opts.resumeAt > 10; + if (typeof opts.resumeAt === 'number') st.time = Math.max(0, opts.resumeAt); + const readyAt = () => (explicitResume ? st.time : 0); teardown(); networkRetry = false; @@ -126,7 +128,7 @@ h.attachMedia(video); h.on(Hls.Events.MANIFEST_PARSED, guarded(() => { restore(st); - onReady(st.time > 10 ? st.time : 0); + onReady(readyAt()); })); h.on(Hls.Events.ERROR, (evt, data) => { if (myToken !== token || !data.fatal) return; @@ -155,7 +157,7 @@ 'loadedmetadata', guarded(() => { restore(st); - onReady(st.time > 10 ? st.time : 0); + onReady(readyAt()); }), { once: true } ); diff --git a/web/js/settings.js b/web/js/settings.js new file mode 100644 index 0000000..c16a834 --- /dev/null +++ b/web/js/settings.js @@ -0,0 +1,158 @@ +/* + SETTINGS — source provider toggles, API server base URL, local data. +*/ +(function () { + 'use strict'; + + SR.app.initChrome(); + + const providerList = document.getElementById('providerList'); + const providersStatus = document.getElementById('providersStatus'); + const apiBaseInput = document.getElementById('apiBaseInput'); + const connStatus = document.getElementById('connStatus'); + const dataStatus = document.getElementById('dataStatus'); + + /* ---- nav --------------------------------------------------------------------- */ + + const navlinks = Array.from(document.querySelectorAll('.settings__navlink')); + navlinks.forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const target = document.querySelector(link.getAttribute('href')); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + navlinks.forEach((l) => l.classList.toggle('is-active', l === link)); + history.replaceState(null, '', link.getAttribute('href')); + }); + }); + + /* ---- sources ------------------------------------------------------------------- */ + + let allProviders = []; + let serverEnabled = []; + const boxes = new Map(); // provider name -> checkbox + + function setStatus(el, message, kind) { + el.textContent = message || ''; + el.className = + kind === 'ok' ? 'settings__status settings__status--ok' + : kind === 'err' ? 'settings__status settings__status--err' + : 'settings__status'; + } + + function currentMap() { + const map = {}; + for (const [name, box] of boxes) map[name] = box.checked; + return map; + } + + function persist(map, message) { + SR.config.setProviders(map); + if (message) setStatus(providersStatus, message); + } + + function renderProviders() { + providerList.replaceChildren(); + boxes.clear(); + const overrides = SR.config.getProviders(); + for (const name of allProviders) { + 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); + input.addEventListener('change', () => + persist(currentMap(), input.checked ? `${name} enabled` : `${name} disabled`)); + boxes.set(name, input); + providerList.appendChild(label); + } + 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}`); + } + + function resetProviders(message) { + SR.config.clearProviders(); + renderProviders(); + if (message) setStatus(providersStatus, message); + } + + document.getElementById('allOn').addEventListener('click', () => { + if (!boxes.size) return; + for (const box of boxes.values()) box.checked = true; + persist(currentMap(), 'All sources enabled'); + }); + + document.getElementById('allOff').addEventListener('click', () => { + if (!boxes.size) return; + for (const box of boxes.values()) box.checked = false; + persist(currentMap(), 'All sources disabled'); + }); + + document.getElementById('providersReset').addEventListener('click', () => + resetProviders('Using server defaults')); + + async function initSources() { + try { + const data = await SR.api.providers(); + allProviders = data.all || []; + serverEnabled = data.enabled || []; + renderProviders(); + } catch (e) { + setStatus(providersStatus, `Could not load the provider list: ${e.message}`, 'err'); + } + } + + /* ---- server ----------------------------------------------------------------------- */ + + apiBaseInput.value = SR.config.getBase(); + + async function testConnection() { + try { + const data = await SR.api.providers(); + setStatus(connStatus, `Connected — ${data.all ? data.all.length : 0} provider(s) known.`, 'ok'); + return true; + } catch (e) { + setStatus(connStatus, `Not reachable: ${e.message}`, 'err'); + return false; + } + } + + document.getElementById('apiBaseSave').addEventListener('click', async () => { + SR.config.setBase(apiBaseInput.value); + apiBaseInput.value = SR.config.getBase(); + SR.app.initChrome(); // refresh the footer label + if (await testConnection()) SR.app.toast('API base URL saved'); + }); + + document.getElementById('apiBaseReset').addEventListener('click', async () => { + SR.config.clearBase(); + apiBaseInput.value = SR.config.getBase(); + SR.app.initChrome(); + await testConnection(); + }); + + /* ---- data ---------------------------------------------------------------------------- */ + + document.getElementById('clearHistory').addEventListener('click', () => { + SR.storage.clear(); + setStatus(dataStatus, 'Continue-watching history cleared.'); + }); + + document.getElementById('resetProviders').addEventListener('click', () => { + resetProviders(''); + setStatus(dataStatus, 'Source toggles reset to server defaults.'); + }); + + initSources(); +})(); diff --git a/web/js/storage.js b/web/js/storage.js index 0b13178..6286b28 100644 --- a/web/js/storage.js +++ b/web/js/storage.js @@ -68,6 +68,10 @@ } } + function clear() { + writeAll({}); + } + function list() { return Object.values(readAll()); } @@ -83,5 +87,5 @@ } window.SR = window.SR || {}; - SR.storage = { KEY, keyFor, get, save, remove, list, latestPerShow }; + SR.storage = { KEY, keyFor, get, save, remove, clear, list, latestPerShow }; })(); diff --git a/web/js/watch.js b/web/js/watch.js index 172894c..3931d82 100644 --- a/web/js/watch.js +++ b/web/js/watch.js @@ -24,8 +24,21 @@ 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'); + + // 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'); @@ -39,12 +52,14 @@ sourceIndex: -1, season: null, episode: null, + loadedKey: null, }; /* ---- status overlay ------------------------------------------------------ */ function showStatus(msg, isError) { 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)}

`; @@ -52,6 +67,7 @@ function hideStatus() { statusEl.hidden = true; + syncPaused(); } /* ---- player --------------------------------------------------------------- */ @@ -68,6 +84,229 @@ }, }); + /* ---- 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) { + 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', 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', () => { + sourcesPanel.hidden = !sourcesPanel.hidden; + pcGear.classList.toggle('is-on', !sourcesPanel.hidden); + }); + 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; + } + if (video.src || video.currentSrc) showControls(); + }); + + // initial icon state + syncPlayIcon(); + syncVolIcon(); + /* ---- hero ------------------------------------------------------------------ */ function renderHero(d) { @@ -119,15 +358,29 @@ return SR.storage.keyFor({ type, tmdbId: Number(id), season: state.season, episode: state.episode }); } + let providerNames = null; // cached per page load; null → ask server again + + async function getProviderNames() { + if (providerNames) return providerNames; + try { + const data = await SR.api.providers(); + providerNames = SR.config.enabledProviders(data.all || [], data.enabled || []); + } catch (e) { + providerNames = null; // unreachable → let /streams use server defaults + } + return providerNames; + } + async function loadStreams() { - const query = { tmdb: id, type }; + const query = { tmdb: id, type, providers: await getProviderNames() }; 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'; + pcGear.classList.remove('is-on'); + pcQuality.textContent = ''; showStatus('Finding streams…'); sourceLabel.textContent = ''; resumeNote.textContent = ''; @@ -135,7 +388,7 @@ const data = await SR.api.streams(query); state.streams = data.streams || []; renderSources(); - changeSourceBtn.hidden = state.streams.length === 0; + pcGear.hidden = state.streams.length === 0; if (state.streams.length) playSource(0); else showStatus('No streams found for this title.', true); } catch (e) { @@ -166,18 +419,22 @@ 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; 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 }); + pcQuality.textContent = s.quality || ''; + player.load(s, { resumeAt }); } - changeSourceBtn.addEventListener('click', () => { - sourcesPanel.hidden = !sourcesPanel; - changeSourceBtn.textContent = sourcesPanel.hidden ? 'Change source' : 'Hide sources'; - }); - /* ---- resume persistence ---------------------------------------------------------- */ let lastPersist = 0; diff --git a/web/search.html b/web/search.html index 2c966d6..ffb4abc 100644 --- a/web/search.html +++ b/web/search.html @@ -13,10 +13,18 @@ @@ -42,29 +50,6 @@ - - diff --git a/web/settings.html b/web/settings.html new file mode 100644 index 0000000..40a59ca --- /dev/null +++ b/web/settings.html @@ -0,0 +1,92 @@ + + + + + + bug.tv — settings + + + + + + + + +
+
+ + +
+
+

Sources

+

Choose which providers to search for streams. Saved in this browser; applies to new lookups.

+
+ + + +
+

+
+
+ +
+

Server

+

The REST API that bug.tv queries. Change this only if you run your own instance.

+ + +
+ + +
+

+
+ +
+

Data

+

Everything bug.tv stores lives in this browser — no accounts, nothing on a server.

+
+ + +
+

+
+
+
+
+ + + + + + + + + + + + diff --git a/web/watch.html b/web/watch.html index 60514c5..9dfbc20 100644 --- a/web/watch.html +++ b/web/watch.html @@ -13,22 +13,65 @@ -
-
+
+
-
-
- -
+
+
+ + + + + + + + +
+ +
+ + + + 0:00 / 0:00 + + + + + + +
+
+ +
@@ -36,23 +79,24 @@ -
-
-
@@ -63,29 +107,6 @@ - -