refactor: group player state

This commit is contained in:
2026-06-16 10:37:55 +02:00
committed by Milas Holsting
parent 4d8486e6ea
commit b569b06591
8 changed files with 260 additions and 219 deletions

View File

@@ -13,50 +13,53 @@ export const formatTime = (seconds: number): string => {
* Shows the controls overlay and schedules auto-hide after 2s if playing. * Shows the controls overlay and schedules auto-hide after 2s if playing.
*/ */
export const showControls = (): void => { export const showControls = (): void => {
state.container.classList.add("show-controls"); state.elements.container.classList.add("show-controls");
window.clearTimeout(state.playerControlsTimeout); window.clearTimeout(state.timers.playerControlsTimeout);
state.playerControlsTimeout = window.setTimeout(() => { state.timers.playerControlsTimeout = window.setTimeout(() => {
if (!state.isScrubbing && !state.video.paused) { if (!state.ui.isScrubbing && !state.elements.video.paused) {
state.container.classList.remove("show-controls"); state.elements.container.classList.remove("show-controls");
} }
}, 2000); }, 2000);
}; };
// seek relative to current position // seek relative to current position
export const seekBy = (delta: number): void => { export const seekBy = (delta: number): void => {
if (state.video.duration <= 0) return; if (state.elements.video.duration <= 0) return;
state.video.currentTime = Math.max( state.elements.video.currentTime = Math.max(
0, 0,
Math.min(state.video.duration, state.video.currentTime + delta), Math.min(state.elements.video.duration, state.elements.video.currentTime + delta),
); );
showControls(); showControls();
}; };
export const togglePlayPause = (): void => { export const togglePlayPause = (): void => {
if (state.video.paused) { if (state.elements.video.paused) {
state.video.play(); state.elements.video.play();
} else { } else {
state.video.pause(); state.elements.video.pause();
} }
}; };
// toggle mute, restoring previous volume // toggle mute, restoring previous volume
export const toggleMute = (): void => { export const toggleMute = (): void => {
if (state.video.muted || state.video.volume === 0) { if (state.elements.video.muted || state.elements.video.volume === 0) {
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1; const restored = state.playback.lastKnownVolume > 0 ? state.playback.lastKnownVolume : 1;
state.video.muted = false; state.elements.video.muted = false;
state.video.volume = restored; state.elements.video.volume = restored;
} else { } else {
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume; state.playback.lastKnownVolume =
state.video.muted = true; state.elements.video.volume > 0
? state.elements.video.volume
: state.playback.lastKnownVolume;
state.elements.video.muted = true;
} }
}; };
// set volume (0-1), auto-unmute // set volume (0-1), auto-unmute
export const setVolume = (value: number): void => { export const setVolume = (value: number): void => {
state.video.volume = Math.max(0, Math.min(1, value)); state.elements.video.volume = Math.max(0, Math.min(1, value));
state.video.muted = value === 0; state.elements.video.muted = value === 0;
if (value > 0) state.lastKnownVolume = value; if (value > 0) state.playback.lastKnownVolume = value;
}; };
export const toggleFullscreen = (): void => { export const toggleFullscreen = (): void => {
@@ -64,19 +67,19 @@ export const toggleFullscreen = (): void => {
document.exitFullscreen(); document.exitFullscreen();
return; return;
} }
state.container.requestFullscreen?.(); state.elements.container.requestFullscreen?.();
}; };
// syncs volume slider, underline, and mute icon // syncs volume slider, underline, and mute icon
export const syncVolumeUI = (): void => { export const syncVolumeUI = (): void => {
const { volumeRange, volumeUnderline } = getControls(); const { volumeRange, volumeUnderline } = getControls();
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100); const value = state.elements.video.muted ? 0 : Math.round(state.elements.video.volume * 100);
if (volumeRange) { if (volumeRange) {
volumeRange.value = String(value); volumeRange.value = String(value);
volumeRange.style.setProperty("--volume-percent", `${value}%`); volumeRange.style.setProperty("--volume-percent", `${value}%`);
} }
if (volumeUnderline) volumeUnderline.style.height = `${value}%`; if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
updateMuteIcons(state.video.muted || state.video.volume === 0); updateMuteIcons(state.elements.video.muted || state.elements.video.volume === 0);
}; };
const VOLUME_STORAGE_KEY = "player-volume"; const VOLUME_STORAGE_KEY = "player-volume";
@@ -93,17 +96,17 @@ const applyStoredVolume = (): void => {
const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY)); const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY));
if (stored === null) return; if (stored === null) return;
state.video.volume = stored; state.elements.video.volume = stored;
state.video.muted = stored === 0; state.elements.video.muted = stored === 0;
if (stored > 0) state.lastKnownVolume = stored; if (stored > 0) state.playback.lastKnownVolume = stored;
}; };
let volumeSaveTimer: number | undefined; let volumeSaveTimer: number | undefined;
const schedulePersistVolume = (): void => { const schedulePersistVolume = (): void => {
window.clearTimeout(volumeSaveTimer); window.clearTimeout(volumeSaveTimer);
volumeSaveTimer = window.setTimeout(() => { volumeSaveTimer = window.setTimeout(() => {
if (!Number.isFinite(state.video.volume)) return; if (!Number.isFinite(state.elements.video.volume)) return;
const clamped = Math.max(0, Math.min(1, state.video.volume)); const clamped = Math.max(0, Math.min(1, state.elements.video.volume));
safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3)); safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3));
}, 250); }, 250);
}; };
@@ -130,7 +133,7 @@ let controlsCache: Controls | null = null;
const getControls = (): Controls => { const getControls = (): Controls => {
if (controlsCache) return controlsCache; if (controlsCache) return controlsCache;
const c = state.container; const c = state.elements.container;
controlsCache = { controlsCache = {
playPause: c.querySelector("[data-play-pause]"), playPause: c.querySelector("[data-play-pause]"),
muteBtn: c.querySelector("[data-mute]"), muteBtn: c.querySelector("[data-mute]"),
@@ -186,7 +189,7 @@ export const setupControls = (): void => {
togglePlayPause(); togglePlayPause();
showControls(); showControls();
}); });
state.video.addEventListener("click", () => { state.elements.video.addEventListener("click", () => {
togglePlayPause(); togglePlayPause();
showControls(); showControls();
}); });
@@ -216,37 +219,37 @@ export const setupControls = (): void => {
// skip intro/outro button // skip intro/outro button
skipSegmentBtn?.addEventListener("click", () => { skipSegmentBtn?.addEventListener("click", () => {
if (!state.activeSkipSegment) return; if (!state.skip.activeSegment) return;
state.video.currentTime = state.activeSkipSegment.end + 0.01; state.elements.video.currentTime = state.skip.activeSegment.end + 0.01;
showControls(); showControls();
}); });
// fullscreen change handler // fullscreen change handler
document.addEventListener("fullscreenchange", () => { document.addEventListener("fullscreenchange", () => {
state.isFullscreen = !!document.fullscreenElement; state.ui.isFullscreen = !!document.fullscreenElement;
state.container.classList.toggle("fullscreen", state.isFullscreen); state.elements.container.classList.toggle("fullscreen", state.ui.isFullscreen);
if (state.isFullscreen) showControls(); if (state.ui.isFullscreen) showControls();
}); });
// icon sync on state changes // icon sync on state changes
state.video.addEventListener("play", () => { state.elements.video.addEventListener("play", () => {
updatePlayPauseIcons(true); updatePlayPauseIcons(true);
showControls(); showControls();
}); });
state.video.addEventListener("pause", () => { state.elements.video.addEventListener("pause", () => {
updatePlayPauseIcons(false); updatePlayPauseIcons(false);
showControls(); showControls();
void saveProgress(); void saveProgress();
}); });
state.video.addEventListener("volumechange", () => { state.elements.video.addEventListener("volumechange", () => {
syncVolumeUI(); syncVolumeUI();
schedulePersistVolume(); schedulePersistVolume();
}); });
// mouse move in container shows controls // mouse move in container shows controls
state.container.addEventListener("mousemove", showControls); state.elements.container.addEventListener("mousemove", showControls);
// initial sync — check actual video state since inline script may have started playback // initial sync — check actual video state since inline script may have started playback
updatePlayPauseIcons(!state.video.paused); updatePlayPauseIcons(!state.elements.video.paused);
syncVolumeUI(); syncVolumeUI();
}; };

View File

@@ -41,12 +41,12 @@ export const setupKeyboard = (): void => {
break; break;
case "ArrowUp": case "ArrowUp":
e.preventDefault(); e.preventDefault();
setVolume(state.video.volume + 0.05); setVolume(state.elements.video.volume + 0.05);
showControls(); showControls();
break; break;
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
setVolume(state.video.volume - 0.05); setVolume(state.elements.video.volume - 0.05);
showControls(); showControls();
break; break;
case "KeyM": case "KeyM":
@@ -65,7 +65,7 @@ export const setupKeyboard = (): void => {
const b = getBounds(); const b = getBounds();
if (b.duration > 0) { if (b.duration > 0) {
e.preventDefault(); e.preventDefault();
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10); state.elements.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
showControls(); showControls();
} }
} }

View File

@@ -14,13 +14,13 @@ const alternateModeFor = (mode: string): "sub" | "dub" | null => {
}; };
export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void> => { export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void> => {
const alternateMode = alternateModeFor(state.currentMode); const alternateMode = alternateModeFor(state.playback.currentMode);
if (!alternateMode) return; if (!alternateMode) return;
if (state.modeSources[alternateMode]?.token) return; if (state.playback.modeSources[alternateMode]?.token) return;
try { try {
const res = await fetch( const res = await fetch(
`/api/watch/episode/${state.malID}/${encodeURIComponent(state.currentEpisode)}?mode=${encodeURIComponent(alternateMode)}`, `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(alternateMode)}`,
{ signal }, { signal },
); );
if (!res.ok) return; if (!res.ok) return;
@@ -32,8 +32,8 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void>
const alternateSource = sources[alternateMode]; const alternateSource = sources[alternateMode];
if (!alternateSource?.token) return; if (!alternateSource?.token) return;
state.modeSources = { state.playback.modeSources = {
...state.modeSources, ...state.playback.modeSources,
[alternateMode]: alternateSource, [alternateMode]: alternateSource,
}; };
@@ -50,24 +50,24 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void>
* Saves preference to localStorage, reloads video src. * Saves preference to localStorage, reloads video src.
*/ */
export const switchMode = (mode: string): void => { export const switchMode = (mode: string): void => {
if (!state.availableModes.includes(mode) || mode === state.currentMode) return; if (!state.playback.availableModes.includes(mode) || mode === state.playback.currentMode) return;
state.currentMode = mode; state.playback.currentMode = mode;
safeLocalStorage.setItem("player-audio-mode", mode); safeLocalStorage.setItem("player-audio-mode", mode);
const qualitySelect = state.container.querySelector( const qualitySelect = state.elements.container.querySelector(
"[data-quality-select]", "[data-quality-select]",
) as HTMLSelectElement | null; ) as HTMLSelectElement | null;
const url = streamUrlForMode(mode, qualitySelect?.value); const url = streamUrlForMode(mode, qualitySelect?.value);
loadVideoSource(url, state.modeSources[mode]?.type); loadVideoSource(url, state.playback.modeSources[mode]?.type);
// Fallback: if the media element doesn't actually switch sources (some browsers can get "stuck"), // Fallback: if the media element doesn't actually switch sources (some browsers can get "stuck"),
// reload the page with the desired mode and resume time via sessionStorage. // reload the page with the desired mode and resume time via sessionStorage.
if (url) { if (url) {
const expectedToken = state.modeSources[mode]?.token; const expectedToken = state.playback.modeSources[mode]?.token;
const expectedMode = mode; const expectedMode = mode;
const resumeSeconds = state.video.currentTime; const resumeSeconds = state.elements.video.currentTime;
window.setTimeout(() => { window.setTimeout(() => {
if (!expectedToken) return; if (!expectedToken) return;
const currentSrc = state.video.currentSrc || state.video.src || ""; const currentSrc = state.elements.video.currentSrc || state.elements.video.src || "";
if (currentSrc.includes(`token=${encodeURIComponent(expectedToken)}`)) return; if (currentSrc.includes(`token=${encodeURIComponent(expectedToken)}`)) return;
try { try {
@@ -90,24 +90,24 @@ export const switchMode = (mode: string): void => {
* Disables unavailable modes. * Disables unavailable modes.
*/ */
export const updateModeButtons = (): void => { export const updateModeButtons = (): void => {
const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
const m = state.currentMode; const m = state.playback.currentMode;
dub?.classList.toggle("text-accent", m === "dub"); dub?.classList.toggle("text-accent", m === "dub");
dub?.classList.toggle("text-foreground", m !== "dub"); dub?.classList.toggle("text-foreground", m !== "dub");
dub?.classList.toggle("opacity-50", !state.availableModes.includes("dub")); dub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("dub"));
dub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("dub")); dub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("dub"));
if (dub) { if (dub) {
dub.disabled = !state.availableModes.includes("dub"); dub.disabled = !state.playback.availableModes.includes("dub");
} }
sub?.classList.toggle("text-accent", m === "sub"); sub?.classList.toggle("text-accent", m === "sub");
sub?.classList.toggle("text-foreground", m !== "sub"); sub?.classList.toggle("text-foreground", m !== "sub");
sub?.classList.toggle("opacity-50", !state.availableModes.includes("sub")); sub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("sub"));
sub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("sub")); sub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("sub"));
if (sub) { if (sub) {
sub.disabled = !state.availableModes.includes("sub"); sub.disabled = !state.playback.availableModes.includes("sub");
} }
}; };
@@ -115,17 +115,17 @@ export const updateModeButtons = (): void => {
* Binds click handlers for mode buttons and autoplay toggle. * Binds click handlers for mode buttons and autoplay toggle.
*/ */
export const setupMode = (): void => { export const setupMode = (): void => {
const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
dub?.addEventListener("click", () => { dub?.addEventListener("click", () => {
if (state.availableModes.includes("dub")) { if (state.playback.availableModes.includes("dub")) {
switchMode("dub"); switchMode("dub");
showControls(); showControls();
} }
}); });
sub?.addEventListener("click", () => { sub?.addEventListener("click", () => {
if (state.availableModes.includes("sub")) { if (state.playback.availableModes.includes("sub")) {
switchMode("sub"); switchMode("sub");
showControls(); showControls();
} }

View File

@@ -8,10 +8,10 @@ import { loadVideoSource } from "./video";
* Persists preference to localStorage. * Persists preference to localStorage.
*/ */
export const switchQuality = (quality: string): void => { export const switchQuality = (quality: string): void => {
const url = streamUrlForMode(state.currentMode, quality); const url = streamUrlForMode(state.playback.currentMode, quality);
if (!url) return; if (!url) return;
safeLocalStorage.setItem("mal:preferred-quality", quality); safeLocalStorage.setItem("mal:preferred-quality", quality);
loadVideoSource(url, state.modeSources[state.currentMode]?.type); loadVideoSource(url, state.playback.modeSources[state.playback.currentMode]?.type);
}; };
/** /**
@@ -19,9 +19,11 @@ export const switchQuality = (quality: string): void => {
* Shows/hides dropdown based on availability. * Shows/hides dropdown based on availability.
*/ */
export const updateQualityOptions = (): void => { export const updateQualityOptions = (): void => {
const select = state.container.querySelector("[data-quality-select]") as HTMLSelectElement | null; const select = state.elements.container.querySelector(
"[data-quality-select]",
) as HTMLSelectElement | null;
if (!select) return; if (!select) return;
const qualities = state.modeSources[state.currentMode]?.qualities ?? []; const qualities = state.playback.modeSources[state.playback.currentMode]?.qualities ?? [];
select.innerHTML = ""; select.innerHTML = "";
const best = document.createElement("option"); const best = document.createElement("option");
@@ -49,7 +51,9 @@ export const updateQualityOptions = (): void => {
* Binds quality select change handler. * Binds quality select change handler.
*/ */
export const setupQuality = (): void => { export const setupQuality = (): void => {
const select = state.container.querySelector("[data-quality-select]") as HTMLSelectElement | null; const select = state.elements.container.querySelector(
"[data-quality-select]",
) as HTMLSelectElement | null;
select?.addEventListener("change", (e) => { select?.addEventListener("change", (e) => {
switchQuality((e.target as HTMLSelectElement).value); switchQuality((e.target as HTMLSelectElement).value);
}); });

View File

@@ -1,10 +1,10 @@
import { state } from "./state"; import { state } from "./state";
export const streamUrlForMode = (mode: string, quality?: string): string => { export const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.modeSources[mode]; const src = state.playback.modeSources[mode];
if (!src?.token) return ""; if (!src?.token) return "";
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`; let url = `${state.playback.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
if (src.type === "m3u8") { if (src.type === "m3u8") {
url += "&hls=1"; url += "&hls=1";
} }

View File

@@ -4,6 +4,7 @@ import { q, qs, dataset } from "../q";
import { safeLocalStorage } from "./storage"; import { safeLocalStorage } from "./storage";
export interface PlayerState { export interface PlayerState {
elements: {
container: HTMLElement; container: HTMLElement;
video: HTMLVideoElement; video: HTMLVideoElement;
progress: HTMLElement; progress: HTMLElement;
@@ -11,42 +12,56 @@ export interface PlayerState {
buffered: HTMLElement; buffered: HTMLElement;
timeDisplay: HTMLElement; timeDisplay: HTMLElement;
durationDisplay: HTMLElement; durationDisplay: HTMLElement;
modeSources: Record<string, ModeSource>;
readonly availableModes: string[];
currentMode: string;
modeSwitchedFrom: string;
currentEpisode: string;
totalEpisodes: number;
isAiring: boolean;
malID: number;
streamURL: string;
initialStreamToken: string;
startTimeSeconds: number;
shouldAutoPlay: boolean;
parsedSegments: SkipSegment[];
activeSegments: ActiveSegment[];
activeSkipSegment: ActiveSegment | null;
activeSubtitles: SubtitleCue[];
currentSubtitleTracks: SubtitleTrack[];
lastKnownVolume: number;
pendingSeekTime: number | null;
isScrubbing: boolean;
isFullscreen: boolean;
playerControlsTimeout: number | undefined;
progressSaveTimer: number | undefined;
transitionEpisode: number | null;
completionSent: boolean;
completionAttempts: number;
endedProgressSaved: boolean;
lastSavedProgress: { episode: string; seconds: number };
episodeGrid: HTMLElement | null; episodeGrid: HTMLElement | null;
episodeList: HTMLElement | null; episodeList: HTMLElement | null;
previewPopover: HTMLElement | null; previewPopover: HTMLElement | null;
previewTime: HTMLElement | null; previewTime: HTMLElement | null;
videoOverlay: HTMLElement | null; videoOverlay: HTMLElement | null;
};
playback: {
modeSources: Record<string, ModeSource>;
readonly availableModes: string[];
currentMode: string;
modeSwitchedFrom: string;
streamURL: string;
initialStreamToken: string;
startTimeSeconds: number;
shouldAutoPlay: boolean;
lastKnownVolume: number;
pendingSeekTime: number | null;
};
episode: {
current: string;
total: number;
isAiring: boolean;
malID: number;
transitionEpisode: number | null;
completionSent: boolean;
completionAttempts: number;
endedProgressSaved: boolean;
lastSavedProgress: { episode: string; seconds: number };
};
skip: {
parsedSegments: SkipSegment[];
activeSegments: ActiveSegment[];
activeSegment: ActiveSegment | null;
};
subtitles: {
activeCues: SubtitleCue[];
tracks: SubtitleTrack[];
};
ui: {
isScrubbing: boolean;
isFullscreen: boolean;
};
timers: {
playerControlsTimeout: number | undefined;
progressSaveTimer: number | undefined;
};
} }
const createInitialState = (): PlayerState => ({ const createInitialState = (): PlayerState => ({
elements: {
container: document.createElement("div"), container: document.createElement("div"),
video: document.createElement("video"), video: document.createElement("video"),
progress: document.createElement("div"), progress: document.createElement("div"),
@@ -54,52 +69,65 @@ const createInitialState = (): PlayerState => ({
buffered: document.createElement("div"), buffered: document.createElement("div"),
timeDisplay: document.createElement("div"), timeDisplay: document.createElement("div"),
durationDisplay: document.createElement("div"), durationDisplay: document.createElement("div"),
episodeGrid: null,
episodeList: null,
previewPopover: null,
previewTime: null,
videoOverlay: null,
},
playback: {
modeSources: {}, modeSources: {},
get availableModes() { get availableModes() {
return Object.keys(this.modeSources); return Object.keys(this.modeSources);
}, },
currentMode: "dub", currentMode: "dub",
modeSwitchedFrom: "", modeSwitchedFrom: "",
currentEpisode: "1",
totalEpisodes: 0,
isAiring: false,
malID: 0,
streamURL: "/watch/proxy/stream", streamURL: "/watch/proxy/stream",
initialStreamToken: "", initialStreamToken: "",
startTimeSeconds: 0, startTimeSeconds: 0,
shouldAutoPlay: false, shouldAutoPlay: false,
parsedSegments: [],
activeSegments: [],
activeSkipSegment: null,
activeSubtitles: [],
currentSubtitleTracks: [],
lastKnownVolume: 1, lastKnownVolume: 1,
pendingSeekTime: null, pendingSeekTime: null,
isScrubbing: false, },
isFullscreen: false, episode: {
playerControlsTimeout: undefined, current: "1",
progressSaveTimer: undefined, total: 0,
isAiring: false,
malID: 0,
transitionEpisode: null, transitionEpisode: null,
completionSent: false, completionSent: false,
completionAttempts: 0, completionAttempts: 0,
endedProgressSaved: false, endedProgressSaved: false,
lastSavedProgress: { episode: "1", seconds: -1 }, lastSavedProgress: { episode: "1", seconds: -1 },
episodeGrid: null, },
episodeList: null, skip: {
previewPopover: null, parsedSegments: [],
previewTime: null, activeSegments: [],
videoOverlay: null, activeSegment: null,
},
subtitles: {
activeCues: [],
tracks: [],
},
ui: {
isScrubbing: false,
isFullscreen: false,
},
timers: {
playerControlsTimeout: undefined,
progressSaveTimer: undefined,
},
}); });
export const state: PlayerState = createInitialState(); export const state: PlayerState = createInitialState();
export const showEndState = (): void => { export const showEndState = (): void => {
state.container.classList.add("video-ended"); state.elements.container.classList.add("video-ended");
state.video.pause(); state.elements.video.pause();
}; };
export const hideEndState = (): void => { export const hideEndState = (): void => {
state.container.classList.remove("video-ended"); state.elements.container.classList.remove("video-ended");
}; };
interface RequiredPlayerElements { interface RequiredPlayerElements {
@@ -145,33 +173,33 @@ export const initState = (c: HTMLElement): boolean => {
if (!elements) return false; if (!elements) return false;
// core elements // core elements
state.container = c; state.elements.container = c;
state.video = elements.video; state.elements.video = elements.video;
state.progress = elements.progress; state.elements.progress = elements.progress;
state.scrubber = elements.scrubber; state.elements.scrubber = elements.scrubber;
state.buffered = elements.buffered; state.elements.buffered = elements.buffered;
state.timeDisplay = elements.timeDisplay; state.elements.timeDisplay = elements.timeDisplay;
state.durationDisplay = elements.durationDisplay; state.elements.durationDisplay = elements.durationDisplay;
state.previewPopover = q<HTMLElement>(c, "[data-preview-popover]"); state.elements.previewPopover = q<HTMLElement>(c, "[data-preview-popover]");
state.previewTime = q<HTMLElement>(c, "[data-preview-time]"); state.elements.previewTime = q<HTMLElement>(c, "[data-preview-time]");
state.videoOverlay = q<HTMLElement>(c, "[data-video-overlay]"); state.elements.videoOverlay = q<HTMLElement>(c, "[data-video-overlay]");
// data attributes from server // data attributes from server
state.malID = Number.parseInt(dataset(c, "malId"), 10); state.episode.malID = Number.parseInt(dataset(c, "malId"), 10);
state.currentEpisode = dataset(c, "currentEpisode") || "1"; state.episode.current = dataset(c, "currentEpisode") || "1";
state.totalEpisodes = Number.parseInt(dataset(c, "totalEpisodes"), 10); state.episode.total = Number.parseInt(dataset(c, "totalEpisodes"), 10);
state.isAiring = dataset(c, "isAiring") === "true"; state.episode.isAiring = dataset(c, "isAiring") === "true";
state.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream"; state.playback.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream";
state.initialStreamToken = dataset(c, "streamToken") || ""; state.playback.initialStreamToken = dataset(c, "streamToken") || "";
state.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0; state.playback.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0;
// from session: previous page set this when autoplay triggered // from session: previous page set this when autoplay triggered
state.shouldAutoPlay = sessionStorage.getItem("mal:autoplay-next") === "true"; state.playback.shouldAutoPlay = sessionStorage.getItem("mal:autoplay-next") === "true";
sessionStorage.removeItem("mal:autoplay-next"); sessionStorage.removeItem("mal:autoplay-next");
// global elements (not inside player container) // global elements (not inside player container)
state.episodeGrid = qs<HTMLElement>("[data-episode-grid]"); state.elements.episodeGrid = qs<HTMLElement>("[data-episode-grid]");
state.episodeList = qs<HTMLElement>("[data-episode-list]"); state.elements.episodeList = qs<HTMLElement>("[data-episode-list]");
const safeJsonUnknown = (raw: string | undefined): unknown => { const safeJsonUnknown = (raw: string | undefined): unknown => {
try { try {
@@ -182,23 +210,27 @@ export const initState = (c: HTMLElement): boolean => {
}; };
// mode sources = { sub: { token, subtitles, qualities }, dub: { ... } } // mode sources = { sub: { token, subtitles, qualities }, dub: { ... } }
state.modeSources = parseModeSources(safeJsonUnknown(dataset(c, "modeSources"))); state.playback.modeSources = parseModeSources(safeJsonUnknown(dataset(c, "modeSources")));
// resolve initial mode: localStorage > backend default > first available > 'dub' // resolve initial mode: localStorage > backend default > first available > 'dub'
const backendInitialMode = dataset(c, "initialMode") || "dub"; const backendInitialMode = dataset(c, "initialMode") || "dub";
state.modeSwitchedFrom = dataset(c, "modeSwitchedFrom") || ""; state.playback.modeSwitchedFrom = dataset(c, "modeSwitchedFrom") || "";
const storedMode = safeLocalStorage.getItem("player-audio-mode"); const storedMode = safeLocalStorage.getItem("player-audio-mode");
const initialMode = const initialMode =
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode; storedMode && state.playback.availableModes.includes(storedMode)
const fallbackMode = Object.keys(state.modeSources).find((m) => state.modeSources[m]?.token); ? storedMode
state.currentMode = state.modeSources[initialMode]?.token : backendInitialMode;
const fallbackMode = Object.keys(state.playback.modeSources).find(
(m) => state.playback.modeSources[m]?.token,
);
state.playback.currentMode = state.playback.modeSources[initialMode]?.token
? initialMode ? initialMode
: (fallbackMode ?? state.availableModes[0] ?? "dub"); : (fallbackMode ?? state.playback.availableModes[0] ?? "dub");
// If the inline template script already set a video src, prefer deriving the mode from it. // If the inline template script already set a video src, prefer deriving the mode from it.
// This avoids mismatches where the UI highlights one mode but the video is actually playing the other. // This avoids mismatches where the UI highlights one mode but the video is actually playing the other.
const deriveModeFromVideoSrc = (): string | null => { const deriveModeFromVideoSrc = (): string | null => {
const raw = state.video.currentSrc || state.video.src; const raw = state.elements.video.currentSrc || state.elements.video.src;
if (!raw) return null; if (!raw) return null;
try { try {
const u = new URL(raw, window.location.href); const u = new URL(raw, window.location.href);
@@ -213,16 +245,16 @@ export const initState = (c: HTMLElement): boolean => {
const modeFromVideo = deriveModeFromVideoSrc(); const modeFromVideo = deriveModeFromVideoSrc();
if ( if (
modeFromVideo && modeFromVideo &&
modeFromVideo !== state.currentMode && modeFromVideo !== state.playback.currentMode &&
state.availableModes.includes(modeFromVideo) && state.playback.availableModes.includes(modeFromVideo) &&
state.modeSources[modeFromVideo]?.token state.playback.modeSources[modeFromVideo]?.token
) { ) {
state.currentMode = modeFromVideo; state.playback.currentMode = modeFromVideo;
} }
// parse skip segments from data attribute // parse skip segments from data attribute
const segments = parseSegments(safeJsonUnknown(dataset(c, "segments"))); const segments = parseSegments(safeJsonUnknown(dataset(c, "segments")));
state.parsedSegments = segments state.skip.parsedSegments = segments
.map((s) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) .map((s) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
.filter((s) => s.end > s.start); .filter((s) => s.end > s.start);

View File

@@ -8,10 +8,12 @@ let cachedDuration = 0;
let cachedSeekableEnd = 0; let cachedSeekableEnd = 0;
const getDuration = (): number => const getDuration = (): number =>
Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0; Number.isFinite(state.elements.video.duration) && state.elements.video.duration > 0
? state.elements.video.duration
: 0;
const getSeekableEnd = (): number => { const getSeekableEnd = (): number => {
const ranges = state.video.seekable; const ranges = state.elements.video.seekable;
if (!ranges || ranges.length <= 0) return 0; if (!ranges || ranges.length <= 0) return 0;
const end = ranges.end(ranges.length - 1); const end = ranges.end(ranges.length - 1);
return Number.isFinite(end) && end > 0 ? end : 0; return Number.isFinite(end) && end > 0 ? end : 0;
@@ -27,9 +29,9 @@ export const timelineBounds = (): TimelineBounds => {
return { start: 0, end: duration, duration }; return { start: 0, end: duration, duration };
} }
if (state.video.seekable.length > 0) { if (state.elements.video.seekable.length > 0) {
const seekableStart = state.video.seekable.start(0); const seekableStart = state.elements.video.seekable.start(0);
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1); const seekableEnd = state.elements.video.seekable.end(state.elements.video.seekable.length - 1);
if ( if (
Number.isFinite(seekableStart) && Number.isFinite(seekableStart) &&
Number.isFinite(seekableEnd) && Number.isFinite(seekableEnd) &&
@@ -92,23 +94,23 @@ export const absoluteTimeFromRatio = (ratio: number): number => {
// finds the end of the buffered region containing currentTime // finds the end of the buffered region containing currentTime
export const getBufferedEnd = (): number => { export const getBufferedEnd = (): number => {
const currentTime = state.video.currentTime; const currentTime = state.elements.video.currentTime;
let end = 0; let end = 0;
// first: find buffered range that contains current time // first: find buffered range that contains current time
for (let i = 0; i < state.video.buffered.length; i++) { for (let i = 0; i < state.elements.video.buffered.length; i++) {
if ( if (
state.video.buffered.start(i) <= currentTime && state.elements.video.buffered.start(i) <= currentTime &&
state.video.buffered.end(i) >= currentTime state.elements.video.buffered.end(i) >= currentTime
) { ) {
end = state.video.buffered.end(i); end = state.elements.video.buffered.end(i);
break; break;
} }
} }
// fallback: next buffered range after current time // fallback: next buffered range after current time
if (end === 0) { if (end === 0) {
for (let i = 0; i < state.video.buffered.length; i++) { for (let i = 0; i < state.elements.video.buffered.length; i++) {
if (state.video.buffered.end(i) > currentTime) { if (state.elements.video.buffered.end(i) > currentTime) {
end = Math.max(end, state.video.buffered.end(i)); end = Math.max(end, state.elements.video.buffered.end(i));
} }
} }
} }
@@ -120,7 +122,7 @@ export const getBufferedEnd = (): number => {
* Called on timeupdate, progress events, and seek. * Called on timeupdate, progress events, and seek.
*/ */
export const updateTimeline = (currentTime: number): void => { export const updateTimeline = (currentTime: number): void => {
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state; const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state.elements;
const b = getBounds(); const b = getBounds();
if (b.duration <= 0) { if (b.duration <= 0) {

View File

@@ -15,9 +15,9 @@ const destroyHLS = (): void => {
export const destroyVideoSource = (): void => { export const destroyVideoSource = (): void => {
destroyHLS(); destroyHLS();
state.video.pause(); state.elements.video.pause();
state.video.removeAttribute("src"); state.elements.video.removeAttribute("src");
state.video.load(); state.elements.video.load();
}; };
const shouldUseHLS = (type: string | undefined, url: string): boolean => { const shouldUseHLS = (type: string | undefined, url: string): boolean => {
@@ -40,31 +40,31 @@ const shouldUseHLS = (type: string | undefined, url: string): boolean => {
export const loadVideoSource = (url: string, type?: string): void => { export const loadVideoSource = (url: string, type?: string): void => {
if (!url) return; if (!url) return;
const wasPlaying = !state.video.paused; const wasPlaying = !state.elements.video.paused;
const prevDisplayTime = displayTimeFromAbsolute(state.video.currentTime); const prevDisplayTime = displayTimeFromAbsolute(state.elements.video.currentTime);
// Fully reset the element before setting a new source. // Fully reset the element before setting a new source.
destroyVideoSource(); destroyVideoSource();
if (shouldUseHLS(type, url) && Hls.isSupported()) { if (shouldUseHLS(type, url) && Hls.isSupported()) {
hls = new Hls(); hls = new Hls();
stopHLSProfile = attachHLSProfile(hls, state.video); stopHLSProfile = attachHLSProfile(hls, state.elements.video);
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(state.video); hls.attachMedia(state.elements.video);
} else { } else {
state.video.src = url; state.elements.video.src = url;
state.video.load(); state.elements.video.load();
} }
// Try an eager seek; if metadata isn't ready yet, main.ts will restore via pendingSeekTime. // Try an eager seek; if metadata isn't ready yet, main.ts will restore via pendingSeekTime.
state.pendingSeekTime = prevDisplayTime; state.playback.pendingSeekTime = prevDisplayTime;
if (state.video.readyState >= HTMLMediaElement.HAVE_METADATA) { if (state.elements.video.readyState >= HTMLMediaElement.HAVE_METADATA) {
invalidateBounds(); invalidateBounds();
state.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime); state.elements.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime);
state.pendingSeekTime = null; state.playback.pendingSeekTime = null;
} }
if (wasPlaying) { if (wasPlaying) {
state.video.play().catch(() => undefined); state.elements.video.play().catch(() => undefined);
} }
}; };