Add index.html
This commit is contained in:
+527
@@ -0,0 +1,527 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mado</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #14121a; --panel: #1d1a26; --panel2: #262233; --line: #34304a;
|
||||
--text: #ece9f5; --muted: #9a94b3; --accent: #ff7a59; --accent2: #ffb199; --danger: #ff6b81;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 16px 48px; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; }
|
||||
.brand { font-weight: 800; font-size: 22px; letter-spacing: .5px; cursor: pointer; }
|
||||
.brand span { color: var(--accent); }
|
||||
.who { display: flex; gap: 10px; align-items: center; color: var(--muted); }
|
||||
.btn { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; }
|
||||
.btn:hover { border-color: var(--accent2); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #1a0f0b; font-weight: 700; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.link { background: none; border: 0; color: var(--muted); padding: 2px 4px; text-decoration: underline; }
|
||||
.link:hover { color: var(--text); }
|
||||
input[type=text], input[type=password], select {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
|
||||
input:focus, select:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
|
||||
.auth { max-width: 360px; margin: 8vh auto 0; }
|
||||
.auth h1 { margin: 0 0 4px; }
|
||||
.auth form { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.error { color: var(--danger); min-height: 1.4em; margin: 6px 0 0; }
|
||||
.notice { background: #3a2a1d; border: 1px solid #6b4a2e; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }
|
||||
.searchbar { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.searchbar input[type=text] { flex: 1 1 260px; font-size: 17px; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--muted); margin: 26px 0 10px; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.chip { background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 4px 6px 4px 12px;
|
||||
display: inline-flex; align-items: center; gap: 4px; }
|
||||
.chip:hover { border-color: var(--accent2); }
|
||||
.chip button.main { background: none; border: 0; padding: 0; }
|
||||
.chip button.x { background: none; border: 0; color: var(--muted); padding: 0 6px; font-size: 16px; line-height: 1; }
|
||||
.chip button.x:hover { color: var(--danger); }
|
||||
.tag { font-size: 11px; background: var(--panel2); border-radius: 4px; padding: 1px 5px; color: var(--muted); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||
.item { text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
||||
.item:hover { border-color: var(--accent); }
|
||||
.item .t { font-weight: 600; }
|
||||
.item .s { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.bar { height: 4px; background: var(--panel2); border-radius: 2px; margin-top: 8px; overflow: hidden; }
|
||||
.bar i { display: block; height: 100%; background: var(--accent); }
|
||||
.watch { margin-top: 18px; }
|
||||
.watch .head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.watch h3 { margin: 0; font-size: 19px; }
|
||||
.player { position: relative; background: #000; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; }
|
||||
video { width: 100%; height: 100%; display: block; background: #000; }
|
||||
.overlay { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 20px;
|
||||
background: rgba(0,0,0,.55); }
|
||||
.overlay[hidden] { display: none; }
|
||||
.spinner { width: 34px; height: 34px; border: 3px solid #fff3; border-top-color: var(--accent); border-radius: 50%;
|
||||
animation: spin .8s linear infinite; margin: 0 auto 10px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin: 10px 0; }
|
||||
.controls label { display: inline-flex; gap: 6px; align-items: center; color: var(--muted); }
|
||||
.controls select { padding: 6px 8px; }
|
||||
.eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(58px, 1fr)); gap: 6px; max-height: 260px; overflow: auto; padding: 2px; }
|
||||
.ep { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 0; text-align: center; }
|
||||
.ep:hover { border-color: var(--accent2); }
|
||||
.ep.cur { background: var(--accent); border-color: var(--accent); color: #1a0f0b; font-weight: 700; }
|
||||
.ep.seen { color: var(--accent2); }
|
||||
.hint { color: var(--muted); font-size: 13px; }
|
||||
.empty { color: var(--muted); }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="brand" id="brand">Ma<span>do</span></div>
|
||||
<div class="who" id="who" hidden>
|
||||
<span id="whoName"></span>
|
||||
<button class="btn" id="logout">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
|
||||
<!-- Sign in / register -->
|
||||
<section id="authView" class="card auth" hidden>
|
||||
<h1 id="authTitle">Sign in</h1>
|
||||
<div class="hint">Your search history and last-watched episodes are saved to your account.</div>
|
||||
<form id="authForm">
|
||||
<input type="text" id="authUser" placeholder="Username" autocomplete="username" required>
|
||||
<input type="password" id="authPass" placeholder="Password" autocomplete="current-password" required>
|
||||
<button class="btn primary" id="authSubmit">Sign in</button>
|
||||
</form>
|
||||
<p class="error" id="authErr" role="alert"></p>
|
||||
<button class="link" id="authSwap">New here? Create an account</button>
|
||||
</section>
|
||||
|
||||
<!-- Main app -->
|
||||
<main id="appView" hidden>
|
||||
<form class="searchbar" id="searchForm">
|
||||
<input type="text" id="q" placeholder="Search for an anime…" autocomplete="off" aria-label="Anime name">
|
||||
<select id="mode" aria-label="Audio">
|
||||
<option value="sub">Sub</option>
|
||||
<option value="dub">Dub</option>
|
||||
</select>
|
||||
<button class="btn primary" id="searchBtn">Search</button>
|
||||
</form>
|
||||
<p class="error" id="searchErr" role="alert"></p>
|
||||
|
||||
<section id="watchView" class="watch" hidden>
|
||||
<div class="head">
|
||||
<h3 id="wTitle"></h3>
|
||||
<span class="hint" id="wSub"></span>
|
||||
</div>
|
||||
<div class="player">
|
||||
<video id="video" controls playsinline preload="auto"></video>
|
||||
<div class="overlay" id="overlay" hidden>
|
||||
<div><div class="spinner" id="spin"></div><div id="overlayMsg"></div>
|
||||
<button class="btn" id="retry" hidden style="margin-top:10px">Try again</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="btn" id="prev">⏮ Previous</button>
|
||||
<button class="btn" id="next">Next ⏭</button>
|
||||
<label><input type="checkbox" id="autoplay" checked> Autoplay next</label>
|
||||
<label>Quality
|
||||
<select id="quality">
|
||||
<option value="best">Best</option><option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option><option value="480p">480p</option><option value="360p">360p</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="eps" id="eps"></div>
|
||||
</section>
|
||||
|
||||
<section id="continueBox" hidden>
|
||||
<h2>Continue watching</h2>
|
||||
<div class="grid" id="continueList"></div>
|
||||
</section>
|
||||
|
||||
<section id="resultsBox" hidden>
|
||||
<h2 id="resultsTitle">Results</h2>
|
||||
<div class="grid" id="results"></div>
|
||||
</section>
|
||||
|
||||
<section id="recentBox" hidden>
|
||||
<h2>Recent searches <button class="link" id="clearSearches">clear</button></h2>
|
||||
<div class="row" id="recent"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (tag, props = {}, ...kids) => {
|
||||
const n = Object.assign(document.createElement(tag), props);
|
||||
for (const k of kids) n.append(k);
|
||||
return n;
|
||||
};
|
||||
const store = {
|
||||
get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch { return d; } },
|
||||
set(k, v) { try { localStorage.setItem(k, v); } catch {} },
|
||||
};
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const init = { method: opts.method || "GET", credentials: "same-origin", keepalive: !!opts.keepalive, headers: {} };
|
||||
if (opts.body !== undefined) { init.body = JSON.stringify(opts.body); init.headers["Content-Type"] = "application/json"; }
|
||||
let res;
|
||||
try { res = await fetch(path, init); } catch { throw new Error("Can't reach the server."); }
|
||||
if (res.status === 204) return null;
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch {}
|
||||
if (res.status === 401 && path !== "/api/login") { showAuth(); throw new Error("Sign in to continue."); }
|
||||
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
const S = {
|
||||
user: null, q: "", mode: "sub", entry: null, episodes: [], ep: null,
|
||||
last: null, playToken: 0, hls: null, prefetch: null, lastSave: 0, ended: false,
|
||||
resumeAt: 0, retried: false,
|
||||
};
|
||||
const video = $("video");
|
||||
|
||||
// ---------------------------------------------------------------- auth
|
||||
let registering = false;
|
||||
function setAuthMode(reg) {
|
||||
registering = reg;
|
||||
$("authTitle").textContent = reg ? "Create account" : "Sign in";
|
||||
$("authSubmit").textContent = reg ? "Create account" : "Sign in";
|
||||
$("authSwap").textContent = reg ? "Have an account? Sign in" : "New here? Create an account";
|
||||
$("authPass").autocomplete = reg ? "new-password" : "current-password";
|
||||
$("authErr").textContent = "";
|
||||
}
|
||||
$("authSwap").onclick = () => setAuthMode(!registering);
|
||||
$("authForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
$("authErr").textContent = "";
|
||||
$("authSubmit").disabled = true;
|
||||
try {
|
||||
const r = await api(registering ? "/api/register" : "/api/login",
|
||||
{ method: "POST", body: { username: $("authUser").value, password: $("authPass").value } });
|
||||
$("authPass").value = "";
|
||||
enter(r.user);
|
||||
} catch (err) { $("authErr").textContent = err.message; }
|
||||
finally { $("authSubmit").disabled = false; }
|
||||
};
|
||||
$("logout").onclick = async () => {
|
||||
saveProgress(true);
|
||||
stopPlayer();
|
||||
try { await api("/api/logout", { method: "POST", body: {} }); } catch {}
|
||||
S.user = null; showAuth();
|
||||
};
|
||||
|
||||
function showAuth() {
|
||||
S.user = null;
|
||||
$("authView").hidden = false; $("appView").hidden = true; $("who").hidden = true;
|
||||
}
|
||||
function enter(user) {
|
||||
S.user = user;
|
||||
$("authView").hidden = true; $("appView").hidden = false; $("who").hidden = false;
|
||||
$("whoName").textContent = user;
|
||||
$("mode").value = store.get("mado.mode", "sub");
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- history
|
||||
async function loadHistory() {
|
||||
let h;
|
||||
try { h = await api("/api/history"); } catch { return; }
|
||||
const cl = $("continueList"); cl.replaceChildren();
|
||||
for (const w of h.watching) {
|
||||
const pct = w.duration > 0 ? Math.min(100, (w.position / w.duration) * 100) : 0;
|
||||
const b = el("button", { className: "item", type: "button" },
|
||||
el("div", { className: "t", textContent: w.title }),
|
||||
el("div", { className: "s", textContent: `Episode ${w.episode}${w.total ? " of " + w.total : ""} · ${w.mode}` }));
|
||||
if (pct > 0) { const bar = el("div", { className: "bar" }); const i = el("i"); i.style.width = pct + "%"; bar.append(i); b.append(bar); }
|
||||
b.onclick = () => resume(w);
|
||||
const rm = el("button", { className: "link", type: "button", textContent: "remove" });
|
||||
rm.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await api("/api/history/watching/remove", { method: "POST", body: { show: w.show, mode: w.mode } }).catch(() => {});
|
||||
loadHistory();
|
||||
};
|
||||
b.append(rm);
|
||||
cl.append(b);
|
||||
}
|
||||
$("continueBox").hidden = !h.watching.length;
|
||||
|
||||
const rc = $("recent"); rc.replaceChildren();
|
||||
for (const s of h.searches) {
|
||||
const main = el("button", { className: "main", type: "button", textContent: s.query });
|
||||
main.append(" ", el("span", { className: "tag", textContent: s.mode }));
|
||||
main.onclick = () => { $("q").value = s.query; $("mode").value = s.mode; doSearch(); };
|
||||
const x = el("button", { className: "x", type: "button", title: "Remove", textContent: "×" });
|
||||
x.onclick = async () => { await api("/api/history/searches/" + s.id, { method: "DELETE" }).catch(() => {}); loadHistory(); };
|
||||
rc.append(el("span", { className: "chip" }, main, x));
|
||||
}
|
||||
$("recentBox").hidden = !h.searches.length;
|
||||
}
|
||||
$("clearSearches").onclick = async () => {
|
||||
await api("/api/history/searches", { method: "DELETE" }).catch(() => {});
|
||||
loadHistory();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- search
|
||||
$("searchForm").onsubmit = (e) => { e.preventDefault(); doSearch(); };
|
||||
$("mode").onchange = () => store.set("mado.mode", $("mode").value);
|
||||
|
||||
async function doSearch() {
|
||||
const q = $("q").value.trim();
|
||||
if (!q) return;
|
||||
store.set("mado.mode", $("mode").value);
|
||||
$("searchErr").textContent = "";
|
||||
$("searchBtn").disabled = true; $("searchBtn").textContent = "Searching…";
|
||||
$("resultsBox").hidden = true;
|
||||
try {
|
||||
const r = await api(`/api/search?q=${encodeURIComponent(q)}&mode=${$("mode").value}`);
|
||||
S.q = r.query; S.mode = r.mode;
|
||||
renderResults(r.results);
|
||||
loadHistory();
|
||||
} catch (err) { $("searchErr").textContent = err.message; }
|
||||
finally { $("searchBtn").disabled = false; $("searchBtn").textContent = "Search"; }
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const box = $("results"); box.replaceChildren();
|
||||
$("resultsTitle").textContent = `Results for “${S.q}” (${results.length})`;
|
||||
for (const r of results) {
|
||||
const b = el("button", { className: "item", type: "button" },
|
||||
el("div", { className: "t", textContent: r.guessed ? r.title : r.title }),
|
||||
el("div", { className: "s", textContent: r.last_episode ? `Last watched: episode ${r.last_episode}` : "Not watched yet" }));
|
||||
b.onclick = () => openShow(S.q, S.mode, r.show, r.index, r.title, null);
|
||||
box.append(b);
|
||||
}
|
||||
$("resultsBox").hidden = false;
|
||||
$("resultsBox").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- show + episodes
|
||||
async function openShow(q, mode, show, index, title, startEp) {
|
||||
$("searchErr").textContent = "";
|
||||
$("watchView").hidden = false;
|
||||
$("wTitle").textContent = title;
|
||||
$("wSub").textContent = "Loading episodes…";
|
||||
$("eps").replaceChildren();
|
||||
saveProgress(true);
|
||||
stopPlayer();
|
||||
S.ep = null; S.entry = null; S.episodes = []; S.prefetch = null;
|
||||
showOverlay("Loading episodes…", true);
|
||||
$("watchView").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
let r;
|
||||
try {
|
||||
r = await api(`/api/episodes?q=${encodeURIComponent(q)}&mode=${mode}&show=${encodeURIComponent(show)}&index=${index}`);
|
||||
} catch (err) { showOverlay(err.message, false, null); return; }
|
||||
S.q = q; S.mode = mode; S.entry = r.entry; S.episodes = r.episodes; S.last = r.last;
|
||||
$("wTitle").textContent = r.entry.title;
|
||||
$("wSub").textContent = `${r.episodes.length} episode${r.episodes.length === 1 ? "" : "s"} · ${mode}`;
|
||||
renderEpisodes();
|
||||
let ep = startEp || (r.last && r.episodes.includes(r.last.episode) ? r.last.episode : null);
|
||||
// finished the last episode we saved? offer the next one instead
|
||||
if (!startEp && r.last && ep && r.last.duration > 0 && r.last.position >= r.last.duration - 30) {
|
||||
const nx = r.episodes[r.episodes.indexOf(ep) + 1];
|
||||
if (nx) ep = nx;
|
||||
}
|
||||
if (ep) playEpisode(ep);
|
||||
else showOverlay("Pick an episode to start watching.", false, null);
|
||||
}
|
||||
|
||||
function resume(w) { openShow(w.query, w.mode, w.show, w.index, w.title, w.episode); }
|
||||
|
||||
function renderEpisodes() {
|
||||
const box = $("eps"); box.replaceChildren();
|
||||
for (const ep of S.episodes) {
|
||||
const b = el("button", { className: "ep", type: "button", textContent: ep });
|
||||
if (ep === S.ep) b.classList.add("cur");
|
||||
else if (S.last && S.last.episode === ep) b.classList.add("seen");
|
||||
b.onclick = () => playEpisode(ep);
|
||||
box.append(b);
|
||||
}
|
||||
const cur = box.querySelector(".cur");
|
||||
if (cur) cur.scrollIntoView({ block: "nearest" });
|
||||
const i = S.episodes.indexOf(S.ep);
|
||||
$("prev").disabled = i <= 0;
|
||||
$("next").disabled = i < 0 || i >= S.episodes.length - 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- playback
|
||||
function showOverlay(msg, spin, retry) {
|
||||
$("overlay").hidden = false;
|
||||
$("overlayMsg").textContent = msg;
|
||||
$("spin").hidden = !spin;
|
||||
$("retry").hidden = !retry;
|
||||
$("retry").onclick = retry || null;
|
||||
}
|
||||
const hideOverlay = () => { $("overlay").hidden = true; };
|
||||
|
||||
function stopPlayer() {
|
||||
S.playToken++;
|
||||
if (S.hls) { S.hls.destroy(); S.hls = null; }
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.querySelectorAll("track").forEach((t) => t.remove());
|
||||
video.load();
|
||||
}
|
||||
|
||||
function playReq(ep) {
|
||||
return api("/api/play", { method: "POST", body: {
|
||||
q: S.q, mode: S.mode, show: S.entry.show, index: S.entry.index, episode: ep, quality: $("quality").value,
|
||||
} });
|
||||
}
|
||||
|
||||
async function playEpisode(ep, opts = {}) {
|
||||
if (!S.entry) return;
|
||||
saveProgress(true);
|
||||
const token = ++S.playToken;
|
||||
S.ep = ep; S.ended = false; S.retried = false;
|
||||
S.resumeAt = opts.resume !== undefined ? opts.resume :
|
||||
(S.last && S.last.episode === ep && S.last.duration > 0 && S.last.position > 10 &&
|
||||
S.last.position < S.last.duration - 30 ? S.last.position : 0);
|
||||
renderEpisodes();
|
||||
$("wSub").textContent = `Episode ${ep} of ${S.episodes.length} · ${S.mode}`;
|
||||
if (S.hls) { S.hls.destroy(); S.hls = null; }
|
||||
video.pause();
|
||||
showOverlay(`Fetching episode ${ep}…`, true, null);
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = (S.prefetch && S.prefetch.ep === ep && S.prefetch.key === prefetchKey()) ? await S.prefetch.promise : await playReq(ep);
|
||||
} catch (err) {
|
||||
if (token !== S.playToken) return;
|
||||
showOverlay(err.message, false, () => playEpisode(ep));
|
||||
return;
|
||||
} finally { if (S.prefetch && S.prefetch.ep === ep) S.prefetch = null; }
|
||||
if (token !== S.playToken) return;
|
||||
attach(info, token);
|
||||
// fix the title when ani-cli skipped the menu and we only had a guess
|
||||
if (info.title && S.entry.guessed) { S.entry.title = info.title; $("wTitle").textContent = info.title; }
|
||||
}
|
||||
|
||||
const prefetchKey = () => [S.q, S.mode, S.entry && S.entry.show, $("quality").value].join("|");
|
||||
|
||||
function attach(info, token) {
|
||||
video.querySelectorAll("track").forEach((t) => t.remove());
|
||||
if (info.subtitle) {
|
||||
video.append(el("track", { kind: "subtitles", label: "Subtitles", srclang: "en", src: info.subtitle, default: true }));
|
||||
}
|
||||
const onReady = () => {
|
||||
if (token !== S.playToken) return;
|
||||
hideOverlay();
|
||||
if (S.resumeAt > 0) { try { video.currentTime = S.resumeAt; } catch {} S.resumeAt = 0; }
|
||||
video.play().catch(() => showOverlay("Press play to start.", false, null));
|
||||
saveProgress(true);
|
||||
};
|
||||
if (info.kind === "hls" && window.Hls && Hls.isSupported()) {
|
||||
const hls = new Hls({ maxBufferLength: 40 });
|
||||
S.hls = hls;
|
||||
hls.loadSource(info.src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, onReady);
|
||||
hls.on(Hls.Events.ERROR, (_e, d) => {
|
||||
if (!d.fatal || token !== S.playToken) return;
|
||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR && !S.retried) { S.retried = true; hls.startLoad(); }
|
||||
else if (d.type === Hls.ErrorTypes.MEDIA_ERROR && !S.retried) { S.retried = true; hls.recoverMediaError(); }
|
||||
else failed(token);
|
||||
});
|
||||
} else {
|
||||
video.src = info.src; // MP4, or HLS in Safari
|
||||
video.addEventListener("loadedmetadata", onReady, { once: true });
|
||||
video.load();
|
||||
}
|
||||
}
|
||||
|
||||
function failed(token) {
|
||||
if (token !== S.playToken) return;
|
||||
const ep = S.ep;
|
||||
showOverlay("This stream failed to load.", false, () => {
|
||||
// drop the cached link on the server side by asking again in a different quality order
|
||||
playEpisode(ep, { resume: video.currentTime || 0 });
|
||||
});
|
||||
}
|
||||
video.addEventListener("error", () => { if (video.getAttribute("src")) failed(S.playToken); });
|
||||
|
||||
// next / previous / autoplay
|
||||
const nextEp = () => S.episodes[S.episodes.indexOf(S.ep) + 1];
|
||||
const prevEp = () => S.episodes[S.episodes.indexOf(S.ep) - 1];
|
||||
$("next").onclick = () => { const n = nextEp(); if (n) playEpisode(n, { resume: 0 }); };
|
||||
$("prev").onclick = () => { const p = prevEp(); if (p) playEpisode(p, { resume: 0 }); };
|
||||
$("autoplay").checked = store.get("mado.autoplay", "1") === "1";
|
||||
$("autoplay").onchange = () => store.set("mado.autoplay", $("autoplay").checked ? "1" : "0");
|
||||
$("quality").value = store.get("mado.quality", "best");
|
||||
$("quality").onchange = () => {
|
||||
store.set("mado.quality", $("quality").value);
|
||||
if (S.ep) playEpisode(S.ep, { resume: video.currentTime || 0 });
|
||||
};
|
||||
|
||||
video.addEventListener("ended", () => {
|
||||
S.ended = true;
|
||||
saveProgress(true);
|
||||
const n = nextEp();
|
||||
if (n && $("autoplay").checked) {
|
||||
S.last = { episode: S.ep, position: video.duration || 0, duration: video.duration || 0 };
|
||||
playEpisode(n, { resume: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
// warm the next episode's stream link once we're a third of the way in
|
||||
video.addEventListener("timeupdate", () => {
|
||||
const now = Date.now();
|
||||
if (now - S.lastSave > 10000) saveProgress(false);
|
||||
const n = nextEp();
|
||||
if (n && video.duration > 0 && video.currentTime / video.duration > 0.33 &&
|
||||
(!S.prefetch || S.prefetch.ep !== n || S.prefetch.key !== prefetchKey())) {
|
||||
const p = playReq(n);
|
||||
p.catch(() => { if (S.prefetch && S.prefetch.promise === p) S.prefetch = null; });
|
||||
S.prefetch = { ep: n, key: prefetchKey(), promise: p };
|
||||
}
|
||||
});
|
||||
video.addEventListener("pause", () => { if (!video.ended) saveProgress(true); });
|
||||
|
||||
function saveProgress(force, keepalive) {
|
||||
if (!S.user || !S.entry || !S.ep) return;
|
||||
const pos = video.currentTime || 0, dur = video.duration || 0;
|
||||
// while a new episode is still loading there's no position to report; record the start of it
|
||||
S.lastSave = Date.now();
|
||||
api("/api/progress", { method: "POST", keepalive: !!keepalive, body: {
|
||||
show: S.entry.show, title: S.entry.title, q: S.q, mode: S.mode, index: S.entry.index,
|
||||
total: S.episodes.length, episode: S.ep,
|
||||
position: Number.isFinite(pos) ? pos : 0, duration: Number.isFinite(dur) ? dur : 0,
|
||||
} }).then(() => { if (force) loadHistoryDebounced(); }).catch(() => {});
|
||||
}
|
||||
let histTimer = 0;
|
||||
function loadHistoryDebounced() { clearTimeout(histTimer); histTimer = setTimeout(loadHistory, 800); }
|
||||
window.addEventListener("pagehide", () => saveProgress(true, true));
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) saveProgress(true, true); });
|
||||
|
||||
$("brand").onclick = () => window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
(async function boot() {
|
||||
setAuthMode(false);
|
||||
try {
|
||||
const st = await fetch("/api/status").then((r) => r.json());
|
||||
if (!st.ok) {
|
||||
const n = $("notice"); n.hidden = false;
|
||||
n.textContent = "The server is missing: " + st.missing.join(", ") + ". Install it and restart Mado.";
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const me = await api("/api/me");
|
||||
if (me.user) enter(me.user); else showAuth();
|
||||
} catch { showAuth(); }
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user