siftlode/frontend/src/components/PlexPlayer.tsx

491 lines
16 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
ArrowLeft,
Download,
Maximize,
Minimize,
Pause,
Play,
RotateCcw,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
// restart the session at the target. Watch state / resume persist to plex_states.
type Props = { itemId: string; onClose: () => void };
function fmt(t: number): string {
if (!isFinite(t) || t < 0) t = 0;
const s = Math.floor(t % 60);
const m = Math.floor((t / 60) % 60);
const h = Math.floor(t / 3600);
const pad = (n: number) => String(n).padStart(2, "0");
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export default function PlexPlayer({ itemId, onClose }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const [id, setId] = useState(itemId);
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
const detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0;
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
const durationRef = useRef(0);
const [playing, setPlaying] = useState(false);
const [abs, setAbs] = useState(0); // absolute current time
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [fs, setFs] = useState(false);
const [ready, setReady] = useState(false);
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
durationRef.current = duration;
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
const loadSession = useCallback(
async (startAt: number) => {
const video = videoRef.current;
if (!video) return;
setReady(false);
let sess;
try {
sess = await api.plexSession(id, startAt);
} catch {
return;
}
sessionStartRef.current = sess.start_s;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setReady(true);
video.play().catch(() => {});
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta);
};
video.addEventListener("loadedmetadata", onMeta);
}
},
[id],
);
// Start playback when the detail arrives (resume from the saved position unless already watched).
useEffect(() => {
if (!detail) return;
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
loadSession(resume);
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [detail, loadSession]);
// --- time tracking + progress checkpoints ----------------------------------------------------
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => {
const a = sessionStartRef.current + video.currentTime;
setAbs(a);
try {
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
setSeekableEnd(end);
} catch {
/* ignore */
}
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause);
return () => {
video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause);
};
}, [id]);
// Periodic + on-unmount progress checkpoint.
const absRef = useRef(0);
absRef.current = abs;
useEffect(() => {
const flush = () => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
};
const iv = window.setInterval(flush, 10000);
return () => {
window.clearInterval(iv);
flush();
qc.invalidateQueries({ queryKey: ["plex-browse"] });
qc.invalidateQueries({ queryKey: ["plex-show"] });
};
}, [id, qc]);
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
(target: number) => {
const video = videoRef.current;
if (!video) return;
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
video.currentTime = rel;
} else {
loadSession(T);
}
},
[loadSession],
);
// --- controls --------------------------------------------------------------------------------
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play().catch(() => {});
else v.pause();
}, []);
const applyVolume = useCallback((vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = Math.max(0, Math.min(1, vol));
v.muted = mute;
}, []);
const nudgeVolume = useCallback(
(d: number) => {
setVolume((prev) => {
const nv = Math.max(0, Math.min(1, prev + d));
applyVolume(nv, false);
setMuted(nv === 0);
return nv;
});
},
[applyVolume],
);
const toggleFs = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else el.requestFullscreen().catch(() => {});
}, []);
useEffect(() => {
const onFs = () => setFs(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFs);
return () => document.removeEventListener("fullscreenchange", onFs);
}, []);
const go = useCallback(
(otherId: string | null | undefined) => {
if (otherId) {
setAbs(0);
setId(otherId);
}
},
[],
);
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
const a = document.createElement("a");
a.href = api.plexDownloadUrl(id);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}, [id]);
// Auto-advance to the next episode when one finishes.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onEnded = () => {
api.plexSetState(id, "watched").catch(() => {});
if (detail?.next_id) go(detail.next_id);
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, detail, go]);
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setUiVisible(false);
}, 3000);
}, []);
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
toggleFs();
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(detail?.prev_id);
else seekTo(absRef.current - 10);
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(detail?.next_id);
else seekTo(absRef.current + 10);
break;
case "ArrowUp":
e.preventDefault();
nudgeVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
nudgeVolume(-0.05);
break;
case "m":
setMuted((m) => {
applyVolume(volume, !m);
return !m;
});
break;
case "Escape":
if (!document.fullscreenElement) onClose();
break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const pct = duration > 0 ? (abs / duration) * 100 : 0;
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
return (
<div
ref={wrapRef}
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
onMouseMove={wake}
onClick={wake}
style={{ cursor: uiVisible ? "default" : "none" }}
>
<video
ref={videoRef}
className="max-h-full max-w-full w-auto h-auto"
onClick={togglePlay}
playsInline
/>
{!ready && (
<div className="absolute inset-0 grid place-items-center text-white/70 text-sm pointer-events-none">
{t("plex.player.loading")}
</div>
)}
{/* Top bar */}
<div
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0"
}`}
>
<div className="flex items-center gap-3 text-white">
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="min-w-0">
<div className="font-semibold truncate">
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
</div>
{detail?.kind === "episode" && (
<div className="text-xs text-white/70 truncate">
S{detail.season_number}·E{detail.episode_number} {detail.title}
</div>
)}
</div>
</div>
</div>
{/* Skip intro / credits */}
{activeMarker && (
<button
onClick={() => seekTo(activeMarker.end_s)}
className={`absolute right-6 bottom-28 px-4 py-2 rounded-lg bg-white/90 text-black text-sm font-medium hover:bg-white transition ${
uiVisible ? "opacity-100" : "opacity-80"
}`}
>
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
</button>
)}
{/* Bottom controls */}
<div
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
{/* Seek bar */}
<div
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
onClick={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
seekTo(((e.clientX - r.left) / r.width) * duration);
}}
>
<div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-amber-400/50"
style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
/>
))}
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${pct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent opacity-0 group-hover:opacity-100"
style={{ left: `${pct}%` }}
/>
</div>
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
{detail?.kind === "episode" && (
<>
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
<SkipBack className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
<SkipForward className="w-5 h-5" />
</Ctrl>
</>
)}
<div className="flex items-center gap-1.5 group/vol">
<Ctrl
label={t("plex.player.mute")}
onClick={() =>
setMuted((m) => {
applyVolume(volume, !m);
return !m;
})
}
>
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</Ctrl>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => {
const nv = Number(e.target.value);
setVolume(nv);
setMuted(nv === 0);
applyVolume(nv, false);
}}
className="w-20 accent-accent"
/>
</div>
<div className="text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
</div>
<div className="ml-auto flex items-center gap-2">
<Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
</div>
</div>
</div>
</div>
);
}
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<div className="relative flex group/ctrl">
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
>
{children}
</button>
<span className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100">
{label}
</span>
</div>
);
}