527 lines
24 KiB
HTML
527 lines
24 KiB
HTML
<!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> |