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

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