1396 lines
58 KiB
TypeScript
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'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,
|
|
);
|
|
}
|