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.
*/
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();
};

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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);
});

View File

@@ -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";
}

View File

@@ -4,6 +4,7 @@ import { q, qs, dataset } from "../q";
import { safeLocalStorage } from "./storage";
export interface PlayerState {
elements: {
container: HTMLElement;
video: HTMLVideoElement;
progress: HTMLElement;
@@ -11,42 +12,56 @@ export interface PlayerState {
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;
};
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 => ({
elements: {
container: document.createElement("div"),
video: document.createElement("video"),
progress: document.createElement("div"),
@@ -54,52 +69,65 @@ const createInitialState = (): PlayerState => ({
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: "",
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,
},
episode: {
current: "1",
total: 0,
isAiring: false,
malID: 0,
transitionEpisode: null,
completionSent: false,
completionAttempts: 0,
endedProgressSaved: false,
lastSavedProgress: { episode: "1", seconds: -1 },
episodeGrid: null,
episodeList: null,
previewPopover: null,
previewTime: null,
videoOverlay: null,
},
skip: {
parsedSegments: [],
activeSegments: [],
activeSegment: null,
},
subtitles: {
activeCues: [],
tracks: [],
},
ui: {
isScrubbing: false,
isFullscreen: false,
},
timers: {
playerControlsTimeout: undefined,
progressSaveTimer: undefined,
},
});
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);

View File

@@ -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) {

View File

@@ -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);
}
};