Files
mado-anime-website/index.html
T
2026-10-10 11:51:06 +02:00

527 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>