push
This commit is contained in:
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user