feat: extract video module and add mode-switch fallback

This commit is contained in:
2026-05-31 00:39:01 +02:00
committed by Milas Holsting
parent be7994b806
commit 2f7af1f739
6 changed files with 115 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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