import { useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_API = typeof window === "undefined" ? "http://localhost:8000" : window.location.origin;
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
function Toast({ toast }) {
if (!toast) return null;
return (
{toast.message}
);
}
function MovieCard({ movie, apiBase, onOpen }) {
const [imgError, setImgError] = useState(false);
const thumbUrl = `${apiBase}/thumb/${movie.id}`;
const showFallback = !movie.has_thumb || imgError;
return (
);
}
function Modal({ movie, apiBase, onClose }) {
const videoRef = useRef(null);
const warningText = useMemo(() => {
const title = (movie?.title || "").toLowerCase();
if (!title) return "";
const hasHevc = title.includes("hevc") || title.includes("x265") || title.includes("h265");
const has10bit = title.includes("10bit") || title.includes("10-bit") || title.includes("10 bit");
if (hasHevc || has10bit) {
return "This file looks like HEVC/x265 or 10-bit. Most browsers cannot play it. Use Open Stream or a compatible file.";
}
return "";
}, [movie]);
useEffect(() => {
const handle = (event) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [onClose]);
const handlePlay = async () => {
if (!videoRef.current) return;
const video = videoRef.current;
const botUrl = `${apiBase}/stream-bot/${movie.id}`;
const fallbackUrl = `${apiBase}/stream/${movie.id}`;
const handleError = async () => {
video.removeEventListener("error", handleError);
video.src = fallbackUrl;
try {
await video.play();
} catch {
// ignore autoplay errors
}
};
video.addEventListener("error", handleError, { once: true });
video.src = botUrl;
try {
await video.play();
} catch {
// ignore autoplay errors
}
};
const handleStop = () => {
if (!videoRef.current) return;
videoRef.current.pause();
videoRef.current.removeAttribute("src");
videoRef.current.load();
};
const handleOpenStream = () => {
window.open(`${apiBase}/stream/${movie.id}`, "_blank");
};
return (
event.stopPropagation()}>
{movie.title}
{movie.duration} · {movie.size} · {movie.width || "--"}x{movie.height || "--"}
{warningText &&
{warningText}
}
);
}
export default function App() {
const [apiUrl, setApiUrl] = useState(DEFAULT_API);
const [movies, setMovies] = useState([]);
const [filtered, setFiltered] = useState([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState("");
const [toast, setToast] = useState(null);
const [nextOffsetId, setNextOffsetId] = useState(null);
const gridRef = useRef(null);
const debouncedQuery = useDebounce(query, 300);
const apiBase = useMemo(() => apiUrl.replace(/\/+$/, ""), [apiUrl]);
useEffect(() => {
if (!toast) return undefined;
const handle = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(handle);
}, [toast]);
useEffect(() => {
if (!gridRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextOffsetId && !loadingMore && !loading) {
loadMore();
}
},
{ threshold: 0.1 }
);
const sentinel = gridRef.current.querySelector(".load-more-sentinel");
if (sentinel) {
observer.observe(sentinel);
}
return () => observer.disconnect();
}, [nextOffsetId, loadingMore, loading]);
const showToast = (message, type) => {
setToast({ message, type });
};
const fetchMovies = async (refresh = false, appendMode = false) => {
if (appendMode) {
setLoadingMore(true);
} else {
setLoading(true);
setNextOffsetId(null);
}
setError("");
try {
const offset = appendMode && nextOffsetId ? `&offset_id=${nextOffsetId}` : "";
const response = await fetch(`${apiBase}/movies?refresh=${refresh}&limit=500${offset}`);
if (!response.ok) {
throw new Error("Failed to fetch movies");
}
const data = await response.json();
const items = Array.isArray(data) ? data : data.items || [];
if (appendMode) {
setMovies((prev) => [...prev, ...items]);
} else {
setMovies(items);
setFiltered(items);
showToast("Movies loaded", "success");
}
setNextOffsetId(data.next_offset_id || null);
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load movies";
setError(message);
showToast(message, "error");
} finally {
if (appendMode) {
setLoadingMore(false);
} else {
setLoading(false);
}
}
};
const loadMore = async () => {
if (!nextOffsetId) return;
await fetchMovies(false, true);
};
useEffect(() => {
if (!debouncedQuery) {
setFiltered(movies);
return;
}
const lower = debouncedQuery.toLowerCase();
const local = movies.filter((movie) => movie.title.toLowerCase().includes(lower));
setFiltered(local);
if (local.length === 0 && movies.length > 0) {
fetch(`${apiBase}/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((res) => (res.ok ? res.json() : []))
.then((data) => {
const items = Array.isArray(data) ? data : data.items || [];
setFiltered(items);
})
.catch(() => setFiltered([]));
}
}, [debouncedQuery, movies, apiBase]);
const statsLabel = debouncedQuery
? `${filtered.length} results for '${debouncedQuery}'`
: `${movies.length} movies${nextOffsetId ? " (scroll to load more)" : ""}`;
return (
setApiUrl(event.target.value)}
placeholder="Backend URL"
/>
{statsLabel}
{error &&
{error}
}
{loading
? Array.from({ length: 12 }).map((_, index) => (
))
: filtered.map((movie) => (
))}
{filtered.length === 0 && !loading && movies.length === 0 && (
No movies yet. Click Connect to start.
)}
{nextOffsetId && (
{loadingMore ? (
<>
Loading more...
>
) : (
"Scroll to load more"
)}
)}
{selected && (
setSelected(null)}
/>
)}
);
}