refactor: group player state
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user