414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { state, initState, showEndState, hideEndState } from "./state";
|
|
import { invalidateBounds, updateTimeline } from "./timeline";
|
|
import { setupControls, showControls } from "./controls";
|
|
import { setupKeyboard } from "./keyboard";
|
|
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
|
|
import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
|
|
import { setupQuality, updateQualityOptions } from "./quality";
|
|
import { hydrateAlternateMode, setupMode, updateModeButtons } from "./mode";
|
|
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui";
|
|
import { goToNextEpisode } from "./episodes/nav";
|
|
import { resolveActiveSegments, renderSegments } from "./skip/segments";
|
|
import { setupSegmentEditor } from "./skip/editor";
|
|
import { setupThumbnails } from "./episodes/thumbnails";
|
|
import { markEpisodeTransition, saveEndedProgress, setupProgress } from "./progress";
|
|
import { safeLocalStorage } from "./storage";
|
|
import { destroyVideoSource, loadVideoSource } from "./video";
|
|
import {
|
|
absoluteTimeFromDisplay,
|
|
absoluteTimeFromRatio,
|
|
getBounds,
|
|
displayTimeFromAbsolute,
|
|
} from "./timeline";
|
|
import { formatTime } from "./controls";
|
|
import { onHtmxLoad, onReady } from "../utils";
|
|
|
|
let currentContainer: HTMLElement | null = null;
|
|
let cleanup: (() => void) | null = null;
|
|
|
|
type ClosableDropdown = HTMLElement & { close: () => void };
|
|
const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
|
|
if (!el) return false;
|
|
if (!(el instanceof HTMLElement)) return false;
|
|
const maybe = el as Partial<{ close: unknown }>;
|
|
return typeof maybe.close === "function";
|
|
};
|
|
|
|
const hidePreviewPopover = (): void => {
|
|
if (!state.elements.previewPopover) return;
|
|
state.elements.previewPopover.classList.add("hidden");
|
|
state.elements.previewPopover.classList.add("opacity-0");
|
|
state.elements.previewPopover.classList.remove("opacity-100");
|
|
state.elements.previewPopover.style.left = "";
|
|
};
|
|
|
|
const showPreviewPopover = (): void => {
|
|
if (!state.elements.previewPopover) return;
|
|
state.elements.previewPopover.classList.remove("hidden");
|
|
state.elements.previewPopover.classList.remove("opacity-0");
|
|
state.elements.previewPopover.classList.add("opacity-100");
|
|
};
|
|
|
|
const teardownPlayer = (): void => {
|
|
destroyVideoSource();
|
|
cleanup?.();
|
|
cleanup = null;
|
|
currentContainer = null;
|
|
};
|
|
|
|
// updates time preview on progress bar hover
|
|
const updatePreviewUI = (ratio: number): void => {
|
|
const progressWrap = state.elements.container.querySelector(
|
|
"[data-progress-wrap]",
|
|
) as HTMLElement | null;
|
|
if (!progressWrap || !state.elements.previewPopover || !state.elements.previewTime) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
const b = getBounds();
|
|
if (b.duration <= 0) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
|
|
// show time for hovered position
|
|
state.elements.previewTime.textContent = formatTime(
|
|
Math.max(0, Math.min(b.duration, ratio * b.duration)),
|
|
);
|
|
|
|
const barWidth = progressWrap.clientWidth;
|
|
if (barWidth <= 0) {
|
|
hidePreviewPopover();
|
|
return;
|
|
}
|
|
|
|
showPreviewPopover();
|
|
// clamp to stay within bar bounds
|
|
const popoverWidth = state.elements.previewPopover.offsetWidth || 72;
|
|
state.elements.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
|
};
|
|
|
|
const initPlayer = (): void => {
|
|
const container = document.querySelector("[data-video-player]") as HTMLElement | null;
|
|
if (!container) return;
|
|
if (container === currentContainer) return;
|
|
teardownPlayer();
|
|
|
|
if (!initState(container)) {
|
|
window.showToast?.({ message: "Video player markup is missing required controls." });
|
|
return;
|
|
}
|
|
currentContainer = container;
|
|
const abortController = new AbortController();
|
|
const signal = abortController.signal;
|
|
cleanup = () => abortController.abort();
|
|
|
|
const loading = container.querySelector("[data-loading]") as HTMLElement | null;
|
|
const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null;
|
|
|
|
const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => {
|
|
if (!progressWrap) return;
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
state.elements.video.currentTime = absoluteTimeFromRatio(
|
|
Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)),
|
|
);
|
|
updateTimeline(state.elements.video.currentTime);
|
|
updateSkipButton(state.elements.video.currentTime);
|
|
if (shouldShowControls) {
|
|
showControls();
|
|
}
|
|
};
|
|
|
|
// build video src from mode, token, and saved quality preference
|
|
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
|
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
|
|
if (streamToken) {
|
|
const source = state.playback.modeSources[state.playback.currentMode];
|
|
const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
|
loadVideoSource(url, source?.type);
|
|
}
|
|
|
|
setupProgress();
|
|
setupControls();
|
|
setupKeyboard();
|
|
setupSkip();
|
|
setupSegmentEditor();
|
|
setupSubtitles();
|
|
setupQuality();
|
|
setupMode();
|
|
|
|
updateSubtitleOptions();
|
|
updateQualityOptions();
|
|
updateModeButtons();
|
|
setupAutoplayButton();
|
|
updateAutoSkipButton();
|
|
showControls();
|
|
if (state.playback.modeSwitchedFrom === "dub" && state.playback.currentMode === "sub") {
|
|
window.showToast?.({
|
|
message: `Episode ${state.episode.current} is only available in sub, switched from dub.`,
|
|
});
|
|
}
|
|
|
|
const onLoadedMetadata = (): void => {
|
|
if (loading) {
|
|
loading.style.display = "none";
|
|
}
|
|
invalidateBounds();
|
|
|
|
resolveActiveSegments();
|
|
renderSegments();
|
|
|
|
// Resume from saved position
|
|
const startTime = state.playback.startTimeSeconds;
|
|
const bounds = getBounds();
|
|
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
|
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
|
|
|
// Resume after a mode-switch page reload (best effort, session-scoped).
|
|
const resumeAfterModeSwitch = (() => {
|
|
try {
|
|
const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
|
|
if (raw === null) return null;
|
|
sessionStorage.removeItem("mal:resume-after-mode-switch");
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (resumeAfterModeSwitch !== null) {
|
|
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
|
if (clamped > 0) {
|
|
state.elements.video.currentTime = clamped;
|
|
}
|
|
}
|
|
|
|
if (startTime > 0 && state.elements.video.currentTime <= 2) {
|
|
if (resumeTime > 0) {
|
|
state.elements.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
|
}
|
|
}
|
|
// resume after mode switch
|
|
if (state.playback.pendingSeekTime !== null) {
|
|
state.elements.video.currentTime = absoluteTimeFromDisplay(state.playback.pendingSeekTime);
|
|
state.playback.pendingSeekTime = null;
|
|
}
|
|
if (state.episode.transitionEpisode === Number.parseInt(state.episode.current, 10)) {
|
|
state.episode.transitionEpisode = null;
|
|
}
|
|
// autoplay if not already playing (inline script may have already called play())
|
|
// but don't autoplay if we've reached the end
|
|
if (!isAtEnd && (state.playback.shouldAutoPlay || state.elements.video.paused)) {
|
|
state.elements.video.play().catch(() => undefined);
|
|
}
|
|
|
|
updateTimeline(state.elements.video.currentTime);
|
|
updateSkipButton(state.elements.video.currentTime);
|
|
|
|
// Apply end-state visuals if we resumed at the end
|
|
if (isAtEnd) {
|
|
showEndState();
|
|
}
|
|
};
|
|
|
|
state.elements.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
|
|
if (state.elements.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
onLoadedMetadata();
|
|
}
|
|
|
|
state.elements.video.addEventListener(
|
|
"waiting",
|
|
() => {
|
|
if (loading) {
|
|
loading.style.display = "flex";
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
state.elements.video.addEventListener(
|
|
"playing",
|
|
() => {
|
|
if (loading) {
|
|
loading.style.display = "none";
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
// update progress bar during buffering
|
|
state.elements.video.addEventListener(
|
|
"progress",
|
|
() => {
|
|
updateTimeline(state.elements.video.currentTime);
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// main loop: update progress, subtitles, skip buttons
|
|
state.elements.video.addEventListener(
|
|
"timeupdate",
|
|
() => {
|
|
updateTimeline(state.elements.video.currentTime);
|
|
updateSubtitleRender(displayTimeFromAbsolute(state.elements.video.currentTime));
|
|
updateSkipButton(state.elements.video.currentTime);
|
|
|
|
// Restore visibility if we've moved away from the end
|
|
if (state.elements.video.currentTime < state.elements.video.duration - 1) {
|
|
hideEndState();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
state.elements.video.addEventListener(
|
|
"ended",
|
|
async () => {
|
|
await saveEndedProgress();
|
|
await goToNextEpisode();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
|
|
progressWrap?.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
// ignore right/middle click
|
|
if ("button" in e && e.button !== 0) return;
|
|
state.ui.isScrubbing = true;
|
|
try {
|
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
|
} catch (e) {
|
|
console.warn("Failed to capture pointer:", e);
|
|
}
|
|
scrubToPointer(e.clientX, true);
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// hover to preview time
|
|
progressWrap?.addEventListener(
|
|
"pointermove",
|
|
(e) => {
|
|
const rect = progressWrap.getBoundingClientRect();
|
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
progressWrap?.addEventListener("pointerleave", hidePreviewPopover, { signal });
|
|
progressWrap?.addEventListener(
|
|
"pointerup",
|
|
() => {
|
|
// ensure we finish the seek even if no window mousemove fired
|
|
if (!progressWrap) return;
|
|
state.ui.isScrubbing = false;
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// dragging outside progress bar while scrubbing
|
|
window.addEventListener(
|
|
"pointermove",
|
|
(e) => {
|
|
if (!state.ui.isScrubbing || !progressWrap) return;
|
|
scrubToPointer(e.clientX, false);
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// track next-episode links outside the player so they start fresh after finishing an episode
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
const target = e.target;
|
|
if (!(target instanceof Element)) return;
|
|
const anchor = target.closest("a[href]");
|
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
|
const url = new URL(anchor.href, location.origin);
|
|
if (url.origin !== location.origin) return;
|
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
if (parts[0] !== "anime" || parts[2] !== "watch") return;
|
|
if (Number.parseInt(parts[1], 10) !== state.episode.malID) return;
|
|
const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
|
|
const currentEpisode = Number.parseInt(state.episode.current, 10);
|
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
state.elements.video.addEventListener("click", showControls, { signal });
|
|
|
|
const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null;
|
|
const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null;
|
|
let searchDebounce: number | undefined;
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener(
|
|
"input",
|
|
() => {
|
|
clearTimeout(searchDebounce);
|
|
// debounce to avoid excessive range switches while typing
|
|
searchDebounce = window.setTimeout(() => {
|
|
const val = searchInput.value.replace(/\D/g, "");
|
|
if (!val) {
|
|
// clear: jump to current episode range
|
|
const cur = Number.parseInt(state.episode.current, 10);
|
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
|
updateEpisodeHighlight(cur);
|
|
return;
|
|
}
|
|
const ep = Number.parseInt(val, 10);
|
|
if (!ep || ep <= 0) return;
|
|
const maxEp = state.episode.total > 0 ? state.episode.total : 500;
|
|
const clamped = Math.min(ep, maxEp);
|
|
searchInput.value = String(clamped);
|
|
if (state.elements.episodeGrid) {
|
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
|
updateEpisodeHighlight(clamped);
|
|
}
|
|
}, 300);
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
// range buttons (100s of episodes)
|
|
if (dropdown) {
|
|
dropdown.querySelectorAll(".episode-range-btn").forEach((btn) => {
|
|
btn.addEventListener(
|
|
"click",
|
|
() => {
|
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? "0", 10);
|
|
switchEpisodeRange(idx);
|
|
const dd = btn.closest("ui-dropdown");
|
|
if (isClosableDropdown(dd)) dd.close();
|
|
},
|
|
{ signal },
|
|
);
|
|
});
|
|
}
|
|
|
|
// initial range for large episode lists
|
|
if (state.elements.episodeGrid && state.episode.total > 100) {
|
|
switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100));
|
|
}
|
|
|
|
setupThumbnails();
|
|
void hydrateAlternateMode(signal);
|
|
};
|
|
|
|
onReady(initPlayer);
|
|
onHtmxLoad((root) => {
|
|
if (root.matches("[data-video-player]") || root.querySelector("[data-video-player]")) {
|
|
initPlayer();
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener("htmx:beforeSwap", (e: Event) => {
|
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
|
if (target && currentContainer && target.contains(currentContainer)) {
|
|
teardownPlayer();
|
|
}
|
|
});
|