| 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 ( |
| <div className={`toast ${toast.type}`}> |
| <span>{toast.message}</span> |
| </div> |
| ); |
| } |
|
|
| function MovieCard({ movie, apiBase, onOpen }) { |
| const [imgError, setImgError] = useState(false); |
| const thumbUrl = `${apiBase}/thumb/${movie.id}`; |
| const showFallback = !movie.has_thumb || imgError; |
|
|
| return ( |
| <button className="card" type="button" onClick={() => onOpen(movie)}> |
| <div className="thumb"> |
| {showFallback ? ( |
| <div className="thumb-fallback">🎞</div> |
| ) : ( |
| <img |
| src={thumbUrl} |
| alt={movie.title} |
| loading="lazy" |
| onError={() => setImgError(true)} |
| /> |
| )} |
| <div className="thumb-overlay">▶</div> |
| <div className="badge">{movie.duration}</div> |
| </div> |
| <div className="card-body"> |
| <h3 title={movie.title}>{movie.title}</h3> |
| <p> |
| {movie.size} · {movie.width || "--"}x{movie.height || "--"} |
| </p> |
| </div> |
| </button> |
| ); |
| } |
|
|
| 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 { |
| |
| } |
| }; |
|
|
| video.addEventListener("error", handleError, { once: true }); |
| video.src = botUrl; |
| try { |
| await video.play(); |
| } catch { |
| |
| } |
| }; |
|
|
| 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 ( |
| <div className="modal-backdrop" onClick={onClose} role="presentation"> |
| <div className="modal" onClick={(event) => event.stopPropagation()}> |
| <button className="close" onClick={onClose} type="button"> |
| × |
| </button> |
| <div className="modal-video"> |
| <video ref={videoRef} controls /> |
| </div> |
| <div className="modal-meta"> |
| <h2>{movie.title}</h2> |
| <p> |
| {movie.duration} · {movie.size} · {movie.width || "--"}x{movie.height || "--"} |
| </p> |
| {warningText && <div className="codec-warning">{warningText}</div>} |
| <div className="modal-actions"> |
| <button className="primary" type="button" onClick={handlePlay}> |
| Play |
| </button> |
| <button type="button" onClick={handleStop}> |
| Stop |
| </button> |
| <button type="button" onClick={handleOpenStream}> |
| Open Stream |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| 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 ( |
| <div className="app"> |
| <style>{` |
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); |
| |
| :root { |
| color-scheme: dark; |
| --bg: #0b0b0f; |
| --surface: #13131a; |
| --surface-2: #181823; |
| --accent: #e8b84b; |
| --muted: #7a7a8c; |
| --text: #f5f5f7; |
| --danger: #ef4444; |
| --success: #10b981; |
| } |
| |
| * { |
| box-sizing: border-box; |
| font-family: 'DM Sans', system-ui, sans-serif; |
| } |
| |
| body { |
| margin: 0; |
| background: radial-gradient(circle at top, #1a1a27 0%, #0b0b0f 50%); |
| color: var(--text); |
| min-height: 100vh; |
| } |
| |
| #root { |
| min-height: 100vh; |
| } |
| |
| .app { |
| padding-top: 120px; |
| padding-bottom: 48px; |
| } |
| |
| .config-bar { |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| padding: 12px 24px; |
| background: #0d0d14ee; |
| backdrop-filter: blur(12px); |
| z-index: 20; |
| border-bottom: 1px solid #1f1f2d; |
| } |
| |
| .config-bar input { |
| flex: 1; |
| padding: 10px 12px; |
| border-radius: 8px; |
| border: 1px solid #262636; |
| background: #0f0f18; |
| color: var(--text); |
| } |
| |
| .config-bar button, |
| .header button, |
| .modal-actions button { |
| padding: 10px 16px; |
| border-radius: 8px; |
| border: 1px solid transparent; |
| background: var(--surface-2); |
| color: var(--text); |
| cursor: pointer; |
| transition: transform 0.2s ease, background 0.2s ease; |
| } |
| |
| .config-bar button.primary, |
| .header button.primary, |
| .modal-actions button.primary { |
| background: var(--accent); |
| color: #1b1503; |
| font-weight: 600; |
| } |
| |
| .config-bar button:hover, |
| .header button:hover, |
| .modal-actions button:hover { |
| transform: translateY(-1px); |
| } |
| |
| .header { |
| position: sticky; |
| top: 56px; |
| z-index: 10; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 16px 24px; |
| background: linear-gradient(135deg, #141422, #0f0f18); |
| border-bottom: 1px solid #1c1c2a; |
| } |
| |
| .logo { |
| font-size: 20px; |
| font-weight: 700; |
| letter-spacing: 0.08em; |
| } |
| |
| .search { |
| flex: 1; |
| margin: 0 16px; |
| position: relative; |
| } |
| |
| .search input { |
| width: 100%; |
| padding: 10px 12px; |
| border-radius: 8px; |
| border: 1px solid #27273a; |
| background: #10101a; |
| color: var(--text); |
| } |
| |
| .stats { |
| margin: 16px 24px 0; |
| color: var(--muted); |
| } |
| |
| .grid { |
| padding: 20px 24px; |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| gap: 20px; |
| } |
| |
| .card { |
| background: var(--surface); |
| border-radius: 14px; |
| overflow: hidden; |
| border: 1px solid #1f1f2a; |
| color: inherit; |
| text-align: left; |
| padding: 0; |
| cursor: pointer; |
| transition: transform 0.2s ease, box-shadow 0.2s ease; |
| } |
| |
| .card:hover { |
| transform: translateY(-4px); |
| box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4); |
| } |
| |
| .thumb { |
| position: relative; |
| height: 150px; |
| background: #09090f; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .thumb img { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| display: block; |
| } |
| |
| .thumb-fallback { |
| font-size: 32px; |
| color: var(--accent); |
| } |
| |
| .thumb-overlay { |
| position: absolute; |
| inset: 0; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| background: rgba(0, 0, 0, 0.4); |
| opacity: 0; |
| font-size: 32px; |
| transition: opacity 0.2s ease; |
| } |
| |
| .card:hover .thumb-overlay { |
| opacity: 1; |
| } |
| |
| .badge { |
| position: absolute; |
| right: 10px; |
| bottom: 10px; |
| background: rgba(0, 0, 0, 0.75); |
| padding: 4px 8px; |
| border-radius: 6px; |
| font-size: 12px; |
| } |
| |
| .card-body { |
| padding: 12px; |
| } |
| |
| .card-body h3 { |
| margin: 0 0 6px; |
| font-size: 15px; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .card-body p { |
| margin: 0; |
| color: var(--muted); |
| font-size: 13px; |
| } |
| |
| .skeleton { |
| position: relative; |
| overflow: hidden; |
| background: #161620; |
| border-radius: 14px; |
| height: 230px; |
| } |
| |
| .skeleton::after { |
| content: ''; |
| position: absolute; |
| top: 0; |
| left: -100%; |
| width: 100%; |
| height: 100%; |
| background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); |
| animation: shimmer 1.4s infinite; |
| } |
| |
| @keyframes shimmer { |
| 100% { |
| left: 100%; |
| } |
| } |
| |
| .load-more-sentinel { |
| grid-column: 1 / -1; |
| text-align: center; |
| padding: 20px; |
| color: var(--muted); |
| } |
| |
| .load-more-spinner { |
| display: inline-block; |
| width: 20px; |
| height: 20px; |
| border: 2px solid var(--muted); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| |
| .modal-backdrop { |
| position: fixed; |
| inset: 0; |
| background: rgba(5, 5, 10, 0.8); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 30; |
| padding: 20px; |
| } |
| |
| .modal { |
| background: #10101a; |
| border-radius: 18px; |
| max-width: 900px; |
| width: 100%; |
| padding: 24px; |
| border: 1px solid #2a2a3d; |
| position: relative; |
| } |
| |
| .modal-video { |
| background: #000; |
| border-radius: 12px; |
| overflow: hidden; |
| } |
| |
| .modal-video video { |
| width: 100%; |
| max-height: 450px; |
| } |
| |
| .modal-meta { |
| margin-top: 16px; |
| } |
| |
| .modal-meta h2 { |
| margin: 0 0 8px; |
| } |
| |
| .modal-meta p { |
| margin: 0 0 16px; |
| color: var(--muted); |
| } |
| |
| .codec-warning { |
| margin: 0 0 16px; |
| padding: 10px 12px; |
| border-radius: 10px; |
| border: 1px solid #3a2b12; |
| background: rgba(232, 184, 75, 0.08); |
| color: #f0d58a; |
| font-size: 13px; |
| line-height: 1.4; |
| } |
| |
| .modal-actions { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| |
| .close { |
| position: absolute; |
| top: 12px; |
| right: 12px; |
| background: transparent; |
| border: none; |
| color: var(--muted); |
| font-size: 24px; |
| cursor: pointer; |
| } |
| |
| .toast { |
| position: fixed; |
| right: 24px; |
| bottom: 24px; |
| padding: 12px 16px; |
| border-radius: 10px; |
| background: #12121a; |
| border: 1px solid; |
| z-index: 40; |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); |
| } |
| |
| .toast.success { |
| border-color: var(--success); |
| } |
| |
| .toast.error { |
| border-color: var(--danger); |
| } |
| |
| .error { |
| margin: 12px 24px; |
| color: var(--danger); |
| } |
| |
| @media (max-width: 720px) { |
| .config-bar, |
| .header { |
| flex-direction: column; |
| align-items: stretch; |
| } |
| |
| .header { |
| top: 96px; |
| gap: 12px; |
| } |
| |
| .search { |
| margin: 0; |
| } |
| } |
| `}</style> |
| |
| <div className="config-bar"> |
| <input |
| value={apiUrl} |
| onChange={(event) => setApiUrl(event.target.value)} |
| placeholder="Backend URL" |
| /> |
| <button className="primary" type="button" onClick={() => fetchMovies(false)}> |
| Connect |
| </button> |
| </div> |
| |
| <header className="header"> |
| <div className="logo">🎬 TGSTREAM</div> |
| <div className="search"> |
| <input |
| value={query} |
| onChange={(event) => setQuery(event.target.value)} |
| placeholder="Search movies..." |
| /> |
| </div> |
| <button className="primary" type="button" onClick={() => fetchMovies(true)}> |
| Refresh |
| </button> |
| </header> |
| |
| <div className="stats">{statsLabel}</div> |
| |
| {error && <div className="error">{error}</div>} |
| |
| <section className="grid" ref={gridRef}> |
| {loading |
| ? Array.from({ length: 12 }).map((_, index) => ( |
| <div className="skeleton" key={`skeleton-${index}`} /> |
| )) |
| : filtered.map((movie) => ( |
| <MovieCard key={movie.id} movie={movie} apiBase={apiBase} onOpen={setSelected} /> |
| ))} |
| |
| {filtered.length === 0 && !loading && movies.length === 0 && ( |
| <div className="load-more-sentinel">No movies yet. Click Connect to start.</div> |
| )} |
| |
| {nextOffsetId && ( |
| <div className="load-more-sentinel"> |
| {loadingMore ? ( |
| <> |
| <div className="load-more-spinner" /> Loading more... |
| </> |
| ) : ( |
| "Scroll to load more" |
| )} |
| </div> |
| )} |
| </section> |
|
|
| {selected && ( |
| <Modal |
| movie={selected} |
| apiBase={apiBase} |
| onClose={() => setSelected(null)} |
| /> |
| )} |
|
|
| <Toast toast={toast} /> |
| </div> |
| ); |
| } |
|
|