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