refactor: group episode state
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
import { state } from "../state";
|
import { state } from "../state";
|
||||||
|
|
||||||
export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||||
if (state.completionSent || !state.malID || !episodeNumber) return;
|
if (state.episode.completionSent || !state.episode.malID || !episodeNumber) return;
|
||||||
state.completionSent = true;
|
state.episode.completionSent = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/watch-complete", {
|
const res = await fetch("/api/watch-complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
|
body: JSON.stringify({ mal_id: state.episode.malID, episode: episodeNumber }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
state.completionSent = false;
|
state.episode.completionSent = false;
|
||||||
if (state.completionAttempts < 2) {
|
if (state.episode.completionAttempts < 2) {
|
||||||
state.completionAttempts++;
|
state.episode.completionAttempts++;
|
||||||
setTimeout(() => completeAnime(episodeNumber), 1000);
|
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -30,9 +30,9 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
|||||||
trigger.appendChild(caret);
|
trigger.appendChild(caret);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
state.completionSent = false;
|
state.episode.completionSent = false;
|
||||||
if (state.completionAttempts < 2) {
|
if (state.episode.completionAttempts < 2) {
|
||||||
state.completionAttempts++;
|
state.episode.completionAttempts++;
|
||||||
setTimeout(() => completeAnime(episodeNumber), 1000);
|
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { loadVideoSource } from "../video";
|
|||||||
* Fetches episode data from API, updates player state and URL.
|
* Fetches episode data from API, updates player state and URL.
|
||||||
*/
|
*/
|
||||||
export const goToNextEpisode = async (): Promise<void> => {
|
export const goToNextEpisode = async (): Promise<void> => {
|
||||||
const currentEp = Number.parseInt(state.currentEpisode, 10);
|
const currentEp = Number.parseInt(state.episode.current, 10);
|
||||||
if (!currentEp) return;
|
if (!currentEp) return;
|
||||||
|
|
||||||
const navigateToEpisode = (episode: number): void => {
|
const navigateToEpisode = (episode: number): void => {
|
||||||
@@ -30,8 +30,8 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// final episode: trigger completion flow or just stop if airing
|
// final episode: trigger completion flow or just stop if airing
|
||||||
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
|
if (state.episode.total > 0 && currentEp >= state.episode.total) {
|
||||||
if (!state.isAiring) {
|
if (!state.episode.isAiring) {
|
||||||
void completeAnime(currentEp);
|
void completeAnime(currentEp);
|
||||||
}
|
}
|
||||||
showEndState();
|
showEndState();
|
||||||
@@ -49,7 +49,7 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/watch/episode/${state.malID}/${nextEp}?mode=${encodeURIComponent(state.currentMode)}`,
|
`/api/watch/episode/${state.episode.malID}/${nextEp}?mode=${encodeURIComponent(state.playback.currentMode)}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// fallback: full page navigation
|
// fallback: full page navigation
|
||||||
@@ -60,20 +60,20 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// update state with new episode data
|
// update state with new episode data
|
||||||
state.modeSources = data.mode_sources ?? {};
|
state.playback.modeSources = data.mode_sources ?? {};
|
||||||
|
|
||||||
const backendMode = typeof data.initial_mode === "string" ? data.initial_mode : "";
|
const backendMode = typeof data.initial_mode === "string" ? data.initial_mode : "";
|
||||||
const fallback = state.modeSources[backendMode]?.token
|
const fallback = state.playback.modeSources[backendMode]?.token
|
||||||
? backendMode
|
? backendMode
|
||||||
: state.availableModes.find((m) => state.modeSources[m]?.token);
|
: state.playback.availableModes.find((m) => state.playback.modeSources[m]?.token);
|
||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
fallbackToEpisodeNavigation(nextEp);
|
fallbackToEpisodeNavigation(nextEp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.currentEpisode = String(nextEp);
|
state.episode.current = String(nextEp);
|
||||||
state.currentMode = fallback;
|
state.playback.currentMode = fallback;
|
||||||
state.endedProgressSaved = false;
|
state.episode.endedProgressSaved = false;
|
||||||
|
|
||||||
hideEndState();
|
hideEndState();
|
||||||
|
|
||||||
@@ -83,34 +83,34 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The progress reset is sent asynchronously, so do not trust the fetch to observe it first.
|
// The progress reset is sent asynchronously, so do not trust the fetch to observe it first.
|
||||||
state.startTimeSeconds = 0;
|
state.playback.startTimeSeconds = 0;
|
||||||
state.container.dataset.currentEpisode = state.currentEpisode;
|
state.elements.container.dataset.currentEpisode = state.episode.current;
|
||||||
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds);
|
state.elements.container.dataset.startTimeSeconds = String(state.playback.startTimeSeconds);
|
||||||
|
|
||||||
// load new video (keep preferences)
|
// load new video (keep preferences)
|
||||||
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
||||||
const source = state.modeSources[fallback];
|
const source = state.playback.modeSources[fallback];
|
||||||
const nextSourceURL = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(source.token)}${source.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
const nextSourceURL = `${state.playback.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(source.token)}${source.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
||||||
loadVideoSource(nextSourceURL, source.type);
|
loadVideoSource(nextSourceURL, source.type);
|
||||||
if (!state.video.paused) {
|
if (!state.elements.video.paused) {
|
||||||
state.video.play().catch(() => undefined);
|
state.elements.video.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pendingSeekTime = null;
|
state.playback.pendingSeekTime = null;
|
||||||
state.completionSent = false;
|
state.episode.completionSent = false;
|
||||||
state.completionAttempts = 0;
|
state.episode.completionAttempts = 0;
|
||||||
state.activeSubtitles = [];
|
state.subtitles.activeCues = [];
|
||||||
|
|
||||||
// update UI
|
// update UI
|
||||||
updateSubtitleOptions();
|
updateSubtitleOptions();
|
||||||
updateQualityOptions();
|
updateQualityOptions();
|
||||||
updateModeButtons();
|
updateModeButtons();
|
||||||
updateOverlay(state.currentEpisode, data.episode_title ?? "");
|
updateOverlay(state.episode.current, data.episode_title ?? "");
|
||||||
void hydrateAlternateMode();
|
void hydrateAlternateMode();
|
||||||
|
|
||||||
// update skip segments
|
// update skip segments
|
||||||
if (data.segments?.length) {
|
if (data.segments?.length) {
|
||||||
state.parsedSegments = data.segments
|
state.skip.parsedSegments = data.segments
|
||||||
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||||
.filter((s: SkipSegment) => s.end > s.start);
|
.filter((s: SkipSegment) => s.end > s.start);
|
||||||
resolveActiveSegments();
|
resolveActiveSegments();
|
||||||
@@ -118,18 +118,18 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// highlight new episode in list/grid
|
// highlight new episode in list/grid
|
||||||
state.episodeList
|
state.elements.episodeList
|
||||||
?.querySelectorAll("[data-episode-id]")
|
?.querySelectorAll("[data-episode-id]")
|
||||||
.forEach((el) => el.classList.remove("bg-accent/20"));
|
.forEach((el) => el.classList.remove("bg-accent/20"));
|
||||||
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
|
const newListEl = state.elements.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||||
newListEl?.classList.add("bg-accent/20");
|
newListEl?.classList.add("bg-accent/20");
|
||||||
|
|
||||||
if (state.episodeGrid) {
|
if (state.elements.episodeGrid) {
|
||||||
state.episodeGrid.querySelectorAll("[data-episode-id]").forEach((el) => {
|
state.elements.episodeGrid.querySelectorAll("[data-episode-id]").forEach((el) => {
|
||||||
el.classList.remove("bg-accent/20", "ring-2", "ring-accent", "text-accent");
|
el.classList.remove("bg-accent/20", "ring-2", "ring-accent", "text-accent");
|
||||||
});
|
});
|
||||||
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
|
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
|
||||||
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
|
const newGridEl = state.elements.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||||
newGridEl?.classList.add("bg-accent/20", "ring-2", "ring-accent", "text-accent");
|
newGridEl?.classList.add("bg-accent/20", "ring-2", "ring-accent", "text-accent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { state } from "../state";
|
|||||||
* Injects images into episode cards, replaces placeholder.
|
* Injects images into episode cards, replaces placeholder.
|
||||||
*/
|
*/
|
||||||
export const setupThumbnails = (): void => {
|
export const setupThumbnails = (): void => {
|
||||||
const episodeList = state.episodeList;
|
const episodeList = state.elements.episodeList;
|
||||||
if (!episodeList) return;
|
if (!episodeList) return;
|
||||||
|
|
||||||
fetch(`/api/watch/thumbnails/${state.malID}`)
|
fetch(`/api/watch/thumbnails/${state.episode.malID}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data: { mal_id: number; url: string; title?: string }[]) => {
|
.then((data: { mal_id: number; url: string; title?: string }[]) => {
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ export const isAutoplayEnabled = (): boolean =>
|
|||||||
* Updates video overlay text (shown briefly on episode change).
|
* Updates video overlay text (shown briefly on episode change).
|
||||||
*/
|
*/
|
||||||
export const updateOverlay = (episode: string, title: string): void => {
|
export const updateOverlay = (episode: string, title: string): void => {
|
||||||
if (!state.videoOverlay) return;
|
if (!state.elements.videoOverlay) return;
|
||||||
const p = state.videoOverlay.querySelector("p");
|
const p = state.elements.videoOverlay.querySelector("p");
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`;
|
p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// helper: get all episode elements from grid and list
|
// helper: get all episode elements from grid and list
|
||||||
const getEpisodeEls = () => {
|
const getEpisodeEls = () => {
|
||||||
const grid = state.episodeGrid;
|
const grid = state.elements.episodeGrid;
|
||||||
const list = state.episodeList;
|
const list = state.elements.episodeList;
|
||||||
return {
|
return {
|
||||||
gridEls: grid ? Array.from(grid.querySelectorAll("[data-episode-id]")) : [],
|
gridEls: grid ? Array.from(grid.querySelectorAll("[data-episode-id]")) : [],
|
||||||
listEls: list ? Array.from(list.querySelectorAll("[data-episode-id]")) : [],
|
listEls: list ? Array.from(list.querySelectorAll("[data-episode-id]")) : [],
|
||||||
@@ -47,8 +47,8 @@ export const updateEpisodeHighlight = (num: number): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// apply new highlight
|
// apply new highlight
|
||||||
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
|
const gridEl = state.elements.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
|
||||||
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
|
const listEl = state.elements.episodeList?.querySelector(`[data-episode-id="${num}"]`);
|
||||||
gridEl?.classList.add("ring-2", "ring-accent");
|
gridEl?.classList.add("ring-2", "ring-accent");
|
||||||
listEl?.classList.add("ring-2", "ring-accent");
|
listEl?.classList.add("ring-2", "ring-accent");
|
||||||
// scroll into view
|
// scroll into view
|
||||||
@@ -75,7 +75,7 @@ export const switchEpisodeRange = (idx: number): void => {
|
|||||||
label.textContent = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
|
label.textContent = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
|
||||||
|
|
||||||
// show/hide episodes in range
|
// show/hide episodes in range
|
||||||
state.episodeGrid?.querySelectorAll("[data-episode-id]").forEach((el) => {
|
state.elements.episodeGrid?.querySelectorAll("[data-episode-id]").forEach((el) => {
|
||||||
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? "0", 10);
|
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? "0", 10);
|
||||||
el.classList.toggle("hidden", n < start || n > end);
|
el.classList.toggle("hidden", n < start || n > end);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hidePreviewPopover = (): void => {
|
const hidePreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.elements.previewPopover) return;
|
||||||
state.previewPopover.classList.add("hidden");
|
state.elements.previewPopover.classList.add("hidden");
|
||||||
state.previewPopover.classList.add("opacity-0");
|
state.elements.previewPopover.classList.add("opacity-0");
|
||||||
state.previewPopover.classList.remove("opacity-100");
|
state.elements.previewPopover.classList.remove("opacity-100");
|
||||||
state.previewPopover.style.left = "";
|
state.elements.previewPopover.style.left = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPreviewPopover = (): void => {
|
const showPreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.elements.previewPopover) return;
|
||||||
state.previewPopover.classList.remove("hidden");
|
state.elements.previewPopover.classList.remove("hidden");
|
||||||
state.previewPopover.classList.remove("opacity-0");
|
state.elements.previewPopover.classList.remove("opacity-0");
|
||||||
state.previewPopover.classList.add("opacity-100");
|
state.elements.previewPopover.classList.add("opacity-100");
|
||||||
};
|
};
|
||||||
|
|
||||||
const teardownPlayer = (): void => {
|
const teardownPlayer = (): void => {
|
||||||
@@ -58,8 +58,10 @@ const teardownPlayer = (): void => {
|
|||||||
|
|
||||||
// updates time preview on progress bar hover
|
// updates time preview on progress bar hover
|
||||||
const updatePreviewUI = (ratio: number): void => {
|
const updatePreviewUI = (ratio: number): void => {
|
||||||
const progressWrap = state.container.querySelector("[data-progress-wrap]") as HTMLElement | null;
|
const progressWrap = state.elements.container.querySelector(
|
||||||
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
"[data-progress-wrap]",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!progressWrap || !state.elements.previewPopover || !state.elements.previewTime) {
|
||||||
hidePreviewPopover();
|
hidePreviewPopover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,9 @@ const updatePreviewUI = (ratio: number): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// show time for hovered position
|
// show time for hovered position
|
||||||
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
|
state.elements.previewTime.textContent = formatTime(
|
||||||
|
Math.max(0, Math.min(b.duration, ratio * b.duration)),
|
||||||
|
);
|
||||||
|
|
||||||
const barWidth = progressWrap.clientWidth;
|
const barWidth = progressWrap.clientWidth;
|
||||||
if (barWidth <= 0) {
|
if (barWidth <= 0) {
|
||||||
@@ -80,8 +84,8 @@ const updatePreviewUI = (ratio: number): void => {
|
|||||||
|
|
||||||
showPreviewPopover();
|
showPreviewPopover();
|
||||||
// clamp to stay within bar bounds
|
// clamp to stay within bar bounds
|
||||||
const popoverWidth = state.previewPopover.offsetWidth || 72;
|
const popoverWidth = state.elements.previewPopover.offsetWidth || 72;
|
||||||
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
state.elements.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initPlayer = (): void => {
|
const initPlayer = (): void => {
|
||||||
@@ -105,11 +109,11 @@ const initPlayer = (): void => {
|
|||||||
const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => {
|
const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => {
|
||||||
if (!progressWrap) return;
|
if (!progressWrap) return;
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(
|
state.elements.video.currentTime = absoluteTimeFromRatio(
|
||||||
Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)),
|
Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)),
|
||||||
);
|
);
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.elements.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.elements.video.currentTime);
|
||||||
if (shouldShowControls) {
|
if (shouldShowControls) {
|
||||||
showControls();
|
showControls();
|
||||||
}
|
}
|
||||||
@@ -117,10 +121,10 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
// 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";
|
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
||||||
const streamToken = state.modeSources[state.currentMode]?.token;
|
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
|
||||||
if (streamToken) {
|
if (streamToken) {
|
||||||
const source = state.modeSources[state.currentMode];
|
const source = state.playback.modeSources[state.playback.currentMode];
|
||||||
const url = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
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);
|
loadVideoSource(url, source?.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +143,9 @@ const initPlayer = (): void => {
|
|||||||
setupAutoplayButton();
|
setupAutoplayButton();
|
||||||
updateAutoSkipButton();
|
updateAutoSkipButton();
|
||||||
showControls();
|
showControls();
|
||||||
if (state.modeSwitchedFrom === "dub" && state.currentMode === "sub") {
|
if (state.playback.modeSwitchedFrom === "dub" && state.playback.currentMode === "sub") {
|
||||||
window.showToast?.({
|
window.showToast?.({
|
||||||
message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`,
|
message: `Episode ${state.episode.current} is only available in sub, switched from dub.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +159,7 @@ const initPlayer = (): void => {
|
|||||||
renderSegments();
|
renderSegments();
|
||||||
|
|
||||||
// Resume from saved position
|
// Resume from saved position
|
||||||
const startTime = state.startTimeSeconds;
|
const startTime = state.playback.startTimeSeconds;
|
||||||
const bounds = getBounds();
|
const bounds = getBounds();
|
||||||
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
||||||
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
||||||
@@ -176,31 +180,31 @@ const initPlayer = (): void => {
|
|||||||
if (resumeAfterModeSwitch !== null) {
|
if (resumeAfterModeSwitch !== null) {
|
||||||
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
||||||
if (clamped > 0) {
|
if (clamped > 0) {
|
||||||
state.video.currentTime = clamped;
|
state.elements.video.currentTime = clamped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startTime > 0 && state.video.currentTime <= 2) {
|
if (startTime > 0 && state.elements.video.currentTime <= 2) {
|
||||||
if (resumeTime > 0) {
|
if (resumeTime > 0) {
|
||||||
state.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
state.elements.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// resume after mode switch
|
// resume after mode switch
|
||||||
if (state.pendingSeekTime !== null) {
|
if (state.playback.pendingSeekTime !== null) {
|
||||||
state.video.currentTime = absoluteTimeFromDisplay(state.pendingSeekTime);
|
state.elements.video.currentTime = absoluteTimeFromDisplay(state.playback.pendingSeekTime);
|
||||||
state.pendingSeekTime = null;
|
state.playback.pendingSeekTime = null;
|
||||||
}
|
}
|
||||||
if (state.transitionEpisode === Number.parseInt(state.currentEpisode, 10)) {
|
if (state.episode.transitionEpisode === Number.parseInt(state.episode.current, 10)) {
|
||||||
state.transitionEpisode = null;
|
state.episode.transitionEpisode = null;
|
||||||
}
|
}
|
||||||
// autoplay if not already playing (inline script may have already called play())
|
// autoplay if not already playing (inline script may have already called play())
|
||||||
// but don't autoplay if we've reached the end
|
// but don't autoplay if we've reached the end
|
||||||
if (!isAtEnd && (state.shouldAutoPlay || state.video.paused)) {
|
if (!isAtEnd && (state.playback.shouldAutoPlay || state.elements.video.paused)) {
|
||||||
state.video.play().catch(() => undefined);
|
state.elements.video.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.elements.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.elements.video.currentTime);
|
||||||
|
|
||||||
// Apply end-state visuals if we resumed at the end
|
// Apply end-state visuals if we resumed at the end
|
||||||
if (isAtEnd) {
|
if (isAtEnd) {
|
||||||
@@ -208,12 +212,12 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
|
state.elements.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
|
||||||
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
if (state.elements.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
onLoadedMetadata();
|
onLoadedMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
state.video.addEventListener(
|
state.elements.video.addEventListener(
|
||||||
"waiting",
|
"waiting",
|
||||||
() => {
|
() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -222,7 +226,7 @@ const initPlayer = (): void => {
|
|||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
state.video.addEventListener(
|
state.elements.video.addEventListener(
|
||||||
"playing",
|
"playing",
|
||||||
() => {
|
() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -232,31 +236,31 @@ const initPlayer = (): void => {
|
|||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
// update progress bar during buffering
|
// update progress bar during buffering
|
||||||
state.video.addEventListener(
|
state.elements.video.addEventListener(
|
||||||
"progress",
|
"progress",
|
||||||
() => {
|
() => {
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.elements.video.currentTime);
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// main loop: update progress, subtitles, skip buttons
|
// main loop: update progress, subtitles, skip buttons
|
||||||
state.video.addEventListener(
|
state.elements.video.addEventListener(
|
||||||
"timeupdate",
|
"timeupdate",
|
||||||
() => {
|
() => {
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.elements.video.currentTime);
|
||||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
updateSubtitleRender(displayTimeFromAbsolute(state.elements.video.currentTime));
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.elements.video.currentTime);
|
||||||
|
|
||||||
// Restore visibility if we've moved away from the end
|
// Restore visibility if we've moved away from the end
|
||||||
if (state.video.currentTime < state.video.duration - 1) {
|
if (state.elements.video.currentTime < state.elements.video.duration - 1) {
|
||||||
hideEndState();
|
hideEndState();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
state.video.addEventListener(
|
state.elements.video.addEventListener(
|
||||||
"ended",
|
"ended",
|
||||||
async () => {
|
async () => {
|
||||||
await saveEndedProgress();
|
await saveEndedProgress();
|
||||||
@@ -271,7 +275,7 @@ const initPlayer = (): void => {
|
|||||||
(e) => {
|
(e) => {
|
||||||
// ignore right/middle click
|
// ignore right/middle click
|
||||||
if ("button" in e && e.button !== 0) return;
|
if ("button" in e && e.button !== 0) return;
|
||||||
state.isScrubbing = true;
|
state.ui.isScrubbing = true;
|
||||||
try {
|
try {
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -298,7 +302,7 @@ const initPlayer = (): void => {
|
|||||||
() => {
|
() => {
|
||||||
// ensure we finish the seek even if no window mousemove fired
|
// ensure we finish the seek even if no window mousemove fired
|
||||||
if (!progressWrap) return;
|
if (!progressWrap) return;
|
||||||
state.isScrubbing = false;
|
state.ui.isScrubbing = false;
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
@@ -307,7 +311,7 @@ const initPlayer = (): void => {
|
|||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"pointermove",
|
"pointermove",
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!state.isScrubbing || !progressWrap) return;
|
if (!state.ui.isScrubbing || !progressWrap) return;
|
||||||
scrubToPointer(e.clientX, false);
|
scrubToPointer(e.clientX, false);
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
@@ -325,15 +329,15 @@ const initPlayer = (): void => {
|
|||||||
if (url.origin !== location.origin) return;
|
if (url.origin !== location.origin) return;
|
||||||
const parts = url.pathname.split("/").filter(Boolean);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
if (parts[0] !== "anime" || parts[2] !== "watch") return;
|
if (parts[0] !== "anime" || parts[2] !== "watch") return;
|
||||||
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
if (Number.parseInt(parts[1], 10) !== state.episode.malID) return;
|
||||||
const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
|
const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
|
||||||
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
const currentEpisode = Number.parseInt(state.episode.current, 10);
|
||||||
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
state.video.addEventListener("click", showControls, { signal });
|
state.elements.video.addEventListener("click", showControls, { signal });
|
||||||
|
|
||||||
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;
|
||||||
@@ -349,17 +353,17 @@ const initPlayer = (): void => {
|
|||||||
const val = searchInput.value.replace(/\D/g, "");
|
const val = searchInput.value.replace(/\D/g, "");
|
||||||
if (!val) {
|
if (!val) {
|
||||||
// clear: jump to current episode range
|
// clear: jump to current episode range
|
||||||
const cur = Number.parseInt(state.currentEpisode, 10);
|
const cur = Number.parseInt(state.episode.current, 10);
|
||||||
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
||||||
updateEpisodeHighlight(cur);
|
updateEpisodeHighlight(cur);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ep = Number.parseInt(val, 10);
|
const ep = Number.parseInt(val, 10);
|
||||||
if (!ep || ep <= 0) return;
|
if (!ep || ep <= 0) return;
|
||||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
const maxEp = state.episode.total > 0 ? state.episode.total : 500;
|
||||||
const clamped = Math.min(ep, maxEp);
|
const clamped = Math.min(ep, maxEp);
|
||||||
searchInput.value = String(clamped);
|
searchInput.value = String(clamped);
|
||||||
if (state.episodeGrid) {
|
if (state.elements.episodeGrid) {
|
||||||
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
||||||
updateEpisodeHighlight(clamped);
|
updateEpisodeHighlight(clamped);
|
||||||
}
|
}
|
||||||
@@ -386,8 +390,8 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initial range for large episode lists
|
// initial range for large episode lists
|
||||||
if (state.episodeGrid && state.totalEpisodes > 100) {
|
if (state.elements.episodeGrid && state.episode.total > 100) {
|
||||||
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100));
|
switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
setupThumbnails();
|
setupThumbnails();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const snapToEnd = (time: number): number => {
|
|||||||
// builds JSON payload for progress API
|
// builds JSON payload for progress API
|
||||||
const buildPayload = (episode: number, seconds: number): string =>
|
const buildPayload = (episode: number, seconds: number): string =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
mal_id: state.malID,
|
mal_id: state.episode.malID,
|
||||||
episode,
|
episode,
|
||||||
time_seconds: seconds,
|
time_seconds: seconds,
|
||||||
});
|
});
|
||||||
@@ -26,7 +26,7 @@ const sendBeacon = (payload: string): boolean => {
|
|||||||
|
|
||||||
let saveProgressInFlight: Promise<void> | null = null;
|
let saveProgressInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
const currentProgressTime = (): number => displayTimeFromAbsolute(state.video.currentTime);
|
const currentProgressTime = (): number => displayTimeFromAbsolute(state.elements.video.currentTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves current progress to backend.
|
* Saves current progress to backend.
|
||||||
@@ -42,17 +42,18 @@ export const saveProgress = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = (async (): Promise<void> => {
|
const request = (async (): Promise<void> => {
|
||||||
if (state.endedProgressSaved && !force) return;
|
if (state.episode.endedProgressSaved && !force) return;
|
||||||
if (state.transitionEpisode !== null || !state.malID || progressSeconds < 1) return;
|
if (state.episode.transitionEpisode !== null || !state.episode.malID || progressSeconds < 1)
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
return;
|
||||||
|
const episode = Number.parseInt(state.episode.current, 10);
|
||||||
if (!episode) return;
|
if (!episode) return;
|
||||||
|
|
||||||
const savedTime = snapToEnd(progressSeconds);
|
const savedTime = snapToEnd(progressSeconds);
|
||||||
// skip if recently saved, unless forced
|
// skip if recently saved, unless forced
|
||||||
if (
|
if (
|
||||||
!force &&
|
!force &&
|
||||||
state.lastSavedProgress.episode === state.currentEpisode &&
|
state.episode.lastSavedProgress.episode === state.episode.current &&
|
||||||
Math.abs(state.lastSavedProgress.seconds - savedTime) < 5
|
Math.abs(state.episode.lastSavedProgress.seconds - savedTime) < 5
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,8 +66,8 @@ export const saveProgress = async (
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
state.lastSavedProgress = {
|
state.episode.lastSavedProgress = {
|
||||||
episode: state.currentEpisode,
|
episode: state.episode.current,
|
||||||
seconds: savedTime,
|
seconds: savedTime,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -86,9 +87,9 @@ export const saveProgress = async (
|
|||||||
|
|
||||||
// schedules periodic save every 30s during playback
|
// schedules periodic save every 30s during playback
|
||||||
const scheduleProgressSave = (): void => {
|
const scheduleProgressSave = (): void => {
|
||||||
if (state.progressSaveTimer !== undefined) return;
|
if (state.timers.progressSaveTimer !== undefined) return;
|
||||||
state.progressSaveTimer = window.setTimeout(() => {
|
state.timers.progressSaveTimer = window.setTimeout(() => {
|
||||||
state.progressSaveTimer = undefined;
|
state.timers.progressSaveTimer = undefined;
|
||||||
saveProgress();
|
saveProgress();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
@@ -98,12 +99,12 @@ const scheduleProgressSave = (): void => {
|
|||||||
* Uses beacon for reliability on page unload.
|
* Uses beacon for reliability on page unload.
|
||||||
*/
|
*/
|
||||||
export const markEpisodeTransition = (episodeNumber: number): void => {
|
export const markEpisodeTransition = (episodeNumber: number): void => {
|
||||||
if (!state.malID || !episodeNumber) return;
|
if (!state.episode.malID || !episodeNumber) return;
|
||||||
if (state.progressSaveTimer !== undefined) {
|
if (state.timers.progressSaveTimer !== undefined) {
|
||||||
window.clearTimeout(state.progressSaveTimer);
|
window.clearTimeout(state.timers.progressSaveTimer);
|
||||||
state.progressSaveTimer = undefined;
|
state.timers.progressSaveTimer = undefined;
|
||||||
}
|
}
|
||||||
state.transitionEpisode = episodeNumber;
|
state.episode.transitionEpisode = episodeNumber;
|
||||||
const payload = buildPayload(episodeNumber, 0);
|
const payload = buildPayload(episodeNumber, 0);
|
||||||
// beacon falls back to fetch with keepalive
|
// beacon falls back to fetch with keepalive
|
||||||
if (!sendBeacon(payload)) {
|
if (!sendBeacon(payload)) {
|
||||||
@@ -121,44 +122,49 @@ export const markEpisodeTransition = (episodeNumber: number): void => {
|
|||||||
*/
|
*/
|
||||||
export const setupProgress = (): void => {
|
export const setupProgress = (): void => {
|
||||||
// periodic save during playback
|
// periodic save during playback
|
||||||
state.video.addEventListener("timeupdate", () => {
|
state.elements.video.addEventListener("timeupdate", () => {
|
||||||
scheduleProgressSave();
|
scheduleProgressSave();
|
||||||
});
|
});
|
||||||
|
|
||||||
// immediate save on pause
|
// immediate save on pause
|
||||||
state.video.addEventListener("pause", () => {
|
state.elements.video.addEventListener("pause", () => {
|
||||||
window.clearTimeout(state.progressSaveTimer);
|
window.clearTimeout(state.timers.progressSaveTimer);
|
||||||
state.progressSaveTimer = undefined;
|
state.timers.progressSaveTimer = undefined;
|
||||||
if (state.endedProgressSaved) return;
|
if (state.episode.endedProgressSaved) return;
|
||||||
|
|
||||||
// If we're at the very end, force a save to ensure we don't skip the last frame
|
// If we're at the very end, force a save to ensure we don't skip the last frame
|
||||||
const isAtEnd =
|
const isAtEnd =
|
||||||
state.video.duration > 0 && Math.abs(state.video.currentTime - state.video.duration) < 1.5;
|
state.elements.video.duration > 0 &&
|
||||||
|
Math.abs(state.elements.video.currentTime - state.elements.video.duration) < 1.5;
|
||||||
|
|
||||||
saveProgress(isAtEnd);
|
saveProgress(isAtEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
// save after scrubbing
|
// save after scrubbing
|
||||||
window.addEventListener("mouseup", () => {
|
window.addEventListener("mouseup", () => {
|
||||||
state.isScrubbing = false;
|
state.ui.isScrubbing = false;
|
||||||
if (state.endedProgressSaved) return;
|
if (state.episode.endedProgressSaved) return;
|
||||||
saveProgress();
|
saveProgress();
|
||||||
});
|
});
|
||||||
|
|
||||||
// save on page close
|
// save on page close
|
||||||
window.addEventListener("beforeunload", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
if (state.endedProgressSaved || state.transitionEpisode !== null || !state.malID) {
|
if (
|
||||||
|
state.episode.endedProgressSaved ||
|
||||||
|
state.episode.transitionEpisode !== null ||
|
||||||
|
!state.episode.malID
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
const episode = Number.parseInt(state.episode.current, 10);
|
||||||
if (!episode) return;
|
if (!episode) return;
|
||||||
const time = displayTimeFromAbsolute(state.video.currentTime);
|
const time = displayTimeFromAbsolute(state.elements.video.currentTime);
|
||||||
sendBeacon(buildPayload(episode, snapToEnd(time)));
|
sendBeacon(buildPayload(episode, snapToEnd(time)));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveEndedProgress = async (): Promise<void> => {
|
export const saveEndedProgress = async (): Promise<void> => {
|
||||||
const duration = getBounds().duration;
|
const duration = getBounds().duration;
|
||||||
state.endedProgressSaved = true;
|
state.episode.endedProgressSaved = true;
|
||||||
await saveProgress(true, duration > 0 ? duration : currentProgressTime());
|
await saveProgress(true, duration > 0 ? duration : currentProgressTime());
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user