From 1da19d500e1490be66ded6c456c4c92bff138c87 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 31 May 2026 00:39:01 +0200 Subject: [PATCH] feat: extract video module and add mode-switch fallback --- justfile | 3 ++- static/player/main.ts | 20 ++++++++++++++++++++ static/player/mode.ts | 40 +++++++++++++++++++++++++--------------- static/player/quality.ts | 16 ++-------------- static/player/state.ts | 32 ++++++++++++++++++++++++++++++-- static/player/video.ts | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 static/player/video.ts diff --git a/justfile b/justfile index 331d4e7..52ef512 100644 --- a/justfile +++ b/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 diff --git a/static/player/main.ts b/static/player/main.ts index 99abcc1..ae6a6b8 100644 --- a/static/player/main.ts +++ b/static/player/main.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); diff --git a/static/player/mode.ts b/static/player/mode.ts index 9bd22d6..8e86dce 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -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(); diff --git a/static/player/quality.ts b/static/player/quality.ts index 63bf4dc..4e14fea 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -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); }; /** diff --git a/static/player/state.ts b/static/player/state.ts index dbe1293..a511c45 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -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 diff --git a/static/player/video.ts b/static/player/video.ts new file mode 100644 index 0000000..34664ab --- /dev/null +++ b/static/player/video.ts @@ -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); + } +}; +