This commit is contained in:
4DBug
2026-08-26 18:28:49 -05:00
parent 9e93df5961
commit 145851a32e
15 changed files with 1215 additions and 272 deletions
+9 -1
View File
@@ -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'),
};
})();
+2 -40
View File
@@ -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();
+62 -4
View File
@@ -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=<url> 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,
};
})();
+5 -3
View File
@@ -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 }
);
+158
View File
@@ -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 = '<span class="switch__thumb"></span>';
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();
})();
+5 -1
View File
@@ -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 };
})();
+268 -11
View File
@@ -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
? `<h3>Playback problem</h3><p>${SR.util.escapeHtml(msg)}</p>`
: `<div class="spinner"></div><p>${SR.util.escapeHtml(msg)}</p>`;
@@ -52,6 +67,7 @@
function hideStatus() {
statusEl.hidden = true;
syncPaused();
}
/* ---- player --------------------------------------------------------------- */
@@ -68,6 +84,229 @@
},
});
/* ---- custom player controls -------------------------------------------------- */
const ICON_PLAY =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>';
const ICON_PAUSE =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 4h4v16H6zm8 0h4v16h-4z"></path></svg>';
const ICON_VOL =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path></svg>';
const ICON_VOL_X =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
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;