feat: integrate loading state into player init and remove metadata fetch

This commit is contained in:
2026-07-06 07:09:56 +02:00
parent 7215b1a4ee
commit 413759c454
2 changed files with 71 additions and 61 deletions

View File

@@ -1,31 +0,0 @@
import { state } from "../state";
/** Fetches episode metadata from API and updates episode-card titles. */
export const setupEpisodeMetadata = (): void => {
const { episodeList } = state.elements;
if (!episodeList) {
return;
}
fetch(`/api/watch/episodes/${state.episode.malID}/metadata`)
.then((res) => res.json())
.then((data: Array<{ mal_id: number; title?: string }>) => {
data.forEach((item) => {
const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) {
return;
}
if (item.title) {
const titleEl = card.querySelector("[data-episode-title]");
if (titleEl) {
titleEl.textContent = item.title;
}
}
});
})
.catch((error) => {
window.showToast?.({ message: "Failed to load episode metadata." });
console.error("failed to load episode metadata:", error);
});
};

View File

@@ -1,10 +1,10 @@
import { onHtmxLoad, onReady } from "../utils"; import { onHtmxLoad, onReady } from "../utils";
import { setupControls, showControls } from "./controls"; import { setupControls, showControls } from "./controls";
import { formatTime } from "./controls"; import { formatTime } from "./controls";
import { setupEpisodeMetadata } from "./episodes/metadata";
import { goToNextEpisode, prefetchNextEpisode, setupEpisodeNavigation } from "./episodes/nav"; import { goToNextEpisode, prefetchNextEpisode, setupEpisodeNavigation } from "./episodes/nav";
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui"; import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui";
import { setupKeyboard } from "./keyboard"; import { setupKeyboard } from "./keyboard";
import { setPlayerLoadState, setupPlayerLoading, teardownPlayerLoading } from "./loading";
import { import {
ensurePreferredModeSource, ensurePreferredModeSource,
hydrateAlternateMode, hydrateAlternateMode,
@@ -16,7 +16,7 @@ import { setupQuality, updateQualityOptions } from "./quality";
import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip"; import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
import { setupSegmentEditor } from "./skip/editor"; import { setupSegmentEditor } from "./skip/editor";
import { resolveActiveSegments, renderSegments } from "./skip/segments"; import { resolveActiveSegments, renderSegments } from "./skip/segments";
import { refreshCurrentModeSource } from "./source"; import { refreshCurrentModeSource, resolveCurrentModeSource } from "./source";
import { state, initState, showEndState, hideEndState } from "./state"; import { state, initState, showEndState, hideEndState } from "./state";
import { safeLocalStorage } from "./storage"; import { safeLocalStorage } from "./storage";
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles"; import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
@@ -64,9 +64,10 @@ const showPreviewPopover = (): void => {
}; };
const teardownPlayer = (): void => { const teardownPlayer = (): void => {
destroyVideoSource();
cleanup?.(); cleanup?.();
cleanup = null; cleanup = null;
teardownPlayerLoading();
destroyVideoSource();
currentContainer = null; currentContainer = null;
}; };
@@ -119,10 +120,21 @@ const initPlayer = async (): Promise<void> => {
currentContainer = container; currentContainer = container;
const abortController = new AbortController(); const abortController = new AbortController();
const { signal } = abortController; const { signal } = abortController;
cleanup = null; let searchDebounce: number | undefined;
cleanup = () => {
clearTimeout(searchDebounce);
abortController.abort();
};
const loading = container.querySelector("[data-loading]") as HTMLElement | null;
const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null; const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null;
let sourceRefreshInFlight = false;
let automaticSourceRefreshAttempted = false;
setupPlayerLoading(() => {
automaticSourceRefreshAttempted = true;
return refreshCurrentModeSource(signal);
}, signal);
setPlayerLoadState("resolving_source");
const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => { const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => {
if (!progressWrap) { if (!progressWrap) {
@@ -167,12 +179,37 @@ const initPlayer = async (): Promise<void> => {
message: "Playback is unavailable for this episode.", message: "Playback is unavailable for this episode.",
variant: "destructive", variant: "destructive",
}); });
setPlayerLoadState("unavailable");
} else {
resolveCurrentModeSource(signal)
.then((resolved) => {
if (!resolved) {
setPlayerLoadState("unavailable");
return;
}
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
resolveActiveSegments();
renderSegments();
if (state.playback.modeSwitchedFrom === "dub" && state.playback.currentMode === "sub") {
window.showToast?.({
message: `Episode ${state.episode.current} is only available in sub, switched from dub.`,
});
}
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("failed to resolve initial video source:", error);
setPlayerLoadState("unavailable");
});
} }
return; } else {
await ensurePreferredModeSource(signal);
} }
await ensurePreferredModeSource(signal);
const resumeAfterModeSwitch = (() => { const resumeAfterModeSwitch = (() => {
try { try {
const raw = sessionStorage.getItem("mal:resume-after-mode-switch"); const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
@@ -190,12 +227,14 @@ const initPlayer = async (): Promise<void> => {
const initialStartTime = resumeAfterModeSwitch ?? state.playback.startTimeSeconds; const initialStartTime = resumeAfterModeSwitch ?? state.playback.startTimeSeconds;
// build video src from mode, token, and saved quality preference // build video src from mode, token, and saved quality preference
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; if (hasPlayableSource) {
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token; const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
if (streamToken) { const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
const source = state.playback.modeSources[state.playback.currentMode]; 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)}` : ""}`; if (streamToken) {
loadVideoSource(url, source?.type, initialStartTime); 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, initialStartTime);
}
} }
updateSubtitleOptions(); updateSubtitleOptions();
@@ -209,9 +248,7 @@ const initPlayer = async (): Promise<void> => {
} }
const onLoadedMetadata = (): void => { const onLoadedMetadata = (): void => {
if (loading) { setPlayerLoadState("ready");
loading.style.display = "none";
}
invalidateBounds(); invalidateBounds();
resolveActiveSegments(); resolveActiveSegments();
@@ -268,35 +305,46 @@ const initPlayer = async (): Promise<void> => {
state.elements.video.addEventListener( state.elements.video.addEventListener(
"waiting", "waiting",
() => { () => {
if (loading) { setPlayerLoadState(
loading.style.display = "flex"; state.elements.video.readyState >= HTMLMediaElement.HAVE_METADATA
} ? "buffering"
: "loading_media",
);
}, },
{ signal }, { signal },
); );
state.elements.video.addEventListener( state.elements.video.addEventListener(
"playing", "playing",
() => { () => {
if (loading) { setPlayerLoadState("ready");
loading.style.display = "none";
}
}, },
{ signal }, { signal },
); );
let sourceRefreshInFlight = false;
state.elements.video.addEventListener( state.elements.video.addEventListener(
"error", "error",
() => { () => {
if (sourceRefreshInFlight || state.episode.transitionEpisode !== null) { if (sourceRefreshInFlight || state.episode.transitionEpisode !== null) {
return; return;
} }
if (automaticSourceRefreshAttempted) {
setPlayerLoadState("unavailable");
return;
}
automaticSourceRefreshAttempted = true;
sourceRefreshInFlight = true; sourceRefreshInFlight = true;
setPlayerLoadState("retrying");
refreshCurrentModeSource(signal) refreshCurrentModeSource(signal)
.then((refreshed) => {
if (!refreshed) {
setPlayerLoadState("unavailable");
}
})
.catch((error) => { .catch((error) => {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
return; return;
} }
console.error("failed to refresh video source:", error); console.error("failed to refresh video source:", error);
setPlayerLoadState("unavailable");
}) })
.finally(() => { .finally(() => {
sourceRefreshInFlight = false; sourceRefreshInFlight = false;
@@ -397,7 +445,6 @@ const initPlayer = async (): Promise<void> => {
const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null; const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null;
const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null; const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null;
let searchDebounce: number | undefined;
if (searchInput) { if (searchInput) {
searchInput.addEventListener( searchInput.addEventListener(
@@ -454,7 +501,6 @@ const initPlayer = async (): Promise<void> => {
switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100)); switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100));
} }
setupEpisodeMetadata();
window.setTimeout(() => { window.setTimeout(() => {
if (!signal.aborted) { if (!signal.aborted) {
hydrateAlternateMode(signal).catch((error) => { hydrateAlternateMode(signal).catch((error) => {
@@ -473,11 +519,6 @@ const initPlayer = async (): Promise<void> => {
}, },
{ signal }, { signal },
); );
cleanup = () => {
clearTimeout(searchDebounce);
abortController.abort();
};
}; };
onReady(() => { onReady(() => {