refactor: group player state
This commit is contained in:
@@ -13,50 +13,53 @@ export const formatTime = (seconds: number): string => {
|
||||
* Shows the controls overlay and schedules auto-hide after 2s if playing.
|
||||
*/
|
||||
export const showControls = (): void => {
|
||||
state.container.classList.add("show-controls");
|
||||
window.clearTimeout(state.playerControlsTimeout);
|
||||
state.playerControlsTimeout = window.setTimeout(() => {
|
||||
if (!state.isScrubbing && !state.video.paused) {
|
||||
state.container.classList.remove("show-controls");
|
||||
state.elements.container.classList.add("show-controls");
|
||||
window.clearTimeout(state.timers.playerControlsTimeout);
|
||||
state.timers.playerControlsTimeout = window.setTimeout(() => {
|
||||
if (!state.ui.isScrubbing && !state.elements.video.paused) {
|
||||
state.elements.container.classList.remove("show-controls");
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// seek relative to current position
|
||||
export const seekBy = (delta: number): void => {
|
||||
if (state.video.duration <= 0) return;
|
||||
state.video.currentTime = Math.max(
|
||||
if (state.elements.video.duration <= 0) return;
|
||||
state.elements.video.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(state.video.duration, state.video.currentTime + delta),
|
||||
Math.min(state.elements.video.duration, state.elements.video.currentTime + delta),
|
||||
);
|
||||
showControls();
|
||||
};
|
||||
|
||||
export const togglePlayPause = (): void => {
|
||||
if (state.video.paused) {
|
||||
state.video.play();
|
||||
if (state.elements.video.paused) {
|
||||
state.elements.video.play();
|
||||
} else {
|
||||
state.video.pause();
|
||||
state.elements.video.pause();
|
||||
}
|
||||
};
|
||||
|
||||
// toggle mute, restoring previous volume
|
||||
export const toggleMute = (): void => {
|
||||
if (state.video.muted || state.video.volume === 0) {
|
||||
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1;
|
||||
state.video.muted = false;
|
||||
state.video.volume = restored;
|
||||
if (state.elements.video.muted || state.elements.video.volume === 0) {
|
||||
const restored = state.playback.lastKnownVolume > 0 ? state.playback.lastKnownVolume : 1;
|
||||
state.elements.video.muted = false;
|
||||
state.elements.video.volume = restored;
|
||||
} else {
|
||||
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume;
|
||||
state.video.muted = true;
|
||||
state.playback.lastKnownVolume =
|
||||
state.elements.video.volume > 0
|
||||
? state.elements.video.volume
|
||||
: state.playback.lastKnownVolume;
|
||||
state.elements.video.muted = true;
|
||||
}
|
||||
};
|
||||
|
||||
// set volume (0-1), auto-unmute
|
||||
export const setVolume = (value: number): void => {
|
||||
state.video.volume = Math.max(0, Math.min(1, value));
|
||||
state.video.muted = value === 0;
|
||||
if (value > 0) state.lastKnownVolume = value;
|
||||
state.elements.video.volume = Math.max(0, Math.min(1, value));
|
||||
state.elements.video.muted = value === 0;
|
||||
if (value > 0) state.playback.lastKnownVolume = value;
|
||||
};
|
||||
|
||||
export const toggleFullscreen = (): void => {
|
||||
@@ -64,19 +67,19 @@ export const toggleFullscreen = (): void => {
|
||||
document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
state.container.requestFullscreen?.();
|
||||
state.elements.container.requestFullscreen?.();
|
||||
};
|
||||
|
||||
// syncs volume slider, underline, and mute icon
|
||||
export const syncVolumeUI = (): void => {
|
||||
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) {
|
||||
volumeRange.value = String(value);
|
||||
volumeRange.style.setProperty("--volume-percent", `${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";
|
||||
@@ -93,17 +96,17 @@ const applyStoredVolume = (): void => {
|
||||
const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY));
|
||||
if (stored === null) return;
|
||||
|
||||
state.video.volume = stored;
|
||||
state.video.muted = stored === 0;
|
||||
if (stored > 0) state.lastKnownVolume = stored;
|
||||
state.elements.video.volume = stored;
|
||||
state.elements.video.muted = stored === 0;
|
||||
if (stored > 0) state.playback.lastKnownVolume = stored;
|
||||
};
|
||||
|
||||
let volumeSaveTimer: number | undefined;
|
||||
const schedulePersistVolume = (): void => {
|
||||
window.clearTimeout(volumeSaveTimer);
|
||||
volumeSaveTimer = window.setTimeout(() => {
|
||||
if (!Number.isFinite(state.video.volume)) return;
|
||||
const clamped = Math.max(0, Math.min(1, state.video.volume));
|
||||
if (!Number.isFinite(state.elements.video.volume)) return;
|
||||
const clamped = Math.max(0, Math.min(1, state.elements.video.volume));
|
||||
safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3));
|
||||
}, 250);
|
||||
};
|
||||
@@ -130,7 +133,7 @@ let controlsCache: Controls | null = null;
|
||||
|
||||
const getControls = (): Controls => {
|
||||
if (controlsCache) return controlsCache;
|
||||
const c = state.container;
|
||||
const c = state.elements.container;
|
||||
controlsCache = {
|
||||
playPause: c.querySelector("[data-play-pause]"),
|
||||
muteBtn: c.querySelector("[data-mute]"),
|
||||
@@ -186,7 +189,7 @@ export const setupControls = (): void => {
|
||||
togglePlayPause();
|
||||
showControls();
|
||||
});
|
||||
state.video.addEventListener("click", () => {
|
||||
state.elements.video.addEventListener("click", () => {
|
||||
togglePlayPause();
|
||||
showControls();
|
||||
});
|
||||
@@ -216,37 +219,37 @@ export const setupControls = (): void => {
|
||||
|
||||
// skip intro/outro button
|
||||
skipSegmentBtn?.addEventListener("click", () => {
|
||||
if (!state.activeSkipSegment) return;
|
||||
state.video.currentTime = state.activeSkipSegment.end + 0.01;
|
||||
if (!state.skip.activeSegment) return;
|
||||
state.elements.video.currentTime = state.skip.activeSegment.end + 0.01;
|
||||
showControls();
|
||||
});
|
||||
|
||||
// fullscreen change handler
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
state.isFullscreen = !!document.fullscreenElement;
|
||||
state.container.classList.toggle("fullscreen", state.isFullscreen);
|
||||
if (state.isFullscreen) showControls();
|
||||
state.ui.isFullscreen = !!document.fullscreenElement;
|
||||
state.elements.container.classList.toggle("fullscreen", state.ui.isFullscreen);
|
||||
if (state.ui.isFullscreen) showControls();
|
||||
});
|
||||
|
||||
// icon sync on state changes
|
||||
state.video.addEventListener("play", () => {
|
||||
state.elements.video.addEventListener("play", () => {
|
||||
updatePlayPauseIcons(true);
|
||||
showControls();
|
||||
});
|
||||
state.video.addEventListener("pause", () => {
|
||||
state.elements.video.addEventListener("pause", () => {
|
||||
updatePlayPauseIcons(false);
|
||||
showControls();
|
||||
void saveProgress();
|
||||
});
|
||||
state.video.addEventListener("volumechange", () => {
|
||||
state.elements.video.addEventListener("volumechange", () => {
|
||||
syncVolumeUI();
|
||||
schedulePersistVolume();
|
||||
});
|
||||
|
||||
// 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
|
||||
updatePlayPauseIcons(!state.video.paused);
|
||||
updatePlayPauseIcons(!state.elements.video.paused);
|
||||
syncVolumeUI();
|
||||
};
|
||||
|
||||
@@ -41,12 +41,12 @@ export const setupKeyboard = (): void => {
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume(state.video.volume + 0.05);
|
||||
setVolume(state.elements.video.volume + 0.05);
|
||||
showControls();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume(state.video.volume - 0.05);
|
||||
setVolume(state.elements.video.volume - 0.05);
|
||||
showControls();
|
||||
break;
|
||||
case "KeyM":
|
||||
@@ -65,7 +65,7 @@ export const setupKeyboard = (): void => {
|
||||
const b = getBounds();
|
||||
if (b.duration > 0) {
|
||||
e.preventDefault();
|
||||
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
|
||||
state.elements.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ const alternateModeFor = (mode: string): "sub" | "dub" | null => {
|
||||
};
|
||||
|
||||
export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void> => {
|
||||
const alternateMode = alternateModeFor(state.currentMode);
|
||||
const alternateMode = alternateModeFor(state.playback.currentMode);
|
||||
if (!alternateMode) return;
|
||||
if (state.modeSources[alternateMode]?.token) return;
|
||||
if (state.playback.modeSources[alternateMode]?.token) return;
|
||||
|
||||
try {
|
||||
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 },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
@@ -32,8 +32,8 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void>
|
||||
const alternateSource = sources[alternateMode];
|
||||
if (!alternateSource?.token) return;
|
||||
|
||||
state.modeSources = {
|
||||
...state.modeSources,
|
||||
state.playback.modeSources = {
|
||||
...state.playback.modeSources,
|
||||
[alternateMode]: alternateSource,
|
||||
};
|
||||
|
||||
@@ -50,24 +50,24 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise<void>
|
||||
* Saves preference to localStorage, reloads video src.
|
||||
*/
|
||||
export const switchMode = (mode: string): void => {
|
||||
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
|
||||
state.currentMode = mode;
|
||||
if (!state.playback.availableModes.includes(mode) || mode === state.playback.currentMode) return;
|
||||
state.playback.currentMode = mode;
|
||||
safeLocalStorage.setItem("player-audio-mode", mode);
|
||||
const qualitySelect = state.container.querySelector(
|
||||
const qualitySelect = state.elements.container.querySelector(
|
||||
"[data-quality-select]",
|
||||
) as HTMLSelectElement | null;
|
||||
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"),
|
||||
// reload the page with the desired mode and resume time via sessionStorage.
|
||||
if (url) {
|
||||
const expectedToken = state.modeSources[mode]?.token;
|
||||
const expectedToken = state.playback.modeSources[mode]?.token;
|
||||
const expectedMode = mode;
|
||||
const resumeSeconds = state.video.currentTime;
|
||||
const resumeSeconds = state.elements.video.currentTime;
|
||||
window.setTimeout(() => {
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -90,24 +90,24 @@ export const switchMode = (mode: string): void => {
|
||||
* Disables unavailable modes.
|
||||
*/
|
||||
export const updateModeButtons = (): void => {
|
||||
const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
|
||||
const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
|
||||
const m = state.currentMode;
|
||||
const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
|
||||
const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
|
||||
const m = state.playback.currentMode;
|
||||
|
||||
dub?.classList.toggle("text-accent", m === "dub");
|
||||
dub?.classList.toggle("text-foreground", m !== "dub");
|
||||
dub?.classList.toggle("opacity-50", !state.availableModes.includes("dub"));
|
||||
dub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("dub"));
|
||||
dub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("dub"));
|
||||
dub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("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-foreground", m !== "sub");
|
||||
sub?.classList.toggle("opacity-50", !state.availableModes.includes("sub"));
|
||||
sub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("sub"));
|
||||
sub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("sub"));
|
||||
sub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("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.
|
||||
*/
|
||||
export const setupMode = (): void => {
|
||||
const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
|
||||
const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
|
||||
const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null;
|
||||
const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null;
|
||||
|
||||
dub?.addEventListener("click", () => {
|
||||
if (state.availableModes.includes("dub")) {
|
||||
if (state.playback.availableModes.includes("dub")) {
|
||||
switchMode("dub");
|
||||
showControls();
|
||||
}
|
||||
});
|
||||
sub?.addEventListener("click", () => {
|
||||
if (state.availableModes.includes("sub")) {
|
||||
if (state.playback.availableModes.includes("sub")) {
|
||||
switchMode("sub");
|
||||
showControls();
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { loadVideoSource } from "./video";
|
||||
* Persists preference to localStorage.
|
||||
*/
|
||||
export const switchQuality = (quality: string): void => {
|
||||
const url = streamUrlForMode(state.currentMode, quality);
|
||||
const url = streamUrlForMode(state.playback.currentMode, quality);
|
||||
if (!url) return;
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
const qualities = state.modeSources[state.currentMode]?.qualities ?? [];
|
||||
const qualities = state.playback.modeSources[state.playback.currentMode]?.qualities ?? [];
|
||||
select.innerHTML = "";
|
||||
|
||||
const best = document.createElement("option");
|
||||
@@ -49,7 +51,9 @@ export const updateQualityOptions = (): void => {
|
||||
* Binds quality select change handler.
|
||||
*/
|
||||
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) => {
|
||||
switchQuality((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { state } from "./state";
|
||||
|
||||
export const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
const src = state.modeSources[mode];
|
||||
const src = state.playback.modeSources[mode];
|
||||
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") {
|
||||
url += "&hls=1";
|
||||
}
|
||||
|
||||
@@ -4,102 +4,130 @@ import { q, qs, dataset } from "../q";
|
||||
import { safeLocalStorage } from "./storage";
|
||||
|
||||
export interface PlayerState {
|
||||
container: HTMLElement;
|
||||
video: HTMLVideoElement;
|
||||
progress: HTMLElement;
|
||||
scrubber: HTMLElement;
|
||||
buffered: HTMLElement;
|
||||
timeDisplay: 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;
|
||||
episodeList: HTMLElement | null;
|
||||
previewPopover: HTMLElement | null;
|
||||
previewTime: HTMLElement | null;
|
||||
videoOverlay: HTMLElement | null;
|
||||
elements: {
|
||||
container: HTMLElement;
|
||||
video: HTMLVideoElement;
|
||||
progress: HTMLElement;
|
||||
scrubber: HTMLElement;
|
||||
buffered: HTMLElement;
|
||||
timeDisplay: HTMLElement;
|
||||
durationDisplay: HTMLElement;
|
||||
episodeGrid: HTMLElement | null;
|
||||
episodeList: HTMLElement | null;
|
||||
previewPopover: HTMLElement | null;
|
||||
previewTime: 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 => ({
|
||||
container: document.createElement("div"),
|
||||
video: document.createElement("video"),
|
||||
progress: document.createElement("div"),
|
||||
scrubber: document.createElement("div"),
|
||||
buffered: document.createElement("div"),
|
||||
timeDisplay: document.createElement("div"),
|
||||
durationDisplay: document.createElement("div"),
|
||||
modeSources: {},
|
||||
get availableModes() {
|
||||
return Object.keys(this.modeSources);
|
||||
elements: {
|
||||
container: document.createElement("div"),
|
||||
video: document.createElement("video"),
|
||||
progress: document.createElement("div"),
|
||||
scrubber: document.createElement("div"),
|
||||
buffered: document.createElement("div"),
|
||||
timeDisplay: document.createElement("div"),
|
||||
durationDisplay: document.createElement("div"),
|
||||
episodeGrid: null,
|
||||
episodeList: null,
|
||||
previewPopover: null,
|
||||
previewTime: null,
|
||||
videoOverlay: null,
|
||||
},
|
||||
playback: {
|
||||
modeSources: {},
|
||||
get availableModes() {
|
||||
return Object.keys(this.modeSources);
|
||||
},
|
||||
currentMode: "dub",
|
||||
modeSwitchedFrom: "",
|
||||
streamURL: "/watch/proxy/stream",
|
||||
initialStreamToken: "",
|
||||
startTimeSeconds: 0,
|
||||
shouldAutoPlay: false,
|
||||
lastKnownVolume: 1,
|
||||
pendingSeekTime: null,
|
||||
},
|
||||
episode: {
|
||||
current: "1",
|
||||
total: 0,
|
||||
isAiring: false,
|
||||
malID: 0,
|
||||
transitionEpisode: null,
|
||||
completionSent: false,
|
||||
completionAttempts: 0,
|
||||
endedProgressSaved: false,
|
||||
lastSavedProgress: { episode: "1", seconds: -1 },
|
||||
},
|
||||
skip: {
|
||||
parsedSegments: [],
|
||||
activeSegments: [],
|
||||
activeSegment: null,
|
||||
},
|
||||
subtitles: {
|
||||
activeCues: [],
|
||||
tracks: [],
|
||||
},
|
||||
ui: {
|
||||
isScrubbing: false,
|
||||
isFullscreen: false,
|
||||
},
|
||||
timers: {
|
||||
playerControlsTimeout: undefined,
|
||||
progressSaveTimer: undefined,
|
||||
},
|
||||
currentMode: "dub",
|
||||
modeSwitchedFrom: "",
|
||||
currentEpisode: "1",
|
||||
totalEpisodes: 0,
|
||||
isAiring: false,
|
||||
malID: 0,
|
||||
streamURL: "/watch/proxy/stream",
|
||||
initialStreamToken: "",
|
||||
startTimeSeconds: 0,
|
||||
shouldAutoPlay: false,
|
||||
parsedSegments: [],
|
||||
activeSegments: [],
|
||||
activeSkipSegment: null,
|
||||
activeSubtitles: [],
|
||||
currentSubtitleTracks: [],
|
||||
lastKnownVolume: 1,
|
||||
pendingSeekTime: null,
|
||||
isScrubbing: false,
|
||||
isFullscreen: false,
|
||||
playerControlsTimeout: undefined,
|
||||
progressSaveTimer: undefined,
|
||||
transitionEpisode: null,
|
||||
completionSent: false,
|
||||
completionAttempts: 0,
|
||||
endedProgressSaved: false,
|
||||
lastSavedProgress: { episode: "1", seconds: -1 },
|
||||
episodeGrid: null,
|
||||
episodeList: null,
|
||||
previewPopover: null,
|
||||
previewTime: null,
|
||||
videoOverlay: null,
|
||||
});
|
||||
|
||||
export const state: PlayerState = createInitialState();
|
||||
|
||||
export const showEndState = (): void => {
|
||||
state.container.classList.add("video-ended");
|
||||
state.video.pause();
|
||||
state.elements.container.classList.add("video-ended");
|
||||
state.elements.video.pause();
|
||||
};
|
||||
|
||||
export const hideEndState = (): void => {
|
||||
state.container.classList.remove("video-ended");
|
||||
state.elements.container.classList.remove("video-ended");
|
||||
};
|
||||
|
||||
interface RequiredPlayerElements {
|
||||
@@ -145,33 +173,33 @@ export const initState = (c: HTMLElement): boolean => {
|
||||
if (!elements) return false;
|
||||
|
||||
// core elements
|
||||
state.container = c;
|
||||
state.video = elements.video;
|
||||
state.progress = elements.progress;
|
||||
state.scrubber = elements.scrubber;
|
||||
state.buffered = elements.buffered;
|
||||
state.timeDisplay = elements.timeDisplay;
|
||||
state.durationDisplay = elements.durationDisplay;
|
||||
state.previewPopover = q<HTMLElement>(c, "[data-preview-popover]");
|
||||
state.previewTime = q<HTMLElement>(c, "[data-preview-time]");
|
||||
state.videoOverlay = q<HTMLElement>(c, "[data-video-overlay]");
|
||||
state.elements.container = c;
|
||||
state.elements.video = elements.video;
|
||||
state.elements.progress = elements.progress;
|
||||
state.elements.scrubber = elements.scrubber;
|
||||
state.elements.buffered = elements.buffered;
|
||||
state.elements.timeDisplay = elements.timeDisplay;
|
||||
state.elements.durationDisplay = elements.durationDisplay;
|
||||
state.elements.previewPopover = q<HTMLElement>(c, "[data-preview-popover]");
|
||||
state.elements.previewTime = q<HTMLElement>(c, "[data-preview-time]");
|
||||
state.elements.videoOverlay = q<HTMLElement>(c, "[data-video-overlay]");
|
||||
|
||||
// data attributes from server
|
||||
state.malID = Number.parseInt(dataset(c, "malId"), 10);
|
||||
state.currentEpisode = dataset(c, "currentEpisode") || "1";
|
||||
state.episode.malID = Number.parseInt(dataset(c, "malId"), 10);
|
||||
state.episode.current = dataset(c, "currentEpisode") || "1";
|
||||
|
||||
state.totalEpisodes = Number.parseInt(dataset(c, "totalEpisodes"), 10);
|
||||
state.isAiring = dataset(c, "isAiring") === "true";
|
||||
state.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream";
|
||||
state.initialStreamToken = dataset(c, "streamToken") || "";
|
||||
state.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0;
|
||||
state.episode.total = Number.parseInt(dataset(c, "totalEpisodes"), 10);
|
||||
state.episode.isAiring = dataset(c, "isAiring") === "true";
|
||||
state.playback.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream";
|
||||
state.playback.initialStreamToken = dataset(c, "streamToken") || "";
|
||||
state.playback.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0;
|
||||
// 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");
|
||||
|
||||
// global elements (not inside player container)
|
||||
state.episodeGrid = qs<HTMLElement>("[data-episode-grid]");
|
||||
state.episodeList = qs<HTMLElement>("[data-episode-list]");
|
||||
state.elements.episodeGrid = qs<HTMLElement>("[data-episode-grid]");
|
||||
state.elements.episodeList = qs<HTMLElement>("[data-episode-list]");
|
||||
|
||||
const safeJsonUnknown = (raw: string | undefined): unknown => {
|
||||
try {
|
||||
@@ -182,23 +210,27 @@ export const initState = (c: HTMLElement): boolean => {
|
||||
};
|
||||
|
||||
// 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'
|
||||
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 initialMode =
|
||||
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
|
||||
const fallbackMode = Object.keys(state.modeSources).find((m) => state.modeSources[m]?.token);
|
||||
state.currentMode = state.modeSources[initialMode]?.token
|
||||
storedMode && state.playback.availableModes.includes(storedMode)
|
||||
? storedMode
|
||||
: backendInitialMode;
|
||||
const fallbackMode = Object.keys(state.playback.modeSources).find(
|
||||
(m) => state.playback.modeSources[m]?.token,
|
||||
);
|
||||
state.playback.currentMode = state.playback.modeSources[initialMode]?.token
|
||||
? 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.
|
||||
// This avoids mismatches where the UI highlights one mode but the video is actually playing the other.
|
||||
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;
|
||||
try {
|
||||
const u = new URL(raw, window.location.href);
|
||||
@@ -213,16 +245,16 @@ export const initState = (c: HTMLElement): boolean => {
|
||||
const modeFromVideo = deriveModeFromVideoSrc();
|
||||
if (
|
||||
modeFromVideo &&
|
||||
modeFromVideo !== state.currentMode &&
|
||||
state.availableModes.includes(modeFromVideo) &&
|
||||
state.modeSources[modeFromVideo]?.token
|
||||
modeFromVideo !== state.playback.currentMode &&
|
||||
state.playback.availableModes.includes(modeFromVideo) &&
|
||||
state.playback.modeSources[modeFromVideo]?.token
|
||||
) {
|
||||
state.currentMode = modeFromVideo;
|
||||
state.playback.currentMode = modeFromVideo;
|
||||
}
|
||||
|
||||
// parse skip segments from data attribute
|
||||
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 }))
|
||||
.filter((s) => s.end > s.start);
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ let cachedDuration = 0;
|
||||
let cachedSeekableEnd = 0;
|
||||
|
||||
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 ranges = state.video.seekable;
|
||||
const ranges = state.elements.video.seekable;
|
||||
if (!ranges || ranges.length <= 0) return 0;
|
||||
const end = ranges.end(ranges.length - 1);
|
||||
return Number.isFinite(end) && end > 0 ? end : 0;
|
||||
@@ -27,9 +29,9 @@ export const timelineBounds = (): TimelineBounds => {
|
||||
return { start: 0, end: duration, duration };
|
||||
}
|
||||
|
||||
if (state.video.seekable.length > 0) {
|
||||
const seekableStart = state.video.seekable.start(0);
|
||||
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1);
|
||||
if (state.elements.video.seekable.length > 0) {
|
||||
const seekableStart = state.elements.video.seekable.start(0);
|
||||
const seekableEnd = state.elements.video.seekable.end(state.elements.video.seekable.length - 1);
|
||||
if (
|
||||
Number.isFinite(seekableStart) &&
|
||||
Number.isFinite(seekableEnd) &&
|
||||
@@ -92,23 +94,23 @@ export const absoluteTimeFromRatio = (ratio: number): number => {
|
||||
|
||||
// finds the end of the buffered region containing currentTime
|
||||
export const getBufferedEnd = (): number => {
|
||||
const currentTime = state.video.currentTime;
|
||||
const currentTime = state.elements.video.currentTime;
|
||||
let end = 0;
|
||||
// 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 (
|
||||
state.video.buffered.start(i) <= currentTime &&
|
||||
state.video.buffered.end(i) >= currentTime
|
||||
state.elements.video.buffered.start(i) <= currentTime &&
|
||||
state.elements.video.buffered.end(i) >= currentTime
|
||||
) {
|
||||
end = state.video.buffered.end(i);
|
||||
end = state.elements.video.buffered.end(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// fallback: next buffered range after current time
|
||||
if (end === 0) {
|
||||
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||
if (state.video.buffered.end(i) > currentTime) {
|
||||
end = Math.max(end, state.video.buffered.end(i));
|
||||
for (let i = 0; i < state.elements.video.buffered.length; i++) {
|
||||
if (state.elements.video.buffered.end(i) > currentTime) {
|
||||
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.
|
||||
*/
|
||||
export const updateTimeline = (currentTime: number): void => {
|
||||
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state;
|
||||
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state.elements;
|
||||
const b = getBounds();
|
||||
|
||||
if (b.duration <= 0) {
|
||||
|
||||
@@ -15,9 +15,9 @@ const destroyHLS = (): void => {
|
||||
|
||||
export const destroyVideoSource = (): void => {
|
||||
destroyHLS();
|
||||
state.video.pause();
|
||||
state.video.removeAttribute("src");
|
||||
state.video.load();
|
||||
state.elements.video.pause();
|
||||
state.elements.video.removeAttribute("src");
|
||||
state.elements.video.load();
|
||||
};
|
||||
|
||||
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 => {
|
||||
if (!url) return;
|
||||
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevDisplayTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
const wasPlaying = !state.elements.video.paused;
|
||||
const prevDisplayTime = displayTimeFromAbsolute(state.elements.video.currentTime);
|
||||
|
||||
// Fully reset the element before setting a new source.
|
||||
destroyVideoSource();
|
||||
|
||||
if (shouldUseHLS(type, url) && Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
stopHLSProfile = attachHLSProfile(hls, state.video);
|
||||
stopHLSProfile = attachHLSProfile(hls, state.elements.video);
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(state.video);
|
||||
hls.attachMedia(state.elements.video);
|
||||
} else {
|
||||
state.video.src = url;
|
||||
state.video.load();
|
||||
state.elements.video.src = url;
|
||||
state.elements.video.load();
|
||||
}
|
||||
|
||||
// Try an eager seek; if metadata isn't ready yet, main.ts will restore via pendingSeekTime.
|
||||
state.pendingSeekTime = prevDisplayTime;
|
||||
if (state.video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
state.playback.pendingSeekTime = prevDisplayTime;
|
||||
if (state.elements.video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
invalidateBounds();
|
||||
state.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime);
|
||||
state.pendingSeekTime = null;
|
||||
state.elements.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime);
|
||||
state.playback.pendingSeekTime = null;
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
state.video.play().catch(() => undefined);
|
||||
state.elements.video.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user