Files
deploy-test/animex/view.html
2026-04-05 20:22:12 -05:00

1198 lines
39 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Episode Player - Animex</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<style>
:root {
--accent-color: #ff9500;
--accent-glow: rgba(255, 149, 0, 0.3);
--background-color: #0a0a0a;
--surface-color: #141414;
--surface-lighter: #1e1e1e;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: rgba(255, 255, 255, 0.08);
--indicator-left: 4px;
--indicator-width: 0px;
--player-aspect: 16 / 9;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: var(--background-color);
font-family: "Inter", sans-serif;
color: var(--text-primary);
-webkit-tap-highlight-color: transparent;
overflow-x: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* --- Phase 6: Dynamic Ambient Glow --- */
#ambient-glow {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
height: 60vh;
background: radial-gradient(
circle at center,
var(--accent-glow) 0%,
transparent 70%
);
filter: blur(80px);
z-index: -1;
opacity: 0.6;
pointer-events: none;
transition: background 1s ease;
}
/* --- Loading Screen --- */
#loading-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 200;
background-color: #000;
background-size: cover;
background-position: center;
display: flex;
align-items: flex-end;
justify-content: center;
transition: opacity 0.5s ease;
}
#loading-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to top,
rgba(0, 0, 0, 1) 10%,
rgba(0, 0, 0, 0.4) 100%
);
}
#loading-info {
position: relative;
text-align: left;
padding: 3rem 1.5rem;
width: 100%;
max-width: 800px;
z-index: 201;
}
#loading-info h2 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.02em;
}
#loading-info p {
margin: 0;
font-size: 1rem;
color: var(--text-secondary);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.loader-spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top: 3px solid var(--accent-color);
width: 50px;
height: 50px;
animation: spin 0.8s cubic-bezier(0.5, 0, 0.5, 1) infinite;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@keyframes spin {
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* --- Player Section --- */
#player-wrapper {
position: relative;
width: 100%;
max-width: 1400px;
margin: 0 auto;
z-index: 10;
padding: 0;
}
#player-container {
width: 100%;
aspect-ratio: var(--player-aspect);
background: #000;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
position: relative;
overflow: hidden;
}
#player-container iframe {
width: 100%;
height: 100%;
border: none;
background: #000;
}
/* --- Content Layout --- */
#main-content {
flex-grow: 1;
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* --- Header & Controls --- */
#header-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#series-title {
font-size: 1.8rem;
font-weight: 800;
letter-spacing: -0.03em;
}
#meta-line {
font-size: 0.95rem;
color: var(--text-secondary);
font-weight: 500;
}
#meta-line span {
color: var(--text-primary);
}
#controls-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
#source-controls {
display: flex;
background-color: var(--surface-color);
border-radius: 14px;
padding: 4px;
border: 1px solid var(--border-color);
gap: 4px;
}
/* SUB/DUB Toggle */
.subdub-toggle {
position: relative;
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.subdub-toggle::before {
content: "";
position: absolute;
top: 0;
left: var(--indicator-left);
width: var(--indicator-width);
height: 100%;
background: var(--accent-color);
border-radius: 10px;
z-index: 1;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
}
.subdub-btn {
position: relative;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 700;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
z-index: 2;
transition: color 0.2s;
}
.subdub-btn.active {
color: #000;
}
/* Source Selector */
.source-selector-wrapper {
position: relative;
border-left: 1px solid var(--border-color);
margin-left: 4px;
padding-left: 4px;
}
#source-changer-select {
background: transparent;
color: var(--text-primary);
border: none;
padding: 10px 30px 10px 15px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
appearance: none;
}
.source-selector-wrapper::after {
content: "\f078";
font-family: "Font Awesome 6 Free";
font-weight: 900;
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 0.7rem;
color: var(--text-secondary);
pointer-events: none;
}
.icon-btn {
background: var(--surface-color);
border: 1px solid var(--border-color);
color: var(--text-primary);
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.icon-btn:hover {
background: var(--surface-lighter);
transform: translateY(-2px);
}
/* --- Phase 5: Episode Scroller --- */
.scroller-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.scroller-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.scroller-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.episode-scroller {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 10px 0 16px 0;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
/* Touch devices: hide scrollbar (they use native swipe) */
scrollbar-width: none;
cursor: grab;
user-select: none;
}
.episode-scroller::-webkit-scrollbar { display: none; }
.episode-scroller.is-dragging { cursor: grabbing; scroll-behavior: auto; }
/* Desktop (pointer device): show a thin styled scrollbar */
@media (hover: hover) and (pointer: fine) {
.episode-scroller {
scrollbar-width: thin;
scrollbar-color: rgba(255,149,0,0.5) rgba(255,255,255,0.05);
padding-bottom: 20px;
}
.episode-scroller::-webkit-scrollbar {
display: block;
height: 4px;
}
.episode-scroller::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05);
border-radius: 2px;
}
.episode-scroller::-webkit-scrollbar-thumb {
background: rgba(255,149,0,0.5);
border-radius: 2px;
}
.episode-scroller::-webkit-scrollbar-thumb:hover {
background: var(--accent-color);
}
}
.ep-card {
flex: 0 0 220px;
background: var(--surface-color);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
position: relative;
}
.ep-card:hover {
transform: translateY(-5px);
border-color: rgba(255, 255, 255, 0.2);
}
.ep-card.active {
border-color: var(--accent-color);
background: #1a1510;
box-shadow: 0 0 20px rgba(255, 149, 0, 0.15);
}
.ep-card-thumb {
width: 100%;
aspect-ratio: 16/9;
background: var(--surface-color);
position: relative;
}
.ep-card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
transition: opacity 0.3s;
}
.ep-card:hover .ep-card-thumb img {
opacity: 1;
}
.ep-card.active .ep-card-thumb img {
opacity: 1;
}
.ep-card.active .ep-card-thumb::after {
background: var(--accent-color);
}
.ep-card-info {
padding: 12px;
}
.ep-card-number {
font-size: 0.7rem;
font-weight: 800;
color: var(--accent-color);
text-transform: uppercase;
margin-bottom: 4px;
}
.ep-card-title {
font-size: 0.85rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Now Playing Badge */
.now-playing-badge {
position: absolute;
top: 8px;
right: 8px;
background: var(--accent-color);
color: #000;
font-size: 0.6rem;
font-weight: 900;
padding: 4px 8px;
border-radius: 6px;
text-transform: uppercase;
}
/* Phase 5: Cross-Season Boundary Cards */
.boundary-card {
flex: 0 0 220px;
background: linear-gradient(135deg, #1a1a1a 0%, #0d0d0d 100%);
border: 1px dashed var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
}
.boundary-card:hover {
border-color: var(--accent-color);
background: #141414;
}
.boundary-card i {
font-size: 1.5rem;
color: var(--accent-color);
}
.boundary-label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
}
.boundary-title {
font-size: 0.85rem;
font-weight: 600;
color: #fff;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Locate (crosshairs) button in scroller header */
.locate-btn {
background: var(--surface-color);
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
flex-shrink: 0;
}
.locate-btn:hover {
border-color: var(--accent-color);
color: var(--accent-color);
background: rgba(255,149,0,0.08);
transform: translateY(-1px);
}
.locate-btn:active {
transform: scale(0.92);
}
/* Highlight pulse — applied briefly when the card is focused */
@keyframes ep-highlight-pulse {
0% { box-shadow: 0 0 0 0 rgba(255,149,0,0.7), 0 0 20px rgba(255,149,0,0.15); }
40% { box-shadow: 0 0 0 6px rgba(255,149,0,0), 0 0 30px rgba(255,149,0,0.3); }
100% { box-shadow: 0 0 0 0 rgba(255,149,0,0), 0 0 20px rgba(255,149,0,0.15); }
}
.ep-card.active.ep-highlight {
animation: ep-highlight-pulse 0.75s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
/* Synopsis Section */
#synopsis-container {
background: var(--surface-color);
padding: 1.5rem;
border-radius: 16px;
border: 1px solid var(--border-color);
}
#synopsis-container h4 {
margin: 0 0 0.8rem 0;
font-size: 1rem;
font-weight: 700;
}
#synopsis-container p {
font-size: 0.95rem;
line-height: 1.7;
color: var(--text-secondary);
margin: 0;
}
@media (max-width: 768px) {
#main-content {
padding: 1rem;
}
#series-title {
font-size: 1.4rem;
}
#controls-row {
flex-direction: column;
align-items: stretch;
}
.source-selector-wrapper {
flex-grow: 1;
}
#source-changer-select {
width: 100%;
}
.ep-card {
flex: 0 0 180px;
}
}
</style>
</head>
<body>
<div id="ambient-glow"></div>
<div id="loading-container">
<div class="loader-spinner"></div>
<div id="loading-info">
<h2 id="loading-title">Preparing Stream</h2>
<p id="loading-synopsis">
Hang tight, we're fetching the best available sources for you...
</p>
</div>
</div>
<div id="player-wrapper">
<div id="player-container"></div>
</div>
<div id="main-content">
<div id="header-info">
<div id="series-title"></div>
<div id="meta-line">
<span id="series-type"></span> &bull;
<span id="series-year"></span><span id="episode-meta-part"> &bull; Episode
<span id="episode-number"></span></span>
</div>
</div>
<div id="controls-row">
<div id="source-controls">
<ul class="subdub-toggle" id="subdub-toggle">
<li>
<button class="subdub-btn active" id="sub-option-btn">SUB</button>
</li>
<li><button class="subdub-btn" id="dub-option-btn">DUB</button></li>
</ul>
<div class="source-selector-wrapper">
<select id="source-changer-select"></select>
</div>
</div>
<div style="display: flex; gap: 10px">
<button id="reload-btn" class="icon-btn" title="Refresh Source">
<i class="fas fa-redo-alt"></i>
</button>
<button id="fullscreen-player-btn" class="icon-btn">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
<!-- Phase 5: Episode Scroller Section -->
<div class="scroller-section">
<div class="scroller-header">
<h3>Episodes</h3>
<div style="display:flex; align-items:center; gap:10px;">
<div id="scroller-count" style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);"></div>
<button id="locate-ep-btn" class="locate-btn" title="Jump to current episode">
<i class="fas fa-location-crosshairs"></i>
</button>
</div>
</div>
<div class="episode-scroller" id="episode-scroller">
<!-- Episodes & Boundary cards injected here -->
</div>
</div>
<div id="synopsis-container">
<h4>Episode Description</h4>
<p id="synopsis-text">No description available.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const jikanId = params.get("id");
let currentEp = parseInt(params.get("ep"));
// Episode list cache — populated once by loadEpisodeScroller()
let allEpsData = []; // flat array of Kitsu episode objects
const epCards = {}; // { epNumber: <DOM card element> } for O(1) active-state updates
const DOM = {
player: document.getElementById("player-container"),
loading: document.getElementById("loading-container"),
subBtn: document.getElementById("sub-option-btn"),
dubBtn: document.getElementById("dub-option-btn"),
sourceSelect: document.getElementById("source-changer-select"),
scroller: document.getElementById("episode-scroller"),
title: document.getElementById("series-title"),
type: document.getElementById("series-type"),
year: document.getElementById("series-year"),
epNum: document.getElementById("episode-number"),
synopsis: document.getElementById("synopsis-text"),
glow: document.getElementById("ambient-glow"),
};
let userPref = localStorage.getItem("animeDubPref") || "sub";
let currentModule = "default";
let cachedAnimeData = null;
document.addEventListener("DOMContentLoaded", async () => {
if (!jikanId || !currentEp) return;
// ── Desktop scroller: wheel → horizontal scroll, mouse drag ──────
const scroller = document.getElementById("episode-scroller");
scroller.addEventListener("wheel", (e) => {
// Only hijack pure vertical wheel events (not touchpad horizontal swipes)
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
e.preventDefault();
scroller.scrollLeft += e.deltaY * 1.5;
}, { passive: false });
let drag = { active: false, startX: 0, scrollLeft: 0 };
scroller.addEventListener("mousedown", (e) => {
drag.active = true;
drag.startX = e.pageX - scroller.offsetLeft;
drag.scrollLeft = scroller.scrollLeft;
scroller.classList.add("is-dragging");
});
const endDrag = () => { drag.active = false; scroller.classList.remove("is-dragging"); };
scroller.addEventListener("mouseleave", endDrag);
scroller.addEventListener("mouseup", endDrag);
scroller.addEventListener("mousemove", (e) => {
if (!drag.active) return;
e.preventDefault();
const x = e.pageX - scroller.offsetLeft;
const walk = (x - drag.startX) * 1.2;
scroller.scrollLeft = drag.scrollLeft - walk;
});
// ── Locate button: scroll to & flash the active episode card ─────
document.getElementById("locate-ep-btn").addEventListener("click", locateCurrentEpisode);
// ─────────────────────────────────────────────────────────────────
setupUI();
await initialize();
});
async function initialize() {
try {
// 1. Get Core Anime Info (Fast)
cachedAnimeData = await fetchAnimeMetadata();
renderMetadata(cachedAnimeData);
// 2. Load Streaming Content
await populateSources();
await loadPlayer(userPref === "dub", currentModule);
// 3. Load Scroller (Phase 5)
await loadEpisodeScroller();
// 4. Log progress
logProgress();
} catch (e) {
console.error("Init Error:", e);
DOM.loading.style.display = "none";
DOM.player.innerHTML = `<div style="padding: 40px; text-align: center; color: #ff6b6b;">Failed to load data: ${e.message}</div>`;
}
}
function setupUI() {
// Toggle handling
DOM.subBtn.onclick = () => switchPref("sub");
DOM.dubBtn.onclick = () => switchPref("dub");
if (userPref === "dub") {
DOM.subBtn.classList.remove("active");
DOM.dubBtn.classList.add("active");
}
updateIndicator();
DOM.sourceSelect.onchange = () => {
currentModule = DOM.sourceSelect.value;
loadPlayer(userPref === "dub", currentModule);
};
document.getElementById("reload-btn").onclick = () =>
loadPlayer(userPref === "dub", currentModule);
document.getElementById("fullscreen-player-btn").onclick = () =>
DOM.player.requestFullscreen();
}
function switchPref(p) {
if (userPref === p) return;
userPref = p;
localStorage.setItem("animeDubPref", p);
DOM.subBtn.classList.toggle("active");
DOM.dubBtn.classList.toggle("active");
updateIndicator();
loadPlayer(userPref === "dub", currentModule);
}
function updateIndicator() {
const active = document.querySelector(".subdub-btn.active");
if (active) {
const rect = active.getBoundingClientRect();
const parentRect = document
.getElementById("subdub-toggle")
.getBoundingClientRect();
document.documentElement.style.setProperty(
"--indicator-left",
rect.left - parentRect.left + "px",
);
document.documentElement.style.setProperty(
"--indicator-width",
rect.width + "px",
);
}
}
async function fetchAnimeMetadata() {
// Fetch from Jikan first for title
const res = await fetch(`https://api.jikan.moe/v4/anime/${jikanId}`);
const info = (await res.json()).data;
// Fetch Kitsu for richer assets and synopsis
let kitsuData = null;
try {
const mapRes = await fetch(`/map/mal/${jikanId}`);
const map = await mapRes.json();
const kitsuRes = await fetch(
`https://kitsu.io/api/edge/anime/${map.kitsu_id}?include=episodes`,
);
kitsuData = await kitsuRes.json();
} catch (e) {}
return {
title: info.title_english || info.title,
type: info.type,
year: info.year || info.aired?.prop?.from?.year,
synopsis: info.synopsis,
kitsu: kitsuData,
poster: info.images.jpg.large_image_url,
};
}
function renderMetadata(data) {
const isMovie = data.type === "Movie";
DOM.title.textContent = data.title;
DOM.type.textContent = data.type;
DOM.year.textContent = data.year;
document.getElementById("loading-title").textContent = data.title;
// For movies, hide the "Episode X" part of the meta line
const episodePart = document.getElementById("episode-meta-part");
if (episodePart) episodePart.style.display = isMovie ? "none" : "";
DOM.epNum.textContent = currentEp;
// For movies, hide the episode scroller section entirely
const scrollerSection = document.querySelector(".scroller-section");
if (scrollerSection) scrollerSection.style.display = isMovie ? "none" : "";
// For movies, rename synopsis heading and default text
const synopsisHeading = document.querySelector("#synopsis-container h4");
if (synopsisHeading) synopsisHeading.textContent = isMovie ? "Synopsis" : "Episode Description";
if (isMovie) {
DOM.synopsis.textContent = data.synopsis || "No description available.";
document.getElementById("loading-synopsis").textContent = DOM.synopsis.textContent;
}
let episodeThumbnail = data.poster; // Default to series poster
if (data.kitsu) {
const epAttr = data.kitsu.included?.find(
(i) => i.attributes?.number == currentEp,
);
if (epAttr && epAttr.attributes.thumbnail?.original) {
episodeThumbnail = `/proxy-image?url=${encodeURIComponent(epAttr.attributes.thumbnail.original)}`;
}
}
DOM.loading.style.backgroundImage = `url(${episodeThumbnail})`;
// Try to find specific episode synopsis (only for non-movies)
if (!isMovie && data.kitsu) {
const epAttr = data.kitsu.included?.find(
(i) => i.attributes?.number == currentEp,
);
if (epAttr) {
DOM.synopsis.textContent =
epAttr.attributes.synopsis ||
epAttr.attributes.description ||
data.synopsis;
document.getElementById("loading-synopsis").textContent =
DOM.synopsis.textContent;
}
}
}
async function populateSources() {
try {
const res = await fetch("/modules/streaming");
const modules = await res.json();
DOM.sourceSelect.innerHTML =
'<option value="default">Auto-Select Source</option>';
modules.forEach((m) => {
const opt = document.createElement("option");
opt.value = m.id;
opt.textContent = m.name;
DOM.sourceSelect.appendChild(opt);
});
} catch (e) {}
}
// ─── Source connectivity probe ────────────────────────────────
// Attempt to reach the player/source URL directly once.
// If it's reachable → load as-is. If blocked → wrap in proxy.
let _srcProbeCache = {}; // keyed by origin, value: true (direct) | false (proxy)
async function probeSourceUrl(src) {
try {
const origin = new URL(src).origin;
if (_srcProbeCache[origin] !== undefined) return _srcProbeCache[origin];
const ok = await new Promise(resolve => {
const img = new Image();
const timer = setTimeout(() => { img.src = ""; resolve(false); }, 6000);
// Use a tiny pixel endpoint at the same origin if it exists, else probe the src itself
img.onload = () => { clearTimeout(timer); resolve(true); };
img.onerror = () => { clearTimeout(timer); resolve(false); };
img.src = src + (src.includes("?") ? "&" : "?") + "_probe=" + Date.now();
});
_srcProbeCache[origin] = ok;
console.log(`[view] source probe for ${origin}: ${ok ? "✓ direct" : "✗ using proxy"}`);
return ok;
} catch (e) {
return true; // can't parse URL — just try direct
}
}
function proxySrc(src) {
return `/proxy-image?url=${encodeURIComponent(src)}`;
}
// ─────────────────────────────────────────────────────────────
async function loadPlayer(dub, module) {
DOM.loading.style.display = "flex";
DOM.loading.style.opacity = "1";
let url = `/iframe-src?mal_id=${jikanId}&episode=${currentEp}&dub=${dub}`;
if (module !== "default") url += `&prefer_module=${module}`;
try {
const res = await fetch(url);
const data = await res.json();
if (!data.src) throw new Error("No source found");
// ── Probe once: can we reach the source directly? ──
const directOk = await probeSourceUrl(data.src);
const finalSrc = directOk ? data.src : proxySrc(data.src);
DOM.player.innerHTML = `<iframe src="${finalSrc}" allowfullscreen sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
if (data.source_module) DOM.sourceSelect.value = data.source_module;
// Phase 6: Sync glow to accent (or potentially extract color from poster)
DOM.glow.style.background = `radial-gradient(circle at center, var(--accent-glow) 0%, transparent 70%)`;
setTimeout(() => {
DOM.loading.style.opacity = "0";
setTimeout(() => (DOM.loading.style.display = "none"), 500);
}, 1000);
} catch (e) {
DOM.loading.style.display = "none";
DOM.player.innerHTML = `<div style="height:100%; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:15px;">
<p style="color:#ff6b6b">Module failed to provide source.</p>
<button onclick="window.location.reload()" class="subdub-btn" style="background:var(--accent-color); color:#000">Try Again</button>
</div>`;
}
}
/* ─── Locate current episode ─────────────────────────────────────
Scrolls the active card into view and fires a brief glow pulse
so the user's eye is drawn to it. */
function locateCurrentEpisode() {
const card = epCards[currentEp];
if (!card) return;
card.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
// Re-trigger the animation by removing + re-adding the class
card.classList.remove("ep-highlight");
// Force reflow so removing the class is visible before re-adding
void card.offsetWidth;
card.classList.add("ep-highlight");
card.addEventListener("animationend", () => card.classList.remove("ep-highlight"), { once: true });
}
/* ─── In-page episode switching ─────────────────────────────────
Called from: episode card clicks, animex-next-episode messages,
and the browser back/forward buttons (popstate).
Never does a full page reload — everything is updated in place. */
async function switchEpisode(num) {
num = parseInt(num);
if (isNaN(num) || num === currentEp) return;
// 0. Kill the current iframe immediately so audio/video stops now,
// not after the loading overlay finishes fading in.
const liveIframe = DOM.player.querySelector("iframe");
if (liveIframe) liveIframe.src = "about:blank";
const prevEp = currentEp;
currentEp = num;
// 1. Update URL (no reload)
params.set("ep", num);
history.pushState({ ep: num }, "", "?" + params.toString());
// 2. Update the meta line episode number
DOM.epNum.textContent = num;
// 3. Swap active state on scroller cards
const prevCard = epCards[prevEp];
const nextCard = epCards[num];
if (prevCard) {
prevCard.classList.remove("active");
prevCard.querySelector(".now-playing-badge")?.remove();
}
if (nextCard) {
nextCard.classList.add("active");
const thumbEl = nextCard.querySelector(".ep-card-thumb");
if (thumbEl && !thumbEl.querySelector(".now-playing-badge")) {
const badge = document.createElement("div");
badge.className = "now-playing-badge";
badge.textContent = "Playing";
thumbEl.appendChild(badge);
}
setTimeout(() =>
nextCard.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }),
100
);
}
// 4. Update per-episode metadata (synopsis, loading backdrop)
updateEpisodeMeta(num);
// 5. Reload the player for the new episode
await loadPlayer(userPref === "dub", currentModule);
// 6. Persist progress
logProgress();
}
/* Updates synopsis and loading-screen thumbnail for a given episode
using the already-fetched allEpsData array — no extra network call. */
function updateEpisodeMeta(num) {
const ep = allEpsData.find(e => e.attributes?.number == num);
if (!ep) return;
const attrs = ep.attributes || {};
// Loading screen backdrop
const thumb = attrs.thumbnail?.original;
if (thumb) {
DOM.loading.style.backgroundImage =
`url(/proxy-image?url=${encodeURIComponent(thumb)})`;
} else if (cachedAnimeData?.poster) {
DOM.loading.style.backgroundImage = `url(${cachedAnimeData.poster})`;
}
// Episode synopsis
const desc = attrs.synopsis || attrs.description || cachedAnimeData?.synopsis || "";
DOM.synopsis.textContent = desc || "No description available.";
// Loading screen synopsis mirror
const loadSyn = document.getElementById("loading-synopsis");
if (loadSyn) loadSyn.textContent = DOM.synopsis.textContent;
}
/* Browser back/forward: re-sync state to what's in the URL */
window.addEventListener("popstate", (event) => {
const ep = event.state?.ep ?? parseInt(new URLSearchParams(window.location.search).get("ep"));
if (ep && ep !== currentEp) switchEpisode(ep);
});
async function loadEpisodeScroller() {
// Movies don't have an episode list
if (cachedAnimeData && cachedAnimeData.type === "Movie") return;
DOM.scroller.innerHTML =
'<div class="loader-spinner" style="position:relative; transform:none; left:0; top:0; margin: 20px auto;"></div>';
try {
// 1. Fetch ALL episodes from the backend cache endpoint.
// The server handles Kitsu pagination, smart TTLs per airing status,
// and background refreshes — so we always get the complete list instantly.
const epRes = await fetch(`/anime/${jikanId}/episodes`);
if (!epRes.ok) throw new Error("Episode cache unavailable");
const epData = await epRes.json();
const eps = epData.episodes || [];
// Keep a module-level copy for updateEpisodeMeta() lookups
allEpsData = eps;
// 2. Fetch Season Mapping for Boundaries
const seasonRes = await fetch(`/anime/${jikanId}/seasons`);
const seasonData = await seasonRes.json();
DOM.scroller.innerHTML = "";
// -- Add "Previous Season" Card if applicable --
const currentGroup = seasonData.season_groups.find((g) =>
g.parts.some((p) => p.mal_id == jikanId),
);
if (currentGroup && currentGroup.season_index > 1) {
const prevGroup = seasonData.season_groups.find(
(g) => g.season_index == currentGroup.season_index - 1,
);
if (prevGroup) {
const lastPart = prevGroup.parts[prevGroup.parts.length - 1];
const card = createBoundaryCard(
"PREVIOUS SEASON",
lastPart.title,
lastPart.mal_id,
"fa-backward-step",
);
DOM.scroller.appendChild(card);
}
}
// -- Add Current Episodes --
eps.forEach((ep) => {
const num = ep.attributes.number;
const card = document.createElement("div");
card.className = `ep-card ${num == currentEp ? "active" : ""}`;
const thumb =
ep.attributes.thumbnail?.original || cachedAnimeData.poster;
card.innerHTML = `
<div class="ep-card-thumb">
<img src="${thumb}" loading="lazy">
${num == currentEp ? '<div class="now-playing-badge">Playing</div>' : ""}
</div>
<div class="ep-card-info">
<div class="ep-card-number">Episode ${num}</div>
<div class="ep-card-title">${ep.attributes.canonicalTitle || `Episode ${num}`}</div>
</div>
`;
card.onclick = () => switchEpisode(num);
// Register in lookup map so switchEpisode() can find this card in O(1)
epCards[num] = card;
DOM.scroller.appendChild(card);
// Auto-scroll to active
if (num == currentEp) {
setTimeout(
() =>
card.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
}),
500,
);
}
});
// -- Add "Next Season" Card if applicable --
if (
currentGroup &&
currentGroup.season_index < seasonData.season_groups.length
) {
const nextGroup = seasonData.season_groups.find(
(g) => g.season_index == currentGroup.season_index + 1,
);
if (nextGroup) {
const firstPart = nextGroup.parts[0];
const card = createBoundaryCard(
"NEXT SEASON",
firstPart.title,
firstPart.mal_id,
"fa-forward-step",
);
DOM.scroller.appendChild(card);
}
}
document.getElementById("scroller-count").textContent =
`${eps.length} Episodes`;
// Auto-locate on initial load so the active card is visible + highlighted
setTimeout(locateCurrentEpisode, 400);
} catch (e) {
DOM.scroller.innerHTML =
'<p style="color:var(--text-secondary); font-size:0.8rem;">Episode list unavailable.</p>';
}
}
function createBoundaryCard(label, title, id, icon) {
const div = document.createElement("div");
div.className = "boundary-card";
div.innerHTML = `
<i class="fas ${icon}"></i>
<div class="boundary-label">${label}</div>
<div class="boundary-title">${title}</div>
`;
div.onclick = () => {
window.location.href = `view.html?id=${id}&ep=1`;
};
return div;
}
function logProgress() {
// Log to local storage for series-info.html integration
let history = JSON.parse(
localStorage.getItem("animex_watch_history") || "{}",
);
if (!history[jikanId]) history[jikanId] = {};
// Initial state
if (!history[jikanId][currentEp]) {
history[jikanId][currentEp] = {
timestamp: 0,
duration: 0,
state: "watching",
};
localStorage.setItem("animex_watch_history", JSON.stringify(history));
}
}
// Listen for messages from the player iframe (e.g. "next episode" button inside player)
window.addEventListener("message", (event) => {
if (event.data && event.data.type === "animex-next-episode") {
switchEpisode(currentEp + 1);
}
});
</script>
</body>
</html>