Initial commit — YT Hub
Self-hosted personal YouTube management app. FastAPI + SQLite backend, React + Vite + Tailwind frontend. Dockerfiles and compose included for Portainer deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
79
frontend/src/components/ChannelCard.jsx
Normal file
79
frontend/src/components/ChannelCard.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { followChannel, unfollowChannel } from "../api";
|
||||
|
||||
function formatSubs(n) {
|
||||
if (!n) return null;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function ChannelCard({ channel }) {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const channelId = channel.local_channel_id ?? channel.id;
|
||||
const isFollowed = channel.is_followed ?? (channel.status === "followed");
|
||||
|
||||
const followMut = useMutation({
|
||||
mutationFn: () => isFollowed ? unfollowChannel(channelId) : followChannel(channelId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
const subs = formatSubs(channel.subscriber_count);
|
||||
const meta = [
|
||||
subs && `${subs} subscribers`,
|
||||
channel.video_count > 0 && `${channel.video_count} videos`,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-zinc-900 rounded-xl p-4 flex gap-4 cursor-pointer hover:bg-zinc-800 transition-colors"
|
||||
onClick={() => channelId && navigate(`/channels/${channelId}`)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{channel.thumbnail_url ? (
|
||||
<img
|
||||
src={channel.thumbnail_url}
|
||||
alt={channel.name}
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
|
||||
{channel.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1 justify-center">
|
||||
<p className="font-semibold text-zinc-100 truncate">{channel.name}</p>
|
||||
{meta && (
|
||||
<p className="text-xs text-zinc-500">{meta}</p>
|
||||
)}
|
||||
{channel.description && (
|
||||
<p className="text-xs text-zinc-400 line-clamp-2 leading-relaxed mt-0.5">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Follow button */}
|
||||
{channelId && (
|
||||
<div className="shrink-0 flex items-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); followMut.mutate(); }}
|
||||
disabled={followMut.isPending}
|
||||
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||
isFollowed || followMut.isSuccess
|
||||
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
: "bg-accent text-black hover:bg-yellow-300"
|
||||
}`}
|
||||
>
|
||||
{isFollowed || followMut.isSuccess ? "Following" : "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/src/components/Layout.jsx
Normal file
209
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Outlet, NavLink, useNavigate, Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { getDownloads, getChannels } from "../api";
|
||||
|
||||
function DownloadIndicator() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["downloads"],
|
||||
queryFn: () => getDownloads().then((r) => r.data),
|
||||
refetchInterval: (query) => {
|
||||
const active = (query.state.data ?? []).some(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
return active ? 1500 : 10_000;
|
||||
},
|
||||
});
|
||||
|
||||
const active = (data ?? []).filter(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
if (!active.length) return null;
|
||||
|
||||
const top = active[0];
|
||||
const pct = top.progress_percent ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/downloads"
|
||||
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0"
|
||||
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 animate-spin text-accent" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span className="font-mono tabular-nums">{pct.toFixed(0)}%</span>
|
||||
{active.length > 1 && (
|
||||
<span className="text-zinc-500">+{active.length - 1}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, children, badge }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`relative px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function DropItem({ to, children, badge }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`relative flex items-center justify-between gap-4 px-3 py-2 text-sm rounded-lg mx-1 transition-colors ${
|
||||
isActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{badge > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDropdown({ label, paths, children }) {
|
||||
const location = useLocation();
|
||||
const isGroupActive = paths.some(p => location.pathname.startsWith(p));
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors select-none ${
|
||||
isGroupActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<svg className="w-3 h-3 opacity-50 transition-transform duration-150 group-hover:rotate-180" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* invisible bridge so mouse can move from button to panel without gap */}
|
||||
<div className="absolute top-full left-0 right-0 h-2 bg-transparent" />
|
||||
|
||||
<div className="absolute top-[calc(100%+2px)] right-0 z-50
|
||||
invisible opacity-0 group-hover:visible group-hover:opacity-100
|
||||
transition-all duration-100 ease-out">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl py-1.5 min-w-[180px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDivider() {
|
||||
return <div className="my-1 mx-3 h-px bg-zinc-800" />;
|
||||
}
|
||||
|
||||
function useNewVideosCount() {
|
||||
const { data: channels = [] } = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: () => getChannels().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const newCount = useNewVideosCount();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="font-display font-bold text-lg text-accent shrink-0"
|
||||
>
|
||||
YT Hub
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Active downloads indicator */}
|
||||
<DownloadIndicator />
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="hidden sm:flex items-center gap-0.5">
|
||||
<NavItem to="/">Home</NavItem>
|
||||
|
||||
<NavDropdown
|
||||
label="Watch"
|
||||
paths={["/continue-watching", "/liked", "/queue", "/history"]}
|
||||
>
|
||||
<DropItem to="/continue-watching">Continue watching</DropItem>
|
||||
<DropItem to="/liked">Liked videos</DropItem>
|
||||
<DropItem to="/queue">Watch Later</DropItem>
|
||||
<DropItem to="/collections">Collections</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/history">History</DropItem>
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown
|
||||
label="Library"
|
||||
paths={["/following", "/downloads", "/discovery", "/stats"]}
|
||||
>
|
||||
<DropItem to="/following" badge={newCount}>Following</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/discovery">Discover</DropItem>
|
||||
<DropItem to="/downloads">Downloads</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/stats">Stats</DropItem>
|
||||
</NavDropdown>
|
||||
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
|
||||
>
|
||||
{user?.username} · sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/SearchBar.jsx
Normal file
141
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { search, getSearchHistory } from "../api";
|
||||
|
||||
function MagnifyIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchBar() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [input, setInput] = useState(params.get("q") || "");
|
||||
const [debouncedQ, setDebouncedQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// Debounce for inline preview
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQ(input.trim()), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [input]);
|
||||
|
||||
// Inline local-only preview results
|
||||
const { data: preview } = useQuery({
|
||||
queryKey: ["search-preview", debouncedQ],
|
||||
queryFn: () => search(debouncedQ, false),
|
||||
enabled: debouncedQ.length >= 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Recent searches (shown when focused + empty)
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ["search-history"],
|
||||
queryFn: () => getSearchHistory().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
enabled: focused,
|
||||
});
|
||||
|
||||
const previewVideos = preview?.data?.videos?.slice(0, 5) ?? [];
|
||||
const recentQueries = historyData?.queries ?? [];
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const submit = (q = input) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
setOpen(false);
|
||||
navigate(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative w-full">
|
||||
<div className="flex items-center bg-zinc-900 border border-zinc-700 rounded-lg px-3 gap-2 focus-within:border-zinc-500 transition-colors">
|
||||
<span className="text-zinc-500">
|
||||
<MagnifyIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => { setFocused(true); if (input.length >= 2) setOpen(true); else setOpen(true); }}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
placeholder="Search videos, channels…"
|
||||
className="flex-1 bg-transparent py-2 text-sm text-zinc-100 placeholder-zinc-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent searches (empty input, focused) */}
|
||||
{open && !debouncedQ && recentQueries.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] text-zinc-600 uppercase tracking-wider font-medium">Recent</p>
|
||||
{recentQueries.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onMouseDown={() => { setOpen(false); navigate(`/search?q=${encodeURIComponent(q)}`); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-zinc-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-300 truncate">{q}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline preview dropdown */}
|
||||
{open && previewVideos.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
|
||||
{previewVideos.map((v) => (
|
||||
<button
|
||||
key={v.youtube_video_id}
|
||||
onMouseDown={() => { setOpen(false); navigate(`/watch/${v.youtube_video_id}`); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<img
|
||||
src={v.thumbnail_url}
|
||||
alt=""
|
||||
className="w-16 h-9 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-100 truncate">{v.title}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">{v.channel_name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onMouseDown={() => submit()}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-accent hover:bg-zinc-800 border-t border-zinc-800 transition-colors"
|
||||
>
|
||||
<MagnifyIcon />
|
||||
Search YouTube for "{input}"
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Shelf.jsx
Normal file
29
frontend/src/components/Shelf.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import VideoCard from "./VideoCard";
|
||||
|
||||
export default function Shelf({ title, videos, action, emptyText }) {
|
||||
if (!videos?.length && emptyText) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-lg text-zinc-300 mb-3">{title}</h2>
|
||||
<p className="text-sm text-zinc-600">{emptyText}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!videos?.length) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display font-semibold text-lg text-zinc-300">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 -mx-1 px-1">
|
||||
{videos.map((v) => (
|
||||
<div key={v.youtube_video_id || v.id} className="w-64 shrink-0">
|
||||
<VideoCard video={v} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/SortPicker.jsx
Normal file
22
frontend/src/components/SortPicker.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function SortPicker({ value, onChange, options }) {
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<svg
|
||||
className="absolute left-2.5 w-3 h-3 text-zinc-500 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-7 pr-3 py-1.5 bg-zinc-800 border border-zinc-700/60 rounded-lg text-xs text-zinc-300 focus:outline-none focus:border-zinc-500 cursor-pointer"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
frontend/src/components/VideoCard.jsx
Normal file
365
frontend/src/components/VideoCard.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return null;
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, active, pending, children }) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-150",
|
||||
active
|
||||
? "text-accent"
|
||||
: "text-zinc-600 hover:text-zinc-200",
|
||||
pending && "opacity-60 cursor-default",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadIcon({ active }) {
|
||||
return active ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 20h14v-2H5v2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon({ active }) {
|
||||
return active ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueIcon({ active }) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
|
||||
d="M4 6h16M4 11h16M4 16h10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
|
||||
d="M16 18l4-2.5-4-2.5v5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, className }) {
|
||||
return (
|
||||
<div className={clsx("relative bg-zinc-800 overflow-hidden", className)}>
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
className={clsx(
|
||||
"w-full h-full object-cover transition-all duration-300",
|
||||
"group-hover:scale-[1.03]",
|
||||
isWatched && "opacity-50",
|
||||
)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss */}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
|
||||
title="Not interested"
|
||||
className="absolute top-2 right-2 z-10 w-5 h-5 rounded-full bg-black/60 text-zinc-400 hover:text-white hover:bg-black flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg className="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{duration && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Resolution */}
|
||||
{video.download_resolution && (
|
||||
<span className="absolute bottom-2 left-2 text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono text-accent bg-black/75">
|
||||
{video.download_resolution}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Watched dot */}
|
||||
{isWatched && (
|
||||
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
|
||||
)}
|
||||
|
||||
{/* Play hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-11 h-11 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
||||
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoCard({ video, size = "md", onDismiss, variant = "grid" }) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: appSettings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => getSettings().then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const calmMode = appSettings?.calm_mode ?? false;
|
||||
|
||||
const channels = qc.getQueryData(["channels"]) ?? [];
|
||||
const channelMeta = channels.find(c => c.id === video.channel_id) ?? null;
|
||||
const channelNote = channelMeta?.notes || null;
|
||||
const isDormant = channelMeta?.last_published_at
|
||||
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
|
||||
: false;
|
||||
|
||||
const internalId = video.id ?? video.local_video_id ?? null;
|
||||
const isDownloaded = video.is_downloaded;
|
||||
const isWatched = video.is_watched;
|
||||
const duration = formatDuration(video.duration_seconds);
|
||||
const date = formatDate(video.published_at);
|
||||
|
||||
const [downloaded, setDownloaded] = useState(isDownloaded);
|
||||
const [queued, setQueued] = useState(video.queued ?? false);
|
||||
const [liked, setLiked] = useState(video.liked ?? false);
|
||||
|
||||
const dlMut = useMutation({
|
||||
mutationFn: () => createDownload(video.youtube_video_id),
|
||||
onSuccess: () => { setDownloaded(true); qc.invalidateQueries({ queryKey: ["downloads"] }); },
|
||||
});
|
||||
const qMut = useMutation({
|
||||
mutationFn: () => toggleQueue(internalId),
|
||||
onSuccess: (res) => { setQueued(res.data.queued); qc.invalidateQueries({ queryKey: ["videos"] }); },
|
||||
});
|
||||
const likeMut = useMutation({
|
||||
mutationFn: () => toggleLike(internalId),
|
||||
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
||||
});
|
||||
const dismissMut = useMutation({
|
||||
mutationFn: () => dismissDiscovery(video.channel_id),
|
||||
onSuccess: () => onDismiss?.(video),
|
||||
});
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
|
||||
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (!downloaded && !dlMut.isPending) dlMut.mutate(); }}
|
||||
title={downloaded ? "Downloaded" : dlMut.isPending ? "Downloading…" : "Download"}
|
||||
active={downloaded}
|
||||
pending={dlMut.isPending}
|
||||
>
|
||||
{dlMut.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : <DownloadIcon active={downloaded} />}
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (internalId && !qMut.isPending) qMut.mutate(); }}
|
||||
title={!internalId ? "Save video first to queue it" : queued ? "Remove from Watch Later" : "Watch Later"}
|
||||
active={queued}
|
||||
pending={qMut.isPending}
|
||||
>
|
||||
<QueueIcon active={queued} />
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (internalId && !likeMut.isPending) likeMut.mutate(); }}
|
||||
title={!internalId ? "Watch video first to like it" : liked ? "Unlike" : "Like"}
|
||||
active={liked}
|
||||
pending={likeMut.isPending}
|
||||
>
|
||||
<HeartIcon active={liked} />
|
||||
</IconBtn>
|
||||
|
||||
{(queued || downloaded) && (
|
||||
<span className="ml-1 text-[10px] text-zinc-700 font-medium">
|
||||
{queued ? "later" : "saved"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── List variant ─────────────────────────────────────────────────────────
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className="group flex gap-5 px-3 py-3.5 rounded-2xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
|
||||
>
|
||||
<ThumbnailBlock
|
||||
video={video}
|
||||
isWatched={isWatched}
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="w-56 sm:w-72 aspect-video rounded-xl shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col min-w-0 flex-1 py-0.5 gap-2">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-[15px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
{/* Channel · date · badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[12px] text-zinc-400 truncate">{video.channel_name}</span>
|
||||
{date && <span className="text-zinc-700 text-[12px]">·</span>}
|
||||
{date && <span className="text-[12px] text-zinc-600 shrink-0">{date}</span>}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[10px] text-zinc-700 tracking-widest">zzz</span>
|
||||
)}
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{video.description ? (
|
||||
<p className="text-[12px] leading-relaxed text-zinc-500 line-clamp-3 flex-1">
|
||||
{video.description.replace(/\n+/g, " ")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 mt-auto">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grid variant ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className={clsx(
|
||||
"group relative flex flex-col cursor-pointer rounded-2xl overflow-hidden",
|
||||
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
|
||||
size === "sm" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<ThumbnailBlock
|
||||
video={video}
|
||||
isWatched={isWatched}
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="aspect-video"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 flex-1">
|
||||
{/* Title */}
|
||||
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</p>
|
||||
|
||||
{/* Channel + date */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
|
||||
)}
|
||||
{date && <span className="text-[11px] text-zinc-700">{date}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{(video.is_recommended && !calmMode) && (
|
||||
<span className="self-start text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 border-t border-zinc-800/80">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/VideoPlayer.jsx
Normal file
277
frontend/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getVideoByYtId, updateProgress, createDownload, followChannelByUrl, getDownload } from "../api";
|
||||
|
||||
function formatDuration(s) {
|
||||
if (!s) return "";
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YoutubeEmbed({ youtubeId, startAt, onTimeUpdate }) {
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.origin !== "https://www.youtube.com") return;
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.event === "infoDelivery" && data.info?.currentTime != null) {
|
||||
onTimeUpdate(Math.floor(data.info.currentTime));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [onTimeUpdate]);
|
||||
|
||||
const start = startAt > 10 ? startAt : 0;
|
||||
const src = `https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0&modestbranding=1&enablejsapi=1&start=${start}&origin=${encodeURIComponent(window.location.origin)}`;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
className="w-full aspect-video rounded-lg bg-black"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalVideo({ src, startAt, onTimeUpdate }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && startAt > 10) {
|
||||
ref.current.currentTime = startAt;
|
||||
}
|
||||
}, []); // only on mount
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full aspect-video rounded-lg bg-black"
|
||||
onTimeUpdate={() => {
|
||||
if (ref.current) onTimeUpdate(Math.floor(ref.current.currentTime));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadProgress({ pct, status }) {
|
||||
const label = status === "pending" ? "Queued…" : `Downloading ${pct.toFixed(0)}%`;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 shrink-0 font-mono w-28">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoPlayer() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const youtubeId = params.get("play");
|
||||
const urlTitle = params.get("pt");
|
||||
const urlChannel = params.get("pc");
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [downloadId, setDownloadId] = useState(null);
|
||||
const [switchedToLocal, setSwitchedToLocal] = useState(false);
|
||||
const saveTimerRef = useRef(null);
|
||||
const initiatedRef = useRef(null); // track which video we triggered download for
|
||||
|
||||
// ── Video metadata ────────────────────────────────────────────────────────
|
||||
const { data: video, refetch: refetchVideo } = useQuery({
|
||||
queryKey: ["video-play", youtubeId],
|
||||
queryFn: () =>
|
||||
getVideoByYtId(youtubeId)
|
||||
.then((r) => r.data)
|
||||
.catch((err) => (err.response?.status === 404 ? null : Promise.reject(err))),
|
||||
enabled: !!youtubeId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// ── Download polling ──────────────────────────────────────────────────────
|
||||
const { data: dlStatus } = useQuery({
|
||||
queryKey: ["download-status", downloadId],
|
||||
queryFn: () => getDownload(downloadId).then((r) => r.data),
|
||||
enabled: !!downloadId,
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data?.status;
|
||||
return s === "complete" || s === "failed" ? false : 1500;
|
||||
},
|
||||
});
|
||||
|
||||
// When download finishes, re-fetch video to get local_file_url and auto-switch
|
||||
useEffect(() => {
|
||||
if (dlStatus?.status === "complete" && !switchedToLocal) {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
}
|
||||
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
|
||||
|
||||
// ── Trigger download on open ──────────────────────────────────────────────
|
||||
const downloadMut = useMutation({
|
||||
mutationFn: (ytId) => createDownload(ytId),
|
||||
onSuccess: (res) => {
|
||||
const dl = res.data;
|
||||
setDownloadId(dl.id);
|
||||
// If it came back complete already (was pre-downloaded), just switch now
|
||||
if (dl.status === "complete") {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
||||
initiatedRef.current = youtubeId;
|
||||
setSwitchedToLocal(false);
|
||||
setCurrentTime(0);
|
||||
setDownloadId(null);
|
||||
// Small delay so the modal renders before the fetch starts
|
||||
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Progress saving ───────────────────────────────────────────────────────
|
||||
const followMut = useMutation({
|
||||
mutationFn: () => followChannelByUrl({ youtube_channel_id: video?.channel_youtube_id }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
const handleTimeUpdate = useCallback((secs) => {
|
||||
setCurrentTime(secs);
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
if (video?.id) {
|
||||
const duration = video.duration_seconds ?? 0;
|
||||
const watched = duration > 0 && secs >= duration * 0.9;
|
||||
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
||||
}
|
||||
}, 10_000);
|
||||
}, [video]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
||||
setSwitchedToLocal(false);
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}, [setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === "Escape") close(); };
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [close]);
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimerRef.current), []);
|
||||
|
||||
if (!youtubeId) return null;
|
||||
|
||||
const title = video?.title ?? urlTitle ?? youtubeId;
|
||||
const channelName = video?.channel_name ?? urlChannel;
|
||||
const startAt = video?.watch_progress_seconds ?? 0;
|
||||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
||||
const localUrl = switchedToLocal ? video?.local_file_url : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) close(); }}
|
||||
>
|
||||
<div className="relative w-full max-w-4xl flex flex-col gap-3 max-h-[95vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display font-semibold text-white text-lg leading-snug line-clamp-2">
|
||||
{title}
|
||||
</h2>
|
||||
{channelName && (
|
||||
<p className="text-sm text-zinc-400 mt-0.5">{channelName}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={close} className="shrink-0 text-zinc-400 hover:text-white transition-colors p-1">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player — local file once ready, YouTube embed while downloading */}
|
||||
{localUrl ? (
|
||||
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
) : (
|
||||
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
)}
|
||||
|
||||
{/* Download progress bar (shows while downloading, disappears when done) */}
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
pct={dlStatus.progress_percent ?? 0}
|
||||
status={dlStatus.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status / source indicator */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{localUrl ? (
|
||||
<span className="text-xs text-accent font-medium">▶ Playing local file</span>
|
||||
) : isDownloading ? (
|
||||
<span className="text-xs text-zinc-500">Watching on YouTube · switching to local when ready</span>
|
||||
) : dlStatus?.status === "failed" ? (
|
||||
<span className="text-xs text-red-400">Download failed — watching on YouTube</span>
|
||||
) : null}
|
||||
|
||||
{/* Follow channel */}
|
||||
{video?.channel_youtube_id && (
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={followMut.isPending || followMut.isSuccess}
|
||||
className="ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{followMut.isSuccess ? "Following ✓" : "Follow channel"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{video?.duration_seconds && (
|
||||
<span className="text-xs text-zinc-600 font-mono">
|
||||
{formatDuration(currentTime || startAt)} / {formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{video?.description && (
|
||||
<details>
|
||||
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300 transition-colors select-none">
|
||||
Description
|
||||
</summary>
|
||||
<p className="text-sm text-zinc-400 mt-2 whitespace-pre-line leading-relaxed max-h-40 overflow-y-auto">
|
||||
{video.description}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user