feat: extract video module and add mode-switch fallback
This commit is contained in:
3
justfile
3
justfile
@@ -23,7 +23,8 @@ build-css:
|
||||
bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css
|
||||
|
||||
build-ts:
|
||||
bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser
|
||||
bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting
|
||||
bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming "[name].js"
|
||||
|
||||
build: build-go build-css build-ts
|
||||
|
||||
|
||||
@@ -143,6 +143,26 @@ const initPlayer = (): void => {
|
||||
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
||||
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
||||
|
||||
// Resume after a mode-switch page reload (best effort, session-scoped).
|
||||
const resumeAfterModeSwitch = (() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
|
||||
if (raw === null) return null;
|
||||
sessionStorage.removeItem("mal:resume-after-mode-switch");
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (resumeAfterModeSwitch !== null) {
|
||||
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
||||
if (clamped > 0) {
|
||||
state.video.currentTime = clamped;
|
||||
}
|
||||
}
|
||||
|
||||
if (startTime > 0 && state.video.currentTime <= 2) {
|
||||
if (resumeTime > 0) {
|
||||
state.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { state } from "./state";
|
||||
import { displayTimeFromAbsolute } from "./timeline";
|
||||
import { showControls } from "./controls";
|
||||
import { updateSubtitleOptions } from "./subtitles";
|
||||
import { updateQualityOptions } from "./quality";
|
||||
import { safeLocalStorage } from "./storage";
|
||||
import { loadVideoSource } from "./video";
|
||||
|
||||
// builds stream URL with mode, token, and optional quality param
|
||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
@@ -14,19 +14,6 @@ const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
return url;
|
||||
};
|
||||
|
||||
// switches video src while preserving playback position
|
||||
const loadVideo = (url: string): void => {
|
||||
if (!url) return;
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
state.video.src = url;
|
||||
state.video.load();
|
||||
state.pendingSeekTime = prevTime; // restored in loadedmetadata handler
|
||||
if (wasPlaying) {
|
||||
state.video.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches between sub/dub mode.
|
||||
* Saves preference to localStorage, reloads video src.
|
||||
@@ -38,7 +25,30 @@ export const switchMode = (mode: string): void => {
|
||||
const qualitySelect = state.container.querySelector(
|
||||
"[data-quality-select]",
|
||||
) as HTMLSelectElement | null;
|
||||
loadVideo(streamUrlForMode(mode, qualitySelect?.value));
|
||||
const url = streamUrlForMode(mode, qualitySelect?.value);
|
||||
loadVideoSource(url);
|
||||
|
||||
// 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 expectedMode = mode;
|
||||
const resumeSeconds = state.video.currentTime;
|
||||
window.setTimeout(() => {
|
||||
if (!expectedToken) return;
|
||||
const currentSrc = state.video.currentSrc || state.video.src || "";
|
||||
if (currentSrc.includes(`token=${encodeURIComponent(expectedToken)}`)) return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem("mal:resume-after-mode-switch", String(resumeSeconds));
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set("mode", expectedMode);
|
||||
window.location.href = next.toString();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
updateSubtitleOptions();
|
||||
updateQualityOptions();
|
||||
updateModeButtons();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { state } from "./state";
|
||||
import { displayTimeFromAbsolute } from "./timeline";
|
||||
import { safeLocalStorage } from "./storage";
|
||||
import { loadVideoSource } from "./video";
|
||||
|
||||
// same as mode.ts - could be extracted to shared util
|
||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
@@ -11,18 +11,6 @@ const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
return url;
|
||||
};
|
||||
|
||||
const loadVideo = (url: string): void => {
|
||||
if (!url) return;
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
state.video.src = url;
|
||||
state.video.load();
|
||||
state.pendingSeekTime = prevTime;
|
||||
if (wasPlaying) {
|
||||
state.video.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches video quality (resolution).
|
||||
* Persists preference to localStorage.
|
||||
@@ -31,7 +19,7 @@ export const switchQuality = (quality: string): void => {
|
||||
const url = streamUrlForMode(state.currentMode, quality);
|
||||
if (!url) return;
|
||||
safeLocalStorage.setItem("mal:preferred-quality", quality);
|
||||
loadVideo(url);
|
||||
loadVideoSource(url);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -196,11 +196,14 @@ export const initState = (c: HTMLElement): boolean => {
|
||||
for (const [key, value] of Object.entries(v)) {
|
||||
if (!isRecord(value)) continue;
|
||||
if (typeof value.token !== "string" || value.token === "") continue;
|
||||
if (!isSubtitleItemArray(value.subtitles)) continue;
|
||||
// `subtitles` can be `null` when the backend has no subtitles for the stream.
|
||||
// Treat that as an empty list instead of dropping the whole mode source.
|
||||
const subtitles = value.subtitles == null ? [] : value.subtitles;
|
||||
if (!isSubtitleItemArray(subtitles)) continue;
|
||||
const qualities = value.qualities;
|
||||
out[key] = {
|
||||
token: value.token,
|
||||
subtitles: value.subtitles,
|
||||
subtitles,
|
||||
qualities: isStringArray(qualities) ? qualities : undefined,
|
||||
};
|
||||
}
|
||||
@@ -239,6 +242,31 @@ export const initState = (c: HTMLElement): boolean => {
|
||||
? initialMode
|
||||
: (fallbackMode ?? state.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;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const u = new URL(raw, window.location.href);
|
||||
const modeParam = u.searchParams.get("mode");
|
||||
if (modeParam === "sub" || modeParam === "dub") return modeParam;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const modeFromVideo = deriveModeFromVideoSrc();
|
||||
if (
|
||||
modeFromVideo &&
|
||||
modeFromVideo !== state.currentMode &&
|
||||
state.availableModes.includes(modeFromVideo) &&
|
||||
state.modeSources[modeFromVideo]?.token
|
||||
) {
|
||||
state.currentMode = modeFromVideo;
|
||||
}
|
||||
|
||||
// parse skip segments from data attribute
|
||||
const segments = parseSegments(safeJsonUnknown(dataset(c, "segments")));
|
||||
state.parsedSegments = segments
|
||||
|
||||
36
static/player/video.ts
Normal file
36
static/player/video.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { state } from "./state";
|
||||
import { absoluteTimeFromDisplay, displayTimeFromAbsolute, invalidateBounds } from "./timeline";
|
||||
|
||||
/**
|
||||
* Force-loads a new video source and preserves playback position.
|
||||
*
|
||||
* Some browsers can be flaky when switching between HLS URLs while playing.
|
||||
* Clearing `src` first ensures the media element fully resets before the new URL is set.
|
||||
*/
|
||||
export const loadVideoSource = (url: string): void => {
|
||||
if (!url) return;
|
||||
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevDisplayTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
|
||||
// Fully reset the element before setting a new source.
|
||||
state.video.pause();
|
||||
state.video.removeAttribute("src");
|
||||
state.video.load();
|
||||
|
||||
state.video.src = url;
|
||||
state.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) {
|
||||
invalidateBounds();
|
||||
state.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime);
|
||||
state.pendingSeekTime = null;
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
state.video.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user