Files
pinkudex/components/video/VideoPlayerModal.tsx
T
2026-05-26 22:46:00 +02:00

1396 lines
58 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, ExternalLink, Loader2, AlertTriangle, Cpu, PlayCircle } from "lucide-react";
function formatJobElapsed(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
import { useSettings } from "@/components/settings/SettingsProvider";
import { VariantPicker } from "./VariantPicker";
import {
SubtitlePicker,
type SubtitleSources,
type EmbeddedEntry,
type SidecarEntry,
type LangIso,
} from "./SubtitlePicker";
import { dispatchVideoStatusRefresh } from "./videoStatusEvents";
import { cn } from "@/lib/utils";
/** One alternate encode of a single part. partIdx is the absolute index
* into the on-disk findVideosForCode list — used as the stream URL's
* `?part=` value. */
interface VariantOut {
partIdx: number;
abs: string;
rel: string;
filename: string;
size: number;
label: string;
metadata?: VideoMetadata | null;
}
interface PartOut {
partIndex: number;
defaultIdx: number;
variants: VariantOut[];
}
interface VideoMetadata {
sizeBytes: number;
durationSec: number | null;
videoCodec: string | null;
videoBFrames: number | null;
width: number | null;
height: number | null;
videoBitrate: number | null;
playbackMode: string | null;
probeError: string | null;
}
/**
* Snapshot dropped/total decoded video frames from a live <video>
* element via the standard MediaCapabilities-adjacent API. Returns
* null if the API is unavailable or the element isn't decoding.
*/
function readQuality(video: HTMLVideoElement | null): { dropped: number; total: number } | null {
if (!video) return null;
const q = video.getVideoPlaybackQuality?.();
if (!q) return null;
return { dropped: q.droppedVideoFrames ?? 0, total: q.totalVideoFrames ?? 0 };
}
/** Threshold check: did this many drops over this many total frames
* in the measurement window indicate the native sink can't keep up? */
function isStuttering(dropped: number, total: number): boolean {
// >5% drop ratio on a meaningful sample, OR >5 absolute drops in
// the test window. The absolute floor catches cases where the
// decoder produced very few total frames.
return (total > 0 && dropped / total > 0.05) || dropped > 5;
}
async function persistMode(code: string, partIdx: number, mode: "direct" | "transcode"): Promise<void> {
try {
await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
} catch { /* best effort */ }
}
function formatDuration(sec: number | null | undefined): string | null {
if (sec == null || !Number.isFinite(sec) || sec <= 0) return null;
const total = Math.round(sec);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatBitrate(bps: number | null | undefined): string | null {
if (bps == null || !Number.isFinite(bps) || bps <= 0) return null;
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
if (bps >= 1_000) return `${Math.round(bps / 1_000)} Kbps`;
return `${Math.round(bps)} bps`;
}
function formatBytes(bytes: number | null | undefined): string | null {
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return null;
const units = ["B", "KB", "MB", "GB", "TB"];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${i === 0 ? Math.round(n) : n.toFixed(n >= 10 ? 1 : 2)} ${units[i]}`;
}
type LangPref = "EN" | "CN" | "JP" | "off";
const SUBTITLE_PREF_KEY = "pinkudex.subtitlePreference";
function readSubtitlePref(): LangPref {
try {
const raw = localStorage.getItem(SUBTITLE_PREF_KEY);
if (raw === "EN" || raw === "CN" || raw === "JP" || raw === "off") return raw;
} catch { /* ignore */ }
return "off";
}
function writeSubtitlePref(pref: LangPref): void {
try { localStorage.setItem(SUBTITLE_PREF_KEY, pref); } catch { /* ignore */ }
}
const SUBTITLE_OFFSET_KEY = (code: string) => `pinkudex.subtitleOffset.${code}`;
function readSubtitleOffset(code: string): number {
try {
const raw = localStorage.getItem(SUBTITLE_OFFSET_KEY(code));
if (raw == null) return 0;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
} catch { return 0; }
}
function writeSubtitleOffset(code: string, value: number): void {
try {
if (value === 0) localStorage.removeItem(SUBTITLE_OFFSET_KEY(code));
else localStorage.setItem(SUBTITLE_OFFSET_KEY(code), String(value));
} catch { /* ignore */ }
}
function isoToPref(iso: LangIso | null): LangPref {
if (iso === "eng") return "EN";
if (iso === "zho") return "CN";
if (iso === "jpn") return "JP";
return "off";
}
function prefToIso(pref: LangPref): LangIso | null {
if (pref === "EN") return "eng";
if (pref === "CN") return "zho";
if (pref === "JP") return "jpn";
return null;
}
const TOKEN_SPLIT = /[\s._\-\[\]()+,;]+/g;
/** Mirror of detectLanguageFromName from lib/video/subtitles.ts — kept
* in the client so Browse can synthesize a sidecar entry locally
* without round-tripping the list endpoint. */
function detectLangClient(filename: string): LangIso | null {
const dot = filename.lastIndexOf(".");
const stem = dot > 0 ? filename.slice(0, dot) : filename;
const tokens = stem.toLowerCase().split(TOKEN_SPLIT).filter(Boolean);
let found: LangIso | null = null;
let multiple = false;
for (const t of tokens) {
let iso: LangIso | null = null;
if (t === "en" || t === "eng" || t === "english") iso = "eng";
else if (t === "zh" || t === "zho" || t === "chi" || t === "chs" || t === "cht"
|| t === "chn" || t === "cn" || t === "chinese") iso = "zho";
else if (t === "ja" || t === "jp" || t === "jpn" || t === "japanese") iso = "jpn";
if (iso) {
if (found && found !== iso) { multiple = true; break; }
found = iso;
}
}
return multiple ? null : found;
}
function langDisplay(iso: LangIso | null): string {
if (iso === "eng") return "English";
if (iso === "zho") return "Chinese";
if (iso === "jpn") return "Japanese";
return "Unknown";
}
function formatCodec(codec: string | null | undefined): string | null {
if (!codec) return null;
const map: Record<string, string> = {
h264: "H.264",
hevc: "HEVC",
h265: "HEVC",
av1: "AV1",
vp9: "VP9",
mpeg4: "MPEG-4",
};
return map[codec.toLowerCase()] ?? codec.toUpperCase();
}
function formatVideoSummary(meta: VideoMetadata | null | undefined, compact = false): string | null {
if (!meta || meta.probeError) return null;
const parts = [
meta.width && meta.height ? (compact ? `${meta.height}p` : `${meta.width}x${meta.height}`) : null,
formatCodec(meta.videoCodec),
compact ? null : formatBitrate(meta.videoBitrate),
compact ? null : formatBytes(meta.sizeBytes),
compact ? null : formatDuration(meta.durationSec),
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" · ") : null;
}
export function VideoPlayerModal({
code,
actresses,
onClose,
}: {
code: string;
actresses?: Array<{ id: number; name: string; slug: string }>;
onClose: () => void;
}) {
const [parts, setParts] = useState<PartOut[] | null>(null);
const [error, setError] = useState<string | null>(null);
/** Absolute stream-URL identifier — see VariantOut.partIdx. */
const [partIdx, setPartIdx] = useState(0);
/** Per-part display index → variant index within that part, used so
* that switching parts and coming back remembers your variant pick.
* In-memory only by design (each session is a fresh decision). */
const [variantPickByPart, setVariantPickByPart] = useState<Record<number, number>>({});
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { settings } = useSettings();
const mode = settings.transcodeMode;
// Resolved playback decision: direct or transcode. For "off"/"always" this
// is fixed. For auto modes we probe + (optionally) measure first.
const [resolved, setResolved] = useState<"direct" | "transcode" | null>(
mode === "off" ? "direct" : mode === "always" ? "transcode" : null,
);
const [probing, setProbing] = useState(false);
// For auto-runtime: we start in direct mode and watch the LIVE main video
// element for dropped frames. If it stutters, swap to transcode and
// persist the decision. The badge shows "Measuring" while this runs so
// the user knows the brief glitch on stuttery files isn't a bug.
const [measuring, setMeasuring] = useState(false);
const transcoding = resolved === "transcode";
// Subtitle state — sources fetched per (code, partIdx). selectedSubId
// is "off" or one of the source ids ("emb:N" / "side:<b64>"). Browsed
// sidecars get appended to subSources.sidecar with origin="browsed".
const [subSources, setSubSources] = useState<SubtitleSources | null>(null);
const [subSourcesLoading, setSubSourcesLoading] = useState(false);
const [selectedSubId, setSelectedSubId] = useState<string | "off">("off");
// Subtitle timing offset in seconds (positive = subs appear later,
// negative = sooner). Per-code, persisted to localStorage. Applied
// to native TextTrackCue startTime/endTime client-side so the shift
// is instant — no server roundtrip.
const [subOffsetSec, setSubOffsetSec] = useState<number>(() => readSubtitleOffset(code));
const subAutoApplied = useRef<string | null>(null);
// Re-read offset when the code changes (e.g. modal navigation between
// titles). Per-code persistence means each video keeps its own sync.
useEffect(() => { setSubOffsetSec(readSubtitleOffset(code)); }, [code]);
// Persist offset on change.
useEffect(() => { writeSubtitleOffset(code, subOffsetSec); }, [code, subOffsetSec]);
useEffect(() => {
if (mode === "off") { setResolved("direct"); setMeasuring(false); return; }
if (mode === "always") { setResolved("transcode"); setMeasuring(false); return; }
let cancelled = false;
setProbing(true);
setResolved(null);
setMeasuring(false);
(async () => {
try {
// Probe the SPECIFIC part the user is opening — the file at part 0
// can be a different codec from part N (e.g. .fixed.mp4 vs HEVC
// .restored.mp4 sharing the same code).
const r = await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`);
const data = await r.json() as {
codec: string | null;
bFrames: number | null;
cachedMode: string | null;
metadata: VideoMetadata | null;
};
if (cancelled) return;
setMetadata(data.metadata ?? null);
// auto-runtime: trust a previously persisted decision when present.
if (mode === "auto-runtime" && (data.cachedMode === "direct" || data.cachedMode === "transcode")) {
setResolved(data.cachedMode);
setProbing(false);
return;
}
const triggersBug = data.codec === "h264" && (data.bFrames ?? 0) > 0;
// auto-predicate: predicate is the final answer, no measurement.
if (mode === "auto-predicate") {
setResolved(triggersBug ? "transcode" : "direct");
setProbing(false);
return;
}
// auto-runtime, first time:
// - Codec can't trigger the bug (HEVC, AV1, no-B-frame H.264) →
// shortcut to direct, persist, no measurement.
if (!triggersBug) {
setResolved("direct");
setProbing(false);
void persistMode(code, partIdx, "direct");
return;
}
// Codec might trigger the bug → start the LIVE measurement on the
// real <video> element (a hidden offscreen probe video doesn't
// actually decode in modern Chromium — battery optimization). The
// measurement effect below picks this up via `measuring` state.
setResolved("direct");
setProbing(false);
setMeasuring(true);
} catch (e) {
if (cancelled) return;
// Probe failed — fall back to direct so the user still gets video.
// eslint-disable-next-line no-console
console.error("[probe]", e);
setResolved("direct");
setProbing(false);
}
})();
return () => { cancelled = true; };
}, [code, mode, partIdx]);
useEffect(() => {
if (mode !== "off" && mode !== "always") return;
let cancelled = false;
(async () => {
try {
const r = await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`);
const data = await r.json() as { metadata: VideoMetadata | null };
if (!cancelled) setMetadata(data.metadata ?? null);
} catch {
if (!cancelled) setMetadata(null);
}
})();
return () => { cancelled = true; };
}, [code, mode, partIdx]);
// Live measurement effect: when `measuring` is true, the user is watching
// the actual file via the direct stream. After a brief settle window we
// sample dropped frames from the live <video>. If it's stuttering, swap
// to the transcode path (HLS) and persist. Otherwise persist "direct".
// Only runs in auto-runtime mode; off/always/auto-predicate never set
// `measuring=true`.
useEffect(() => {
if (!measuring) return;
let cancelled = false;
let baseline: { dropped: number; total: number } | null = null;
// Wait for the video to actually start rendering frames before we
// record the baseline. Without this we may sample before the decoder
// has emitted anything.
const startTimer = setTimeout(() => {
baseline = readQuality(videoRef.current);
}, 500);
const decideTimer = setTimeout(() => {
if (cancelled) return;
const now = readQuality(videoRef.current);
const baseDrop = baseline?.dropped ?? 0;
const baseTotal = baseline?.total ?? 0;
const dropped = Math.max(0, (now?.dropped ?? 0) - baseDrop);
const total = Math.max(0, (now?.total ?? 0) - baseTotal);
const decision = isStuttering(dropped, total) ? "transcode" : "direct";
setMeasuring(false);
void persistMode(code, partIdx, decision);
if (decision === "transcode") setResolved("transcode");
}, 4000);
return () => {
cancelled = true;
clearTimeout(startTimer);
clearTimeout(decideTimer);
};
}, [measuring, code, partIdx]);
useEffect(() => {
let live = true;
setError(null);
fetch(`/api/video-files/${encodeURIComponent(code)}`)
.then((r) => r.json())
.then((j: { parts?: PartOut[] }) => {
if (!live) return;
if (!j.parts || j.parts.length === 0) {
setError("No video file found for this code.");
setParts([]);
return;
}
setParts(j.parts);
// Initial selection: first part's default variant. Stream URL
// uses the variant's absolute partIdx.
const first = j.parts[0]!;
const v = first.variants[first.defaultIdx] ?? first.variants[0]!;
setPartIdx(v.partIdx);
})
.catch((e) => { if (live) setError((e as Error).message); });
return () => { live = false; };
}, [code]);
// Look up the currently playing variant from parts + partIdx.
const currentLocation = useMemo(() => {
if (!parts) return null;
for (let p = 0; p < parts.length; p++) {
const part = parts[p]!;
for (let v = 0; v < part.variants.length; v++) {
if (part.variants[v]!.partIdx === partIdx) return { partDisplay: p, variantIdx: v };
}
}
return null;
}, [parts, partIdx]);
const currentFile = currentLocation && parts
? parts[currentLocation.partDisplay]!.variants[currentLocation.variantIdx]!
: null;
useEffect(() => {
setMetadata(currentFile?.metadata ?? null);
}, [currentFile]);
// Fetch subtitle sources whenever the active variant changes. The
// auto-apply ref is keyed by code+partIdx so switching variants of
// the same part doesn't re-yank the user's manual pick.
const [subRefetchTick, setSubRefetchTick] = useState(0);
const refetchSources = useCallback(() => setSubRefetchTick((t) => t + 1), []);
// Tracks abs paths of sidecars the modal has already seen. Used by the
// auto-select-on-new-generated effect to recognize a freshly produced
// .whisperjav.srt without confusing it with files present at open.
const knownSidecarAbsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!currentFile) return;
let live = true;
const key = `${code}:${partIdx}`;
const isInitial = subAutoApplied.current !== key;
setSubSourcesLoading(true);
fetch(`/api/video-subtitles/${encodeURIComponent(code)}?part=${partIdx}`, { cache: "no-store" })
.then((r) => r.json())
.then((data: SubtitleSources) => {
if (!live) return;
const next: SubtitleSources = {
embedded: data.embedded ?? [],
sidecar: data.sidecar ?? [],
};
setSubSources(next);
setSubSourcesLoading(false);
if (isInitial) {
subAutoApplied.current = key;
// Seed the known-sidecars ref so already-present files don't
// count as fresh on later refetches.
knownSidecarAbsRef.current = new Set(next.sidecar.map((s) => s.abs));
const pref = readSubtitlePref();
const targetIso = prefToIso(pref);
let chosen: { id: string; language: LangIso | null } | null = null;
// English wins by default when present — even ahead of a
// saved preference. The saved pref is mostly populated by
// the "sole tagged sidecar" auto-persist below, which
// shouldn't override the user's explicit "default to EN
// when available" wish. Also match compound-language files
// (lang=null but label contains "English") since multi-tag
// filenames like `.ja.pass1.english.srt` collapse to null.
const isEnglish = (lang: LangIso | null, label: string) =>
lang === "eng" || /\benglish\b/i.test(label);
const eng =
next.embedded.find((e) => e.renderable && isEnglish(e.language, e.label))
?? next.sidecar.find((s) => isEnglish(s.language, s.label));
if (eng) chosen = { id: eng.id, language: eng.language };
if (!chosen && targetIso) {
const m =
next.embedded.find((e) => e.renderable && e.language === targetIso)
?? next.sidecar.find((s) => s.language === targetIso);
if (m) chosen = { id: m.id, language: m.language };
}
if (!chosen) {
const tagged = next.sidecar.filter((s) => s.language != null);
if (tagged.length === 1) {
const sole = tagged[0]!;
chosen = { id: sole.id, language: sole.language };
if (sole.language) writeSubtitlePref(isoToPref(sole.language));
}
}
setSelectedSubId(chosen ? chosen.id : "off");
}
})
.catch(() => {
if (!live) return;
setSubSources({ embedded: [], sidecar: [] });
setSubSourcesLoading(false);
});
return () => { live = false; };
}, [code, partIdx, currentFile, subRefetchTick]);
// Auto-select a freshly generated sidecar (filename contains
// ".whisperjav.") when current selection is "off". Runs whenever
// subSources changes so it picks up post-job refetches reliably.
useEffect(() => {
if (!subSources) return;
const known = knownSidecarAbsRef.current;
const fresh = subSources.sidecar.find(
(s) => !known.has(s.abs) && s.filename.toLowerCase().includes(".whisperjav."),
);
// Update known set to include current sidecars so a fresh entry is
// only recognized once.
knownSidecarAbsRef.current = new Set(subSources.sidecar.map((s) => s.abs));
if (fresh) {
setSelectedSubId((curId) => {
if (curId !== "off") return curId;
// Persist language so future opens auto-apply on initial load.
if (fresh.language) writeSubtitlePref(isoToPref(fresh.language));
return fresh.id;
});
}
}, [subSources]);
// Compute the WebVTT URL for the currently selected source. Embedded
// and sidecar both go through the same track endpoint; "off" yields
// null which suppresses the <track> element entirely.
const trackUrl = useMemo(() => {
if (selectedSubId === "off") return null;
return `/api/video-subtitles/${encodeURIComponent(code)}/track?part=${partIdx}&src=${encodeURIComponent(selectedSubId)}`;
}, [code, partIdx, selectedSubId]);
const trackLang = useMemo<LangIso | null>(() => {
if (selectedSubId === "off" || !subSources) return null;
const emb = subSources.embedded.find((e) => e.id === selectedSubId);
if (emb) return emb.language;
const side = subSources.sidecar.find((s) => s.id === selectedSubId);
return side ? side.language : null;
}, [selectedSubId, subSources]);
const trackLabel = useMemo(() => {
if (selectedSubId === "off" || !subSources) return "Subtitles";
const emb = subSources.embedded.find((e) => e.id === selectedSubId);
if (emb) return emb.label;
const side = subSources.sidecar.find((s) => s.id === selectedSubId);
return side ? side.label : "Subtitles";
}, [selectedSubId, subSources]);
const onSubChange = useCallback((id: string | "off") => {
setSelectedSubId(id);
if (id === "off") { writeSubtitlePref("off"); return; }
const emb = subSources?.embedded.find((e) => e.id === id);
const side = subSources?.sidecar.find((s) => s.id === id);
const lang = emb?.language ?? side?.language ?? null;
if (lang) writeSubtitlePref(isoToPref(lang));
}, [subSources]);
// ───────────────────────── WhisperJAV generation ─────────────────────────
const whisperjavEnabled = !!settings.whisperjav?.cliPath;
/** True when the current sidecar list contains a WhisperJAV-generated
* file for the active variant. Drives the Generate/Regenerate label. */
const hasGenerated = useMemo(() => {
if (!subSources) return false;
return subSources.sidecar.some((s) => s.filename.toLowerCase().includes(".whisperjav."));
}, [subSources]);
interface WhisperJobState {
id: string;
startedAt: number | null;
enqueuedAt: number;
status: "queued" | "running" | "completed" | "warning" | "failed" | "cancelled";
stage: string | null;
stageIndex: number | null;
stageTotal: number | null;
error: string | null;
etaSec: number | null;
/** Wall-clock time the etaSec value was sampled. Used to count it
* down between polls without re-fetching. */
etaSampledAt: number | null;
}
const [activeJob, setActiveJob] = useState<WhisperJobState | null>(null);
const [tickNow, setTickNow] = useState(Date.now());
// Discover any in-flight job on open / variant change so reopening a
// modal during generation re-attaches to the same job.
useEffect(() => {
if (!whisperjavEnabled) return;
let live = true;
fetch(`/api/whisperjav-jobs?code=${encodeURIComponent(code)}`)
.then((r) => r.json())
.then((data: { jobs: Array<{ id: string; status: string; enqueuedAt: number; startedAt: number | null; stage: string | null; stageIndex: number | null; stageTotal: number | null; error: string | null }> }) => {
if (!live) return;
const inflight = (data.jobs ?? []).find((j) => j.status === "queued" || j.status === "running");
if (inflight) {
setActiveJob({
id: inflight.id,
startedAt: inflight.startedAt,
enqueuedAt: inflight.enqueuedAt,
status: inflight.status as WhisperJobState["status"],
stage: inflight.stage,
stageIndex: inflight.stageIndex,
stageTotal: inflight.stageTotal,
error: inflight.error,
etaSec: null,
etaSampledAt: null,
});
} else {
setActiveJob(null);
}
})
.catch(() => { /* ignore */ });
return () => { live = false; };
}, [code, whisperjavEnabled, partIdx]);
// Poll job state every 3s while a job is active. On completion, kick
// a source refetch so the new .srt appears in Sidecar. Effect deps
// are scalar so per-tick updates don't tear down the interval.
const activeJobId = activeJob?.id ?? null;
const activeJobLive =
activeJob?.status === "queued" || activeJob?.status === "running";
useEffect(() => {
if (!activeJobId || !activeJobLive) return;
let live = true;
const tick = async () => {
try {
const r = await fetch(`/api/whisperjav-jobs/${encodeURIComponent(activeJobId)}`);
if (!r.ok) return;
const data = await r.json() as {
status: string; startedAt: number | null; enqueuedAt: number;
stage: string | null; stageIndex: number | null; stageTotal: number | null;
error: string | null; etaSec: number | null;
};
if (!live) return;
setActiveJob({
id: activeJobId,
status: data.status as WhisperJobState["status"],
startedAt: data.startedAt,
enqueuedAt: data.enqueuedAt,
stage: data.stage,
stageIndex: data.stageIndex,
stageTotal: data.stageTotal,
error: data.error,
etaSec: data.etaSec,
etaSampledAt: data.etaSec != null ? Date.now() : null,
});
if (data.status === "completed" || data.status === "warning") {
refetchSources();
// Refresh the client-side video index so card badges update.
// The has_subtitle DB column refreshes naturally on next
// settings rescan or page navigation; calling /api/video-
// rescan here was triggering revalidatePath cascades.
dispatchVideoStatusRefresh();
}
} catch { /* transient — try again next tick */ }
};
const interval = setInterval(tick, 3000);
return () => { live = false; clearInterval(interval); };
}, [activeJobId, activeJobLive, refetchSources]);
// Drive the elapsed-seconds display.
useEffect(() => {
if (!activeJob || (activeJob.status !== "queued" && activeJob.status !== "running")) return;
const i = setInterval(() => setTickNow(Date.now()), 1000);
return () => clearInterval(i);
}, [activeJob]);
const runningSummary = useMemo(() => {
if (!activeJob) return null;
if (activeJob.status !== "queued" && activeJob.status !== "running") return null;
const start = activeJob.startedAt ?? activeJob.enqueuedAt;
const elapsedSec = Math.max(0, (tickNow - start) / 1000);
const status = activeJob.stage
? (activeJob.stageIndex && activeJob.stageTotal
? `Step ${activeJob.stageIndex}/${activeJob.stageTotal}: ${activeJob.stage}`
: activeJob.stage)
: (activeJob.status === "queued" ? "Queued" : "Starting...");
// Tick the ETA down between polls so it doesn't stutter — subtract
// wall-clock time elapsed since the server-sampled value.
let etaSec: number | null = null;
if (activeJob.etaSec != null && activeJob.etaSampledAt != null) {
const drift = (tickNow - activeJob.etaSampledAt) / 1000;
etaSec = Math.max(0, activeJob.etaSec - drift);
}
return { jobId: activeJob.id, elapsedSec, status, etaSec };
}, [activeJob, tickNow]);
const onGenerate = useCallback(async (overwrite: boolean) => {
if (!whisperjavEnabled) return;
try {
const r = await fetch("/api/whisperjav-jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, partIdx, overwrite }),
});
if (r.status === 409) {
// Already exists — confirm overwrite then retry.
const data = await r.json() as { abs: string };
const ok = window.confirm(`Subtitles already exist:\n${data.abs}\n\nRegenerate (overwrite)?`);
if (!ok) return;
await onGenerate(true);
return;
}
if (!r.ok) {
const data = await r.json().catch(() => ({})) as { error?: string };
window.alert(`Generate failed: ${data.error ?? r.statusText}`);
return;
}
const data = await r.json() as { jobId: string };
setActiveJob({
id: data.jobId,
status: "queued",
startedAt: null,
enqueuedAt: Date.now(),
stage: null,
stageIndex: null,
stageTotal: null,
error: null,
etaSec: null,
etaSampledAt: null,
});
} catch (e) {
window.alert(`Generate failed: ${(e as Error).message}`);
}
}, [code, partIdx, whisperjavEnabled]);
/** Two-stage cancel UI: confirm → "Cancelling..." → final status. */
const [cancelling, setCancelling] = useState(false);
// Reset when the active job changes (e.g. cancel completes, status
// flipped, or user kicked off a fresh job).
useEffect(() => {
if (!activeJob || activeJob.status !== "running") setCancelling(false);
}, [activeJob]);
const onCancelJob = useCallback(async () => {
if (!activeJob) return;
if (!window.confirm("Cancel subtitle generation?\n\nProgress will be lost. The video file is unchanged.")) return;
setCancelling(true);
try {
await fetch(`/api/whisperjav-jobs/${encodeURIComponent(activeJob.id)}/cancel`, { method: "POST" });
} catch { /* status will flip via polling */ }
}, [activeJob]);
const onBrowse = useCallback(async () => {
try {
const r = await fetch(`/api/pick-file`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose: "subtitle" }),
});
const data = await r.json() as { path?: string | null; cancelled?: boolean; error?: string };
if (data.cancelled || !data.path) return;
const abs = data.path;
const filename = abs.split(/[\\/]/).pop() ?? abs;
const ext = (() => {
const dot = filename.lastIndexOf(".");
return dot > 0 ? filename.slice(dot).toLowerCase() : "";
})();
const lang = detectLangClient(filename);
const id = "side:" + btoa(unescape(encodeURIComponent(abs)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const entry: SidecarEntry = {
id,
abs,
filename,
ext,
language: lang,
label: lang ? langDisplay(lang) : "Browsed",
origin: "manual",
};
// Persist the pick so it survives modal close and server restart.
// Done before optimistic UI update so server-side trust check
// (isAllowedSubtitlePath via manual_subtitles) is in place by the
// time the track endpoint is hit.
try {
await fetch(`/api/manual-subtitle/${encodeURIComponent(code)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partIdx, abs }),
});
} catch { /* persistence failed; entry still works for this session via session-trusted set */ }
setSubSources((cur) => {
const base = cur ?? { embedded: [], sidecar: [] };
// Replace any existing entry for the same abs path.
const sidecar = base.sidecar.filter((s) => s.id !== id);
sidecar.push(entry);
return { embedded: base.embedded, sidecar };
});
// Keep knownSidecarAbsRef in sync so the auto-select-on-fresh
// effect doesn't treat this entry as a new generation.
knownSidecarAbsRef.current = new Set([...knownSidecarAbsRef.current, abs]);
onSubChange(id);
} catch { /* picker failed — silently ignore */ }
}, [code, partIdx, onSubChange]);
/** Switch to part display index `p`, restoring the user's last
* variant pick for that part (or its default). */
function selectPart(p: number) {
if (!parts) return;
const part = parts[p];
if (!part) return;
const v = variantPickByPart[p] ?? part.defaultIdx;
const variant = part.variants[v] ?? part.variants[part.defaultIdx]!;
setPartIdx(variant.partIdx);
}
/** Switch to variant `v` within the current part. */
function selectVariantInCurrentPart(v: number) {
if (!parts || !currentLocation) return;
const part = parts[currentLocation.partDisplay]!;
const variant = part.variants[v];
if (!variant) return;
setVariantPickByPart((cur) => ({ ...cur, [currentLocation.partDisplay]: v }));
setPartIdx(variant.partIdx);
}
// Stable ref so the keyboard listeners never reattach during the
// modal's lifetime — every reattach is a window where Space could
// leak through to the page.
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
// Lock body scroll while the modal is open. Without this, Space (or
// any other scroll-triggering key) on the native video controls'
// shadow DOM can still nudge the underlying library page even though
// we have keydown listeners on document.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, []);
// Block native HTML5 drag on every element on the page while the modal
// is open. Even with `draggable={false}` on the <video>, Chrome's
// shadow-DOM slider thumbs occasionally let drag gestures escape onto
// whatever's beneath the cursor — usually a card image — and the
// browser starts dragging that. Adding a body class while the modal
// is mounted lets a CSS rule (in globals.css) globally disable
// user-drag, then restores normal behavior on unmount.
useEffect(() => {
document.body.classList.add("video-player-active");
return () => { document.body.classList.remove("video-player-active"); };
}, []);
// Persist volume + mute state across plays. Native <video> resets on
// each new element, so we read on mount and write on every change.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
try {
const rawVol = localStorage.getItem("pinkudex.videoVolume");
const rawMuted = localStorage.getItem("pinkudex.videoMuted");
const vol = rawVol == null ? null : Number(rawVol);
if (vol != null && Number.isFinite(vol) && vol >= 0 && vol <= 1) {
video.volume = vol;
}
// Honor anything truthy/falsy in the saved value, not just "0"|"1".
// Older builds may have stored "true"/"false". Skip only when the
// key is missing entirely so autoplay's default isn't clobbered
// for first-time users.
if (rawMuted != null) {
const lower = rawMuted.toLowerCase();
video.muted = !(lower === "0" || lower === "false" || lower === "");
}
} catch { /* localStorage may be disabled */ }
const onVolumeChange = () => {
try {
localStorage.setItem("pinkudex.videoVolume", String(video.volume));
localStorage.setItem("pinkudex.videoMuted", video.muted ? "1" : "0");
} catch { /* ignore */ }
};
video.addEventListener("volumechange", onVolumeChange);
return () => { video.removeEventListener("volumechange", onVolumeChange); };
// Re-run when the video element identity changes (new src / new key)
// so a remounted player still picks up the saved level.
}, [parts, resolved, partIdx]);
useEffect(() => {
const isTypingTarget = (e: Event) => {
const tgt = e.target as HTMLElement | null;
return !!(tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable));
};
const isSpace = (e: KeyboardEvent) => e.key === " " || e.code === "Space" || e.keyCode === 32;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { onCloseRef.current(); return; }
if (!isSpace(e)) return;
if (isTypingTarget(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play().catch(() => {});
else v.pause();
if (document.activeElement instanceof HTMLElement && document.activeElement !== v) {
document.activeElement.blur();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (!isSpace(e)) return;
if (isTypingTarget(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
};
const onKeyPress = (e: KeyboardEvent) => {
if (!isSpace(e)) return;
if (isTypingTarget(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
};
// Attach to BOTH window and document, capture phase, so we win every
// dispatch order regardless of which target the browser picks.
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", onKeyUp, true);
window.addEventListener("keypress", onKeyPress, true);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("keyup", onKeyUp, true);
document.addEventListener("keypress", onKeyPress, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", onKeyUp, true);
window.removeEventListener("keypress", onKeyPress, true);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("keyup", onKeyUp, true);
document.removeEventListener("keypress", onKeyPress, true);
};
}, []);
// After any pointer release, move keyboard focus OUT of the video
// entirely. Native video controls are in closed shadow DOM, and when
// the slider has focus Chrome handles Space at the engine level
// (slider thumb increment / page scroll fallback) before any JS
// listener fires. Putting focus on the modal container instead means
// Space hits our handlers above cleanly.
useEffect(() => {
const c = containerRef.current;
if (!c) return;
const v = videoRef.current;
const stealFocus = () => {
setTimeout(() => {
const cur = containerRef.current;
if (!cur) return;
// Don't yank focus from typing targets if any get added later.
const a = document.activeElement;
if (a instanceof HTMLElement && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) return;
cur.focus({ preventScroll: true });
}, 0);
};
document.addEventListener("pointerup", stealFocus, true);
document.addEventListener("mouseup", stealFocus, true);
document.addEventListener("touchend", stealFocus, true);
if (v) v.addEventListener("seeked", stealFocus);
stealFocus();
return () => {
document.removeEventListener("pointerup", stealFocus, true);
document.removeEventListener("mouseup", stealFocus, true);
document.removeEventListener("touchend", stealFocus, true);
if (v) v.removeEventListener("seeked", stealFocus);
};
}, [parts, partIdx]);
async function reveal() {
try {
await fetch(`/api/video-reveal/${encodeURIComponent(code)}?part=${partIdx}`, { method: "POST" });
} catch {}
}
const streamUrl = transcoding
? `/api/video-hls/${encodeURIComponent(code)}/playlist?part=${partIdx}`
: `/api/video-stream/${encodeURIComponent(code)}?part=${partIdx}`;
const metadataSummary = formatVideoSummary(metadata);
// Reset any stale error whenever the user switches stream sources.
// Without this, a fatal HLS error on one part survives a part change
// (or transcoding toggle) and locks the modal into the error UI even
// when the new stream loads cleanly.
useEffect(() => { setError(null); }, [streamUrl]);
// Chrome ignores `default` on dynamically inserted <track>. Force
// "showing" once metadata is ready and again when selection or
// stream changes — a streamUrl swap remounts <video> and resets all
// textTracks state.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (selectedSubId === "off") {
for (let i = 0; i < v.textTracks.length; i++) {
const t = v.textTracks[i];
if (t) t.mode = "disabled";
}
return;
}
const apply = () => {
const tracks = v.textTracks;
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i];
if (!t) continue;
t.mode = i === 0 ? "showing" : "disabled";
}
};
apply();
v.addEventListener("loadedmetadata", apply);
return () => v.removeEventListener("loadedmetadata", apply);
}, [selectedSubId, trackUrl, streamUrl]);
// Apply the user's timing offset to the active text track. Snapshots
// each cue's original start/end on first sight so re-applying a
// different offset stays cumulative-free. Native VTTCue.startTime /
// endTime are writable in modern Chromium / Firefox.
useEffect(() => {
if (selectedSubId === "off") return;
const v = videoRef.current;
if (!v) return;
type ShiftableCue = TextTrackCue & { __pinkOriginalStart?: number; __pinkOriginalEnd?: number };
const apply = () => {
const track = v.textTracks[0];
if (!track || !track.cues) return;
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i] as ShiftableCue;
if (cue.__pinkOriginalStart === undefined) {
cue.__pinkOriginalStart = cue.startTime;
cue.__pinkOriginalEnd = cue.endTime;
}
const newStart = Math.max(0, cue.__pinkOriginalStart + subOffsetSec);
const newEnd = Math.max(newStart + 0.001, (cue.__pinkOriginalEnd ?? 0) + subOffsetSec);
cue.startTime = newStart;
cue.endTime = newEnd;
}
};
// Cues populate asynchronously after the <track> fetches its VTT.
// Listen for the track's load + cuechange events plus the video's
// loadedmetadata so we cover every population path.
apply();
const track = v.textTracks[0];
const onCueChange = () => apply();
track?.addEventListener("cuechange", onCueChange);
v.addEventListener("loadedmetadata", apply);
// Polling fallback: cues sometimes appear after both events have
// already fired. Cheap walk over a short window.
const pollUntil = Date.now() + 4000;
const interval = setInterval(() => {
apply();
if (Date.now() > pollUntil) clearInterval(interval);
}, 200);
return () => {
clearInterval(interval);
track?.removeEventListener("cuechange", onCueChange);
v.removeEventListener("loadedmetadata", apply);
};
}, [selectedSubId, trackUrl, streamUrl, subOffsetSec]);
// hls.js drives MSE for transcoded HLS streams. Native <video src> works
// for direct streams. Attach/detach lifecycle is keyed off streamUrl so
// changing parts or toggling transcoding cleans up the previous session.
useEffect(() => {
if (!transcoding) return;
const video = videoRef.current;
if (!video) return;
// Clear any prior fatal-error state before starting a fresh load.
// Without this reset, switching parts after a fatal HLS error keeps
// the modal stuck on the error screen even when the new stream is
// playing fine in the background.
setError(null);
let cancelled = false;
let hls: import("hls.js").default | null = null;
(async () => {
const HlsMod = (await import("hls.js")).default;
if (cancelled) return;
if (HlsMod.isSupported()) {
hls = new HlsMod({ enableWorker: true, lowLatencyMode: false });
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(HlsMod.Events.ERROR, (_e, data) => {
// Effect cleanup may have already fired (mode swap, modal close).
// Don't surface errors to a player that's about to be torn down.
if (cancelled) return;
if (!data.fatal) return;
// eslint-disable-next-line no-console
console.error("[hls.js fatal]", JSON.stringify(data, null, 2), data);
// Try the canonical hls.js recovery pattern for fatal errors
// before giving up. Network errors are often transient (dev
// server restart, hot-reload, brief 404 from stale video
// index) — startLoad pulls the playlist again.
try {
if (data.type === HlsMod.ErrorTypes.NETWORK_ERROR) {
hls?.startLoad();
return;
}
if (data.type === HlsMod.ErrorTypes.MEDIA_ERROR) {
hls?.recoverMediaError();
return;
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("[hls.js recover failed]", e);
}
// Recovery either failed or the error type isn't recoverable —
// build a useful message from whatever fields the payload has.
const parts: string[] = [];
if (data.type) parts.push(String(data.type));
if (data.details) parts.push(String(data.details));
if (data.response?.code) parts.push(`http ${data.response.code}`);
if (data.error?.message) parts.push(String(data.error.message));
if (data.url) parts.push(String(data.url));
setError(`HLS error: ${parts.length ? parts.join(" · ") : "unknown (likely aborted load or network blip)"}`);
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS (Safari). hls.js not needed.
video.src = streamUrl;
} else {
setError("HLS not supported in this browser");
}
})();
return () => {
cancelled = true;
try { hls?.destroy(); } catch { /* ignore */ }
};
// `files` in the deps so the effect re-runs once the modal's pre-flight
// /api/video-files fetch completes and the <video> element actually
// mounts. Without it, this effect fires once with a null videoRef.
}, [streamUrl, transcoding, parts]);
if (typeof document === "undefined") return null;
return createPortal(
<div
ref={containerRef}
tabIndex={-1}
className="fixed inset-0 z-[100] grid place-items-center bg-black/85 backdrop-blur-sm p-4 fade-in outline-none"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="w-full max-w-[1400px] rounded-2xl bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl overflow-hidden flex flex-col"
style={{ height: "calc(100vh - 32px)" }}
>
<div className="border-b border-[var(--color-glass-border)]">
<div className="flex items-center justify-between gap-4 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Playing</div>
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-mono text-[var(--color-cyan)] font-semibold shrink-0">{code}</span>
{actresses && actresses.length > 0 && (
<span className="text-sm text-[var(--color-fg-dim)] truncate">
{actresses.map((a) => a.name).join(", ")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{runningSummary && (
<span
title={cancelling ? "Cancelling generation..." : (runningSummary.status ?? "Generating subtitles")}
className={cn(
"inline-flex items-center gap-1.5 text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border",
cancelling
? "border-red-400/40 bg-red-500/10 text-red-300"
: "border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]",
)}
>
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
{cancelling ? (
<>
<span>Cancelling</span>
<span className="opacity-75">· {formatJobElapsed(runningSummary.elapsedSec)}</span>
</>
) : (
<>
<span>Generating</span>
<span className="opacity-75">· {formatJobElapsed(runningSummary.elapsedSec)}</span>
{runningSummary.etaSec != null && runningSummary.etaSec > 5 && (
<span className="opacity-75" title="Estimated time remaining">
· ETA {formatJobElapsed(runningSummary.etaSec)}
</span>
)}
{runningSummary.status && (
<span className="opacity-75 normal-case truncate max-w-[180px]">· {runningSummary.status}</span>
)}
<button
type="button"
onClick={onCancelJob}
title="Cancel generation"
className="ml-1 p-0.5 rounded hover:bg-red-500/15 text-red-300 cursor-pointer"
>
<X className="w-3 h-3" />
</button>
</>
)}
</span>
)}
{probing && (
<span
title="Probing codec metadata"
className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-fg-muted)]/40 bg-[var(--color-glass)] text-[var(--color-fg-muted)]"
>
<Loader2 className="w-3 h-3 animate-spin shrink-0" /> Probing
</span>
)}
{!probing && measuring && (
<span
title="Watching dropped-frame count on live playback to decide direct vs transcode"
className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-amber-400/40 bg-amber-400/10 text-amber-200"
>
<Loader2 className="w-3 h-3 animate-spin shrink-0" /> Measuring
</span>
)}
{!probing && !measuring && resolved === "transcode" && (
<span
title="Live NVENC transcode (no B-frames) — bypasses Chrome H.264 stutter"
className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/40 bg-[var(--color-coral)]/10 text-[var(--color-coral)]"
>
<span className="relative flex h-3 w-3 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-coral)] opacity-75 animate-ping" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-coral)] shadow-[0_0_10px_var(--color-coral)]" />
</span>
Transcoding
</span>
)}
{!probing && !measuring && resolved === "direct" && (
<span
title="Streaming the original file directly — no re-encode"
className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-mint)]/40 bg-[var(--color-mint)]/10 text-[var(--color-mint)]"
>
<PlayCircle className="w-3 h-3 shrink-0" /> Direct Play
</span>
)}
<SubtitlePicker
sources={subSources}
selectedId={selectedSubId}
loading={subSourcesLoading}
onChange={onSubChange}
onBrowse={onBrowse}
generateEnabled={whisperjavEnabled}
hasGenerated={hasGenerated}
runningJob={runningSummary}
onGenerate={onGenerate}
onCancel={onCancelJob}
offsetSec={subOffsetSec}
onOffsetChange={setSubOffsetSec}
/>
<button
type="button"
onClick={reveal}
title="Reveal in folder"
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
>
<ExternalLink className="w-3.5 h-3.5" /> Reveal in Folder
</button>
<button
onClick={onClose}
aria-label="Close"
className="p-1 rounded hover:bg-[var(--color-glass)]"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{(metadataSummary || currentFile?.filename) && (
<div className="px-5 pb-2 -mt-1 flex items-baseline justify-between gap-4 text-[11px] font-mono">
{metadataSummary ? (
<div className="flex items-baseline gap-2 min-w-0 text-[var(--color-fg-dim)]">
<span
className={
resolved === "transcode"
? "shrink-0 text-[var(--color-coral)]"
: "shrink-0 text-[var(--color-cyan)]"
}
></span>
<span className="whitespace-nowrap">{metadataSummary}</span>
</div>
) : <span />}
{currentFile?.filename && (
<span
className="ml-auto min-w-0 truncate text-[var(--color-fg-dim)]"
title={currentFile.filename}
>
{currentFile.filename}
</span>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 grid place-items-center bg-black overflow-hidden">
{error ? (
<div className="text-center px-6 py-12 text-[var(--color-fg-dim)]">
<AlertTriangle className="w-8 h-8 mx-auto text-[var(--color-coral)] mb-2" />
<div className="text-sm">{error}</div>
<div className="text-xs mt-2">If the codec isn&apos;t playable in-browser, try the Reveal in Folder button.</div>
</div>
) : parts === null || resolved === null ? (
<div className="text-[var(--color-fg-muted)] flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
{probing && (
<span className="text-[11px] font-mono">probing playback quality</span>
)}
</div>
) : (
<video
ref={videoRef}
key={streamUrl}
// For transcoded HLS streams, the source is attached via the
// hls.js useEffect above. For direct streams it's set here.
src={transcoding ? undefined : streamUrl}
controls
autoPlay
tabIndex={0}
// Chrome treats <video> as HTML5-draggable by default. When
// the user click-and-drags on the scrubber or volume slider,
// Chrome interprets the drag past a small threshold as
// "drag the video element out" and starts a native drag
// operation, snatching whatever element is at the cursor
// (often a card on the page beneath). Killing the drag
// capability keeps slider drags inside the shadow-DOM
// controls where they belong.
draggable={false}
onDragStart={(e) => e.preventDefault()}
className="w-full h-full max-h-[calc(100vh-200px)] outline-none"
>
{trackUrl && (
<track
key={selectedSubId}
kind="subtitles"
src={trackUrl}
srcLang={trackLang ?? undefined}
label={trackLabel}
default
/>
)}
</video>
)}
</div>
{parts && (parts.length > 1 || (currentLocation && parts[currentLocation.partDisplay]!.variants.length > 1)) && (
<div className="px-5 py-2 border-t border-[var(--color-glass-border)] flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-dim)] shrink-0">Parts</span>
<div className="flex items-center gap-1.5 overflow-x-auto min-w-0">
{parts.map((p, i) => {
const v = p.variants[variantPickByPart[i] ?? p.defaultIdx] ?? p.variants[p.defaultIdx]!;
const active = currentLocation?.partDisplay === i;
const summary = formatVideoSummary(v.metadata);
const compact = formatVideoSummary(v.metadata, true);
return (
<button
key={`${i}-${v.rel}`}
type="button"
onClick={() => selectPart(i)}
title={summary ? `${v.filename} · ${summary}` : v.filename}
className={
active
? "text-xs font-mono px-3 py-1 rounded-md bg-[var(--color-cyan)] text-black font-semibold whitespace-nowrap"
: "text-xs font-mono px-3 py-1 rounded-md glass glass-hover whitespace-nowrap"
}
>
{p.partIndex}
{compact && <span className="ml-1 opacity-75">{compact}</span>}
</button>
);
})}
</div>
{currentLocation && parts[currentLocation.partDisplay]!.variants.length > 1 && (
<div className="ml-auto shrink-0">
<VariantPicker
options={parts[currentLocation.partDisplay]!.variants.map((v) => ({
id: v.partIdx,
label: v.label,
filename: v.filename,
}))}
selectedId={partIdx}
onChange={(id) => {
const idx = parts[currentLocation.partDisplay]!.variants.findIndex((v) => v.partIdx === id);
if (idx >= 0) selectVariantInCurrentPart(idx);
}}
/>
</div>
)}
</div>
)}
</div>
</div>,
document.body,
);
}