streamtg / frontend /src /App.jsx
dragonxd1's picture
Add bot getFile streaming fallback
e562d52
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 {
// 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 (
<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>
);
}