feat: integrate loading state into player init and remove metadata fetch
This commit is contained in:
@@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user