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
|
bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css
|
||||||
|
|
||||||
build-ts:
|
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
|
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 resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
||||||
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
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 (startTime > 0 && state.video.currentTime <= 2) {
|
||||||
if (resumeTime > 0) {
|
if (resumeTime > 0) {
|
||||||
state.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
state.video.currentTime = absoluteTimeFromDisplay(resumeTime);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { state } from "./state";
|
import { state } from "./state";
|
||||||
import { displayTimeFromAbsolute } from "./timeline";
|
|
||||||
import { showControls } from "./controls";
|
import { showControls } from "./controls";
|
||||||
import { updateSubtitleOptions } from "./subtitles";
|
import { updateSubtitleOptions } from "./subtitles";
|
||||||
import { updateQualityOptions } from "./quality";
|
import { updateQualityOptions } from "./quality";
|
||||||
import { safeLocalStorage } from "./storage";
|
import { safeLocalStorage } from "./storage";
|
||||||
|
import { loadVideoSource } from "./video";
|
||||||
|
|
||||||
// builds stream URL with mode, token, and optional quality param
|
// builds stream URL with mode, token, and optional quality param
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
@@ -14,19 +14,6 @@ const streamUrlForMode = (mode: string, quality?: string): string => {
|
|||||||
return url;
|
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.
|
* Switches between sub/dub mode.
|
||||||
* Saves preference to localStorage, reloads video src.
|
* Saves preference to localStorage, reloads video src.
|
||||||
@@ -38,7 +25,30 @@ export const switchMode = (mode: string): void => {
|
|||||||
const qualitySelect = state.container.querySelector(
|
const qualitySelect = state.container.querySelector(
|
||||||
"[data-quality-select]",
|
"[data-quality-select]",
|
||||||
) as HTMLSelectElement | null;
|
) 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();
|
updateSubtitleOptions();
|
||||||
updateQualityOptions();
|
updateQualityOptions();
|
||||||
updateModeButtons();
|
updateModeButtons();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { state } from "./state";
|
import { state } from "./state";
|
||||||
import { displayTimeFromAbsolute } from "./timeline";
|
|
||||||
import { safeLocalStorage } from "./storage";
|
import { safeLocalStorage } from "./storage";
|
||||||
|
import { loadVideoSource } from "./video";
|
||||||
|
|
||||||
// same as mode.ts - could be extracted to shared util
|
// same as mode.ts - could be extracted to shared util
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
@@ -11,18 +11,6 @@ const streamUrlForMode = (mode: string, quality?: string): string => {
|
|||||||
return url;
|
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).
|
* Switches video quality (resolution).
|
||||||
* Persists preference to localStorage.
|
* Persists preference to localStorage.
|
||||||
@@ -31,7 +19,7 @@ export const switchQuality = (quality: string): void => {
|
|||||||
const url = streamUrlForMode(state.currentMode, quality);
|
const url = streamUrlForMode(state.currentMode, quality);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
safeLocalStorage.setItem("mal:preferred-quality", quality);
|
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)) {
|
for (const [key, value] of Object.entries(v)) {
|
||||||
if (!isRecord(value)) continue;
|
if (!isRecord(value)) continue;
|
||||||
if (typeof value.token !== "string" || value.token === "") 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;
|
const qualities = value.qualities;
|
||||||
out[key] = {
|
out[key] = {
|
||||||
token: value.token,
|
token: value.token,
|
||||||
subtitles: value.subtitles,
|
subtitles,
|
||||||
qualities: isStringArray(qualities) ? qualities : undefined,
|
qualities: isStringArray(qualities) ? qualities : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,6 +242,31 @@ export const initState = (c: HTMLElement): boolean => {
|
|||||||
? initialMode
|
? initialMode
|
||||||
: (fallbackMode ?? state.availableModes[0] ?? "dub");
|
: (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
|
// parse skip segments from data attribute
|
||||||
const segments = parseSegments(safeJsonUnknown(dataset(c, "segments")));
|
const segments = parseSegments(safeJsonUnknown(dataset(c, "segments")));
|
||||||
state.parsedSegments = 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