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" />
🎬 TGSTREAM
setQuery(event.target.value)} placeholder="Search movies..." />
{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)} /> )}
); }