The unified effect already gates polling on dlStatus===complete, but handleDownloadAndPlay was still calling pollForFile immediately on click, before the download even started. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1195 lines
51 KiB
JavaScript
1195 lines
51 KiB
JavaScript
import { useEffect, useRef, useState, useCallback } from "react";
|
||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
getVideoByYtId, updateProgress, createDownload, getDownload, deleteDownload,
|
||
followChannelByUrl, toggleQueue, toggleLike, getChannelVideos, getChannel,
|
||
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
||
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
||
getCollections, addToCollection, getQueue,
|
||
getVideoComments, refreshVideoComments,
|
||
} from "../api";
|
||
import VideoCard from "../components/VideoCard";
|
||
|
||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function formatDuration(s) {
|
||
if (!s) return "0:00";
|
||
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 formatDate(s) {
|
||
if (!s) return null;
|
||
return new Date(s).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||
}
|
||
|
||
function formatViews(n) {
|
||
if (!n) return null;
|
||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B views`;
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 100_000_000 ? 0 : 1)}M views`;
|
||
if (n >= 1_000) return `${Math.round(n / 1_000)}K views`;
|
||
return `${n} views`;
|
||
}
|
||
|
||
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 `${Math.round(n / 1_000)}K`;
|
||
return String(n);
|
||
}
|
||
|
||
function avatarColor(name) {
|
||
if (!name) return "#52525b";
|
||
let h = 0;
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||
return `hsl(${h}, 55%, 42%)`;
|
||
}
|
||
|
||
function linkify(text) {
|
||
const parts = text.split(/(https?:\/\/[^\s)"'\]]+)/g);
|
||
return parts.map((part, i) =>
|
||
/^https?:\/\//.test(part) ? (
|
||
<a key={i} href={part} target="_blank" rel="noopener noreferrer"
|
||
className="text-accent hover:underline break-all"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>{part}</a>
|
||
) : part
|
||
);
|
||
}
|
||
|
||
// ── Description ─────────────────────────────────────────────────────────────
|
||
|
||
function DescriptionBox({ text }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const lines = text.split("\n");
|
||
const hasMore = lines.length > 2 || text.length > 200;
|
||
const displayed = expanded ? text : lines.slice(0, 2).join("\n") + (hasMore ? "…" : "");
|
||
|
||
return (
|
||
<div
|
||
className="bg-zinc-900 rounded-xl p-3 sm:p-4 cursor-pointer select-none"
|
||
onClick={() => hasMore && setExpanded(v => !v)}
|
||
>
|
||
<p className="text-[13px] text-zinc-300 whitespace-pre-line leading-relaxed">
|
||
{linkify(displayed)}
|
||
</p>
|
||
{hasMore && (
|
||
<p className="text-xs font-semibold text-zinc-400 mt-2 hover:text-white">
|
||
{expanded ? "Show less" : "Show more"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Action button ────────────────────────────────────────────────────────────
|
||
|
||
function Chip({ onClick, active, disabled, children }) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={[
|
||
"flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
|
||
disabled && "opacity-40 cursor-not-allowed",
|
||
].filter(Boolean).join(" ")}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Sidebar mode toggle ──────────────────────────────────────────────────────
|
||
|
||
function SidebarModeToggle({ mode, onToggle }) {
|
||
const isRandom = mode === "random";
|
||
return (
|
||
<button
|
||
onClick={onToggle}
|
||
title={isRandom ? "Switch to weighted (by score)" : "Switch to random"}
|
||
className={`flex items-center gap-1 text-xs transition-colors ${isRandom ? "text-accent" : "text-zinc-600 hover:text-zinc-400"}`}
|
||
>
|
||
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
{isRandom ? "Random" : "Weighted"}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Autoplay toggle ──────────────────────────────────────────────────────────
|
||
|
||
function AutoplayToggle({ value, onChange }) {
|
||
return (
|
||
<button
|
||
onClick={() => onChange(!value)}
|
||
className="flex items-center gap-1.5 group"
|
||
>
|
||
<div className={`relative w-7 h-3.5 rounded-full transition-colors ${value ? "bg-accent" : "bg-zinc-700 group-hover:bg-zinc-600"}`}>
|
||
<div className={`absolute top-0.5 w-2.5 h-2.5 rounded-full bg-white shadow transition-transform ${value ? "translate-x-3.5" : "translate-x-0.5"}`} />
|
||
</div>
|
||
<span className={`text-xs ${value ? "text-accent" : "text-zinc-500 group-hover:text-zinc-400"}`}>Autoplay</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Sidebar video row ────────────────────────────────────────────────────────
|
||
|
||
function VideoRow({ video: v, showChannel = false }) {
|
||
return (
|
||
<Link
|
||
to={`/watch/${v.youtube_video_id}`}
|
||
className="flex gap-3 p-2 rounded-xl hover:bg-zinc-900 transition-colors group"
|
||
>
|
||
<div className="relative shrink-0 w-36 aspect-video rounded-lg overflow-hidden bg-zinc-800">
|
||
{v.thumbnail_url && (
|
||
<img src={v.thumbnail_url} alt={v.title}
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||
)}
|
||
{v.duration_seconds && (
|
||
<span className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] font-mono px-1 py-0.5 rounded">
|
||
{formatDuration(v.duration_seconds)}
|
||
</span>
|
||
)}
|
||
{v.is_watched && (
|
||
<span className="absolute top-1 left-1 bg-black/70 text-accent text-[10px] px-1 py-0.5 rounded">✓</span>
|
||
)}
|
||
{v.watch_progress_seconds > 0 && v.duration_seconds > 0 && (
|
||
<div className="absolute bottom-0 inset-x-0 h-0.5 bg-white/20">
|
||
<div
|
||
className="h-full bg-accent"
|
||
style={{ width: `${Math.min(v.watch_progress_seconds / v.duration_seconds, 1) * 100}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0 pt-0.5">
|
||
<p className="text-xs font-medium text-zinc-200 line-clamp-2 leading-snug group-hover:text-white">
|
||
{v.title}
|
||
</p>
|
||
{showChannel && v.channel_name && (
|
||
<p className="text-xs text-zinc-500 mt-0.5 truncate">{v.channel_name}</p>
|
||
)}
|
||
{v.published_at && (
|
||
<p className="text-xs text-zinc-600 mt-0.5">
|
||
{new Date(v.published_at).toLocaleDateString("en-US", { year: "numeric", month: "short" })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ── Player placeholder ───────────────────────────────────────────────────────
|
||
|
||
function Placeholder({ video, dlStatus, onPlay, onDownloadAndPlay, isDownloading }) {
|
||
const active = isDownloading || dlStatus?.status === "pending" || dlStatus?.status === "downloading";
|
||
const pct = dlStatus?.progress_percent ?? 0;
|
||
const polling = !active && !onPlay && !onDownloadAndPlay && dlStatus?.status !== "failed";
|
||
|
||
return (
|
||
<div className="relative w-full h-full bg-zinc-950 flex items-center justify-center">
|
||
{video?.thumbnail_url && (
|
||
<img src={video.thumbnail_url} alt="" className="absolute inset-0 w-full h-full object-cover opacity-30" />
|
||
)}
|
||
<div className="relative flex flex-col items-center gap-3">
|
||
{dlStatus?.status === "failed" ? (
|
||
<p className="text-zinc-400 text-sm">Download failed</p>
|
||
) : active ? (
|
||
<>
|
||
<svg className="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
|
||
<circle cx="28" cy="28" r="24" fill="none" stroke="#3f3f46" strokeWidth="4" />
|
||
<circle cx="28" cy="28" r="24" fill="none" stroke="#facc15" strokeWidth="4"
|
||
strokeDasharray={`${2 * Math.PI * 24}`}
|
||
strokeDashoffset={`${2 * Math.PI * 24 * (1 - pct / 100)}`}
|
||
strokeLinecap="round" style={{ transition: "stroke-dashoffset 0.4s" }} />
|
||
</svg>
|
||
<p className="text-zinc-300 text-sm font-mono">{pct.toFixed(0)}%</p>
|
||
<p className="text-zinc-500 text-xs">Preparing…</p>
|
||
</>
|
||
) : polling ? (
|
||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
) : onPlay ? (
|
||
<button
|
||
onClick={onPlay}
|
||
className="group/play flex flex-col items-center gap-2"
|
||
>
|
||
<div className="w-20 h-20 rounded-full bg-black/50 backdrop-blur-sm border border-white/20 flex items-center justify-center group-hover/play:bg-black/70 transition-colors">
|
||
<svg className="w-10 h-10 text-white ml-1.5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
</div>
|
||
<span className="text-white text-sm font-medium drop-shadow">Play</span>
|
||
</button>
|
||
) : onDownloadAndPlay ? (
|
||
<button
|
||
onClick={onDownloadAndPlay}
|
||
className="flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-black font-bold text-sm hover:bg-yellow-300 transition-colors"
|
||
>
|
||
<svg className="w-4 h-4" 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>
|
||
Download & Watch
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Keyboard shortcut hook ───────────────────────────────────────────────────
|
||
|
||
function useVideoKeys(videoRef, { onSpeedUp, onSpeedDown, onTheater } = {}) {
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
const tag = document.activeElement?.tagName;
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||
|
||
if (e.key === "t") { e.preventDefault(); onTheater?.(); return; }
|
||
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
|
||
switch (e.key) {
|
||
case " ": case "k": e.preventDefault(); v.paused ? v.play() : v.pause(); break;
|
||
case "f": e.preventDefault(); document.fullscreenElement ? document.exitFullscreen() : v.requestFullscreen(); break;
|
||
case "m": e.preventDefault(); v.muted = !v.muted; break;
|
||
case "ArrowLeft": case "j": e.preventDefault(); v.currentTime = Math.max(0, v.currentTime - 5); break;
|
||
case "ArrowRight": case "l": e.preventDefault(); v.currentTime = Math.min(v.duration, v.currentTime + 5); break;
|
||
case "ArrowUp": e.preventDefault(); v.volume = Math.min(1, v.volume + 0.1); break;
|
||
case "ArrowDown": e.preventDefault(); v.volume = Math.max(0, v.volume - 0.1); break;
|
||
case ">": case ".": e.preventDefault(); onSpeedUp?.(); break;
|
||
case "<": case ",": e.preventDefault(); onSpeedDown?.(); break;
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handler);
|
||
return () => window.removeEventListener("keydown", handler);
|
||
}, [videoRef, onSpeedUp, onSpeedDown, onTheater]);
|
||
}
|
||
|
||
// ── Collections ──────────────────────────────────────────────────────────────
|
||
|
||
function AddToCollectionSection({ videoId }) {
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const { data: collections = [] } = useQuery({
|
||
queryKey: ["collections"],
|
||
queryFn: () => getCollections().then(r => r.data),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const addMut = useMutation({
|
||
mutationFn: (colId) => addToCollection(colId, videoId),
|
||
onSuccess: (_, colId) => {
|
||
qc.invalidateQueries({ queryKey: ["collection-videos", colId] });
|
||
setOpen(false);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Collections</h2>
|
||
<button
|
||
onClick={() => setOpen(o => !o)}
|
||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-200 transition-colors"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{open && (
|
||
<div className="flex flex-col gap-1 bg-zinc-800/60 rounded-xl p-2">
|
||
{collections.length === 0 ? (
|
||
<p className="text-xs text-zinc-600 px-2 py-1">No collections yet — create one on the Collections page.</p>
|
||
) : collections.map(col => (
|
||
<button
|
||
key={col.id}
|
||
onClick={() => addMut.mutate(col.id)}
|
||
disabled={addMut.isPending}
|
||
className="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-zinc-700 transition-colors text-left disabled:opacity-50"
|
||
>
|
||
<span className="text-sm text-zinc-200">{col.name}</span>
|
||
<span className="text-[11px] text-zinc-600">{col.video_count} videos</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ── Bookmarks ────────────────────────────────────────────────────────────────
|
||
|
||
function BookmarksSection({ videoId, videoRef }) {
|
||
const qc = useQueryClient();
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [editNote, setEditNote] = useState("");
|
||
|
||
const { data: bookmarks = [] } = useQuery({
|
||
queryKey: ["bookmarks", videoId],
|
||
queryFn: () => getBookmarks(videoId).then(r => r.data),
|
||
enabled: !!videoId,
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
// Auto-import chapters once on mount
|
||
const importMut = useMutation({
|
||
mutationFn: () => importChapters(videoId),
|
||
onSuccess: (res) => {
|
||
if (res.data.length > 0) qc.invalidateQueries({ queryKey: ["bookmarks", videoId] });
|
||
},
|
||
});
|
||
useEffect(() => {
|
||
if (videoId) importMut.mutate();
|
||
}, [videoId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const clearMut = useMutation({
|
||
mutationFn: () => clearChapters(videoId),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bookmarks", videoId] }),
|
||
});
|
||
|
||
const addMut = useMutation({
|
||
mutationFn: () => {
|
||
const secs = videoRef.current ? Math.floor(videoRef.current.currentTime) : 0;
|
||
return createBookmark(videoId, { timestamp_seconds: secs, note: "" });
|
||
},
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bookmarks", videoId] }),
|
||
});
|
||
|
||
const updateMut = useMutation({
|
||
mutationFn: ({ bookmarkId, note }) => updateBookmark(videoId, bookmarkId, { note }),
|
||
onSuccess: () => {
|
||
setEditingId(null);
|
||
qc.invalidateQueries({ queryKey: ["bookmarks", videoId] });
|
||
},
|
||
});
|
||
|
||
const deleteMut = useMutation({
|
||
mutationFn: (bookmarkId) => deleteBookmark(videoId, bookmarkId),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bookmarks", videoId] }),
|
||
});
|
||
|
||
const chapters = bookmarks.filter(b => b.source === "auto");
|
||
const manual = bookmarks.filter(b => b.source !== "auto");
|
||
const currentTimeSecs = videoRef.current ? Math.floor(videoRef.current.currentTime) : 0;
|
||
|
||
const BookmarkRow = ({ bm, isChapter }) => (
|
||
<div className="flex items-center gap-2 group">
|
||
<button
|
||
onClick={() => { if (videoRef.current) videoRef.current.currentTime = bm.timestamp_seconds; }}
|
||
className="shrink-0 px-2 py-0.5 rounded-md bg-zinc-800 text-accent text-xs font-mono hover:bg-zinc-700 transition-colors tabular-nums"
|
||
>
|
||
{formatDuration(bm.timestamp_seconds)}
|
||
</button>
|
||
{!isChapter && editingId === bm.id ? (
|
||
<input
|
||
autoFocus
|
||
value={editNote}
|
||
onChange={e => setEditNote(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === "Enter") updateMut.mutate({ bookmarkId: bm.id, note: editNote });
|
||
if (e.key === "Escape") setEditingId(null);
|
||
}}
|
||
onBlur={() => updateMut.mutate({ bookmarkId: bm.id, note: editNote })}
|
||
className="flex-1 bg-zinc-800 text-zinc-100 text-xs rounded-md px-2 py-1 focus:outline-none border border-zinc-600"
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => { if (!isChapter) { setEditingId(bm.id); setEditNote(bm.note || ""); } }}
|
||
className={`flex-1 text-xs py-0.5 truncate ${isChapter ? "text-zinc-400" : "text-zinc-400 cursor-pointer hover:text-zinc-200 transition-colors"}`}
|
||
>
|
||
{bm.note || (!isChapter && <span className="text-zinc-600 italic">Add note…</span>)}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => deleteMut.mutate(bm.id)}
|
||
disabled={deleteMut.isPending}
|
||
className="shrink-0 p-1 rounded text-zinc-700 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Chapters (auto) */}
|
||
{chapters.length > 0 && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Chapters</h2>
|
||
<button
|
||
onClick={() => clearMut.mutate()}
|
||
disabled={clearMut.isPending}
|
||
className="text-[11px] text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50"
|
||
>
|
||
Clear all
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{chapters.map(bm => <BookmarkRow key={bm.id} bm={bm} isChapter />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Manual bookmarks */}
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Bookmarks</h2>
|
||
<button
|
||
onClick={() => addMut.mutate()}
|
||
disabled={addMut.isPending}
|
||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-200 transition-colors disabled:opacity-50"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
{formatDuration(currentTimeSecs)}
|
||
</button>
|
||
</div>
|
||
{manual.length === 0 ? (
|
||
<p className="text-xs text-zinc-700">No bookmarks yet.</p>
|
||
) : (
|
||
<div className="flex flex-col gap-1.5">
|
||
{manual.map(bm => <BookmarkRow key={bm.id} bm={bm} isChapter={false} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function CommentsSection({ youtubeVideoId }) {
|
||
const qc = useQueryClient();
|
||
|
||
const { data: comments = [], isLoading } = useQuery({
|
||
queryKey: ["comments", youtubeVideoId],
|
||
queryFn: () => getVideoComments(youtubeVideoId).then(r => r.data),
|
||
staleTime: Infinity,
|
||
});
|
||
|
||
const refresh = useMutation({
|
||
mutationFn: () => refreshVideoComments(youtubeVideoId),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", youtubeVideoId] }),
|
||
});
|
||
|
||
if (isLoading) return null;
|
||
|
||
if (!comments.length) {
|
||
return (
|
||
<div className="flex items-center justify-between py-2">
|
||
<p className="text-sm text-zinc-500">No comments loaded yet</p>
|
||
<button
|
||
onClick={() => refresh.mutate()}
|
||
disabled={refresh.isPending}
|
||
className="text-xs px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||
>
|
||
{refresh.isPending ? (
|
||
<>
|
||
<span className="w-3 h-3 border border-zinc-500 border-t-transparent rounded-full animate-spin inline-block" />
|
||
Fetching…
|
||
</>
|
||
) : "Load comments"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||
Top comments
|
||
</p>
|
||
<button
|
||
onClick={() => refresh.mutate()}
|
||
disabled={refresh.isPending}
|
||
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
||
>
|
||
{refresh.isPending ? "Refreshing…" : "Refresh"}
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col gap-3">
|
||
{comments.map((c, i) => (
|
||
<div key={i} className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium text-zinc-300">{c.author}</span>
|
||
{c.is_pinned && (
|
||
<span className="text-[10px] text-accent font-medium">Pinned</span>
|
||
)}
|
||
{c.likes > 0 && (
|
||
<span className="text-[10px] text-zinc-600 ml-auto">
|
||
👍 {c.likes >= 1000 ? `${(c.likes / 1000).toFixed(c.likes >= 10000 ? 0 : 1)}K` : c.likes}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-zinc-400 leading-relaxed">{c.text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ── Main component ───────────────────────────────────────────────────────────
|
||
|
||
export default function Watch() {
|
||
const { youtubeVideoId } = useParams();
|
||
const navigate = useNavigate();
|
||
const qc = useQueryClient();
|
||
|
||
const [downloadId, setDownloadId] = useState(null);
|
||
const [fileReady, setFileReady] = useState(false);
|
||
const [confirmedFileUrl, setConfirmedFileUrl] = useState(null);
|
||
const [playRequested, setPlayRequested] = useState(false);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [queued, setQueued] = useState(null);
|
||
const [liked, setLiked] = useState(null);
|
||
const [disliked, setDisliked] = useState(null);
|
||
const [selectedQuality, setSelectedQuality] = useState(null);
|
||
const [speed, setSpeed] = useState(1);
|
||
const [autoplay, setAutoplay] = useState(false);
|
||
const [theater, setTheater] = useState(false);
|
||
const [sidebarMode, setSidebarMode] = useState(() => localStorage.getItem("sidebar-related-mode") ?? "weighted");
|
||
const [theaterInitialized, setTheaterInitialized] = useState(false);
|
||
const videoRef = useRef(null);
|
||
const lastSaveRef = useRef(0);
|
||
const pollTimerRef = useRef(null);
|
||
|
||
const onSpeedUp = useCallback(() => {
|
||
setSpeed(s => {
|
||
const next = SPEEDS[Math.min(SPEEDS.indexOf(s) + 1, SPEEDS.length - 1)];
|
||
if (videoRef.current) videoRef.current.playbackRate = next;
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const onSpeedDown = useCallback(() => {
|
||
setSpeed(s => {
|
||
const next = SPEEDS[Math.max(SPEEDS.indexOf(s) - 1, 0)];
|
||
if (videoRef.current) videoRef.current.playbackRate = next;
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const onTheater = useCallback(() => {
|
||
setTheater(t => {
|
||
const next = !t;
|
||
updateSettings({ theater_mode: next });
|
||
qc.setQueryData(["settings"], (old) => old ? { ...old, theater_mode: next } : old);
|
||
return next;
|
||
});
|
||
}, [qc]);
|
||
useVideoKeys(videoRef, { onSpeedUp, onSpeedDown, onTheater });
|
||
|
||
useEffect(() => {
|
||
if (videoRef.current) videoRef.current.playbackRate = speed;
|
||
}, [speed]);
|
||
|
||
const { data: video, refetch: refetchVideo } = useQuery({
|
||
queryKey: ["video-play", youtubeVideoId],
|
||
queryFn: () => getVideoByYtId(youtubeVideoId)
|
||
.then(r => r.data)
|
||
.catch(err => err.response?.status === 404 ? null : Promise.reject(err)),
|
||
enabled: !!youtubeVideoId,
|
||
staleTime: 5 * 60_000,
|
||
refetchInterval: (query) => {
|
||
const v = query.state.data;
|
||
return v && v.description == null ? 3000 : false;
|
||
},
|
||
});
|
||
|
||
const { data: userSettings } = useQuery({
|
||
queryKey: ["settings"],
|
||
queryFn: () => getSettings().then(r => r.data),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (userSettings?.preferred_quality && selectedQuality === null)
|
||
setSelectedQuality(userSettings.preferred_quality);
|
||
if (!theaterInitialized && userSettings) {
|
||
setTheater(userSettings.theater_mode ?? false);
|
||
setAutoplay(userSettings.autoplay_enabled ?? false);
|
||
setTheaterInitialized(true);
|
||
}
|
||
}, [userSettings]);
|
||
|
||
const { data: channel } = useQuery({
|
||
queryKey: ["channel", video?.channel_id],
|
||
queryFn: () => getChannel(video.channel_id).then(r => r.data),
|
||
enabled: !!video?.channel_id,
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
|
||
const { data: channelVideos } = useQuery({
|
||
queryKey: ["channel-videos", video?.channel_id],
|
||
queryFn: () => getChannelVideos(video.channel_id).then(r => r.data),
|
||
enabled: !!video?.channel_id,
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const { data: queueVideos = [] } = useQuery({
|
||
queryKey: ["queue"],
|
||
queryFn: () => getQueue().then(r => r.data),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const { data: discoveryVideos = [] } = useQuery({
|
||
queryKey: ["video-related", video?.id, sidebarMode],
|
||
queryFn: () => getRelatedVideos(video.id, sidebarMode).then(r => r.data),
|
||
enabled: !!video?.id,
|
||
staleTime: sidebarMode === "random" ? 0 : 5 * 60_000,
|
||
});
|
||
|
||
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;
|
||
},
|
||
});
|
||
|
||
// Detect a download that was started elsewhere (e.g. from the VideoCard)
|
||
const { data: allDownloads = [] } = useQuery({
|
||
queryKey: ["downloads"],
|
||
queryFn: () => getDownloads().then(r => r.data),
|
||
staleTime: 5_000,
|
||
enabled: !downloadId,
|
||
});
|
||
useEffect(() => {
|
||
if (downloadId || fileReady) return;
|
||
const active = allDownloads.find(
|
||
d => d.youtube_video_id === youtubeVideoId &&
|
||
(d.status === "pending" || d.status === "downloading"),
|
||
);
|
||
if (active) {
|
||
setDownloadId(active.id);
|
||
setPlayRequested(true);
|
||
}
|
||
}, [allDownloads, youtubeVideoId, downloadId, fileReady]);
|
||
|
||
const pollForFile = useCallback((url) => {
|
||
if (!url || fileReady) return;
|
||
fetch(url, { method: "HEAD" })
|
||
.then(res => {
|
||
if (res.ok) { setConfirmedFileUrl(url); setFileReady(true); }
|
||
else pollTimerRef.current = setTimeout(() => pollForFile(url), 1000);
|
||
})
|
||
.catch(() => { pollTimerRef.current = setTimeout(() => pollForFile(url), 1500); });
|
||
}, [fileReady]);
|
||
|
||
// Only poll once the backend confirms the download is fully written.
|
||
// Polling before status==="complete" risks playing a partial file.
|
||
useEffect(() => {
|
||
if (fileReady || !playRequested) return;
|
||
const backendDone = dlStatus?.status === "complete" || !!video?.is_downloaded;
|
||
const fileUrl = dlStatus?.file_url ?? (video?.is_downloaded ? `/files/${youtubeVideoId}.mp4` : null);
|
||
if (!backendDone || !fileUrl) return;
|
||
pollForFile(fileUrl);
|
||
return () => clearTimeout(pollTimerRef.current);
|
||
}, [playRequested, fileReady, dlStatus?.status, dlStatus?.file_url, video?.is_downloaded, youtubeVideoId, pollForFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const downloadMut = useMutation({
|
||
mutationFn: () => createDownload(youtubeVideoId, selectedQuality),
|
||
onSuccess: (res) => {
|
||
setDownloadId(res.data.id);
|
||
refetchVideo();
|
||
},
|
||
});
|
||
|
||
const handlePlay = useCallback(() => setPlayRequested(true), []);
|
||
const handleDownloadAndPlay = useCallback(() => {
|
||
setPlayRequested(true);
|
||
downloadMut.mutate();
|
||
}, [downloadMut]);
|
||
|
||
const saveProgress = useCallback((secs) => {
|
||
if (!video?.id) return;
|
||
lastSaveRef.current = Date.now();
|
||
const duration = video.duration_seconds ?? 0;
|
||
const threshold = (userSettings?.mark_watched_at_percent ?? 90) / 100;
|
||
const watched = duration > 0 && secs >= duration * threshold;
|
||
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
||
}, [video, userSettings]);
|
||
|
||
const handleTimeUpdate = useCallback(() => {
|
||
if (!videoRef.current) return;
|
||
const secs = Math.floor(videoRef.current.currentTime);
|
||
setCurrentTime(secs);
|
||
if (Date.now() - lastSaveRef.current > 30_000) saveProgress(secs);
|
||
}, [saveProgress]);
|
||
|
||
const handlePause = useCallback(() => {
|
||
if (videoRef.current) saveProgress(Math.floor(videoRef.current.currentTime));
|
||
}, [saveProgress]);
|
||
|
||
useEffect(() => () => {
|
||
clearTimeout(pollTimerRef.current);
|
||
}, []);
|
||
|
||
const followMut = useMutation({
|
||
mutationFn: () => followChannelByUrl({ youtube_channel_id: video?.channel_youtube_id }),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||
});
|
||
|
||
const deleteMut = useMutation({
|
||
mutationFn: () => deleteDownload(downloadId),
|
||
onSuccess: () => {
|
||
setFileReady(false); setConfirmedFileUrl(null); setDownloadId(null);
|
||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||
refetchVideo();
|
||
},
|
||
});
|
||
|
||
const queueMut = useMutation({
|
||
mutationFn: () => toggleQueue(video.id),
|
||
onSuccess: (res) => setQueued(res.data.queued),
|
||
});
|
||
|
||
const likeMut = useMutation({
|
||
mutationFn: () => toggleLike(video.id),
|
||
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
||
});
|
||
|
||
const dislikeMut = useMutation({
|
||
mutationFn: () => rateVideo(video.id, isDisliked ? 0 : -1),
|
||
onSuccess: (res) => setDisliked(res.data.rating === -1),
|
||
});
|
||
|
||
const handlePiP = useCallback(async () => {
|
||
if (!videoRef.current) return;
|
||
try {
|
||
if (document.pictureInPictureElement) await document.exitPictureInPicture();
|
||
else await videoRef.current.requestPictureInPicture();
|
||
} catch (_) {}
|
||
}, []);
|
||
|
||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
||
const title = video?.title ?? youtubeVideoId;
|
||
const channelName = video?.channel_name ?? channel?.name;
|
||
const date = formatDate(video?.published_at);
|
||
const startAt = video?.watch_progress_seconds ?? 0;
|
||
const isQueued = queued ?? video?.queued ?? false;
|
||
const isLiked = liked ?? video?.liked ?? false;
|
||
const isDisliked = disliked ?? (video?.rating === -1) ?? false;
|
||
const dlComplete = dlStatus?.status === "complete" || video?.is_downloaded;
|
||
const isFollowed = followMut.isSuccess || video?.channel_followed;
|
||
const subs = formatSubs(channel?.subscriber_count);
|
||
|
||
const channelOtherVideos = channelVideos?.filter(v => v.youtube_video_id !== youtubeVideoId) ?? [];
|
||
const nextFromQueue = queueVideos.find(v => v.youtube_video_id !== youtubeVideoId) ?? null;
|
||
const nextFromChannel = channelOtherVideos.find(v => !v.is_watched) ?? channelOtherVideos[0] ?? null;
|
||
const nextFromRelated = discoveryVideos[0] ?? null;
|
||
const nextVideo = nextFromQueue ?? nextFromChannel ?? nextFromRelated ?? null;
|
||
const nextVideoSource = nextFromQueue ? "queue" : nextFromChannel ? "channel" : nextFromRelated ? "related" : null;
|
||
const channelSidebarVideos = channelOtherVideos.filter(v => v !== nextVideo).slice(0, 4);
|
||
const tags = video?.tags ? video.tags.split(",").map(t => t.trim()).filter(Boolean).slice(0, 12) : [];
|
||
const hasSidebar = nextVideo || channelSidebarVideos.length > 0 || discoveryVideos.length > 0;
|
||
|
||
const handleAutoplayChange = useCallback((next) => {
|
||
setAutoplay(next);
|
||
updateSettings({ autoplay_enabled: next });
|
||
qc.setQueryData(["settings"], (old) => old ? { ...old, autoplay_enabled: next } : old);
|
||
}, [qc]);
|
||
|
||
const handleSidebarModeToggle = useCallback(() => {
|
||
setSidebarMode(m => {
|
||
const next = m === "weighted" ? "random" : "weighted";
|
||
localStorage.setItem("sidebar-related-mode", next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const handleEnded = useCallback(() => {
|
||
if (videoRef.current) saveProgress(Math.floor(videoRef.current.currentTime));
|
||
if (autoplay && nextVideo) navigate(`/watch/${nextVideo.youtube_video_id}`);
|
||
}, [saveProgress, autoplay, nextVideo, navigate]);
|
||
|
||
return (
|
||
<div className={theater ? "flex flex-col gap-0 pb-16" : "max-w-screen-xl mx-auto flex flex-col gap-0 pb-16"}>
|
||
|
||
{/* Back */}
|
||
<button
|
||
onClick={() => navigate(-1)}
|
||
className={`flex items-center gap-1 text-zinc-500 hover:text-zinc-300 transition-colors text-sm mb-4 self-start ${theater ? "px-4" : ""}`}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Back
|
||
</button>
|
||
|
||
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
|
||
|
||
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
||
<div className={theater ? "w-full flex flex-col gap-4 sm:gap-5" : "flex-1 min-w-0 flex flex-col gap-4 sm:gap-5"}>
|
||
|
||
{/* Player */}
|
||
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
|
||
{fileReady && playRequested ? (
|
||
<video
|
||
ref={videoRef}
|
||
src={confirmedFileUrl}
|
||
controls
|
||
className="w-full h-full"
|
||
onTimeUpdate={handleTimeUpdate}
|
||
onPause={handlePause}
|
||
onEnded={handleEnded}
|
||
onLoadedMetadata={() => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
v.playbackRate = speed;
|
||
if (video?.watch_progress_seconds > 10) v.currentTime = video.watch_progress_seconds;
|
||
v.play().catch(() => {});
|
||
}}
|
||
/>
|
||
) : (
|
||
<Placeholder
|
||
video={video}
|
||
dlStatus={dlStatus}
|
||
isDownloading={downloadMut.isPending || isDownloading}
|
||
onPlay={dlComplete && !playRequested ? handlePlay : null}
|
||
onDownloadAndPlay={!dlComplete && !isDownloading && !downloadMut.isPending && !playRequested ? handleDownloadAndPlay : null}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Download progress */}
|
||
{isDownloading && (
|
||
<div className="h-1 w-full bg-zinc-800 rounded-full overflow-hidden -mt-2">
|
||
<div
|
||
className="h-full bg-accent rounded-full transition-all duration-700"
|
||
style={{ width: `${dlStatus.progress_percent ?? 0}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Title */}
|
||
<h1 className="text-base sm:text-xl font-bold text-white leading-snug">{title}</h1>
|
||
|
||
{/* Meta + actions row */}
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<div className="flex items-center gap-2 text-xs text-zinc-500 flex-wrap">
|
||
{date && <span>{date}</span>}
|
||
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
||
{video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>}
|
||
{video?.category && <><span>·</span><span>{video.category}</span></>}
|
||
{video?.duration_seconds && (
|
||
<><span>·</span>
|
||
<span className="tabular-nums">
|
||
{formatDuration(currentTime || startAt)}
|
||
<span className="text-zinc-700 mx-1">/</span>
|
||
<span className="text-zinc-600">{formatDuration(video.duration_seconds)}</span>
|
||
</span></>
|
||
)}
|
||
{fileReady && isDownloading && (
|
||
<span className="text-accent flex items-center gap-1">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-accent inline-block animate-pulse" />
|
||
saving
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
||
<select
|
||
value={selectedQuality ?? "best"}
|
||
onChange={(e) => setSelectedQuality(e.target.value)}
|
||
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
|
||
>
|
||
{[
|
||
["best", "Highest available"],
|
||
["2160p", "4K — 2160p"],
|
||
["1440p", "2K — 1440p"],
|
||
["1080p", "1080p — Full HD"],
|
||
["720p", "720p — HD"],
|
||
["480p", "480p — SD"],
|
||
["360p", "360p"],
|
||
["240p", "240p"],
|
||
["144p", "144p"],
|
||
].map(([v, label]) => (
|
||
<option key={v} value={v}>{label}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
|
||
{fileReady && (
|
||
<select
|
||
value={speed}
|
||
onChange={(e) => setSpeed(Number(e.target.value))}
|
||
className="bg-zinc-800 text-zinc-300 text-xs rounded-full px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent"
|
||
>
|
||
{SPEEDS.map(s => (
|
||
<option key={s} value={s}>{s === 1 ? "1× Speed" : `${s}×`}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
|
||
<Chip
|
||
active={dlComplete}
|
||
disabled={dlComplete || isDownloading || downloadMut.isPending}
|
||
onClick={() => !dlComplete && !isDownloading && downloadMut.mutate()}
|
||
>
|
||
{dlComplete ? (
|
||
<><svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>Saved</>
|
||
) : isDownloading || downloadMut.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>Downloading</>
|
||
) : (
|
||
<><svg className="w-4 h-4" 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>Download</>
|
||
)}
|
||
</Chip>
|
||
|
||
{dlComplete && downloadId && (
|
||
<Chip onClick={() => deleteMut.mutate()} disabled={deleteMut.isPending}>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Delete
|
||
</Chip>
|
||
)}
|
||
|
||
{video?.id && (
|
||
<>
|
||
<Chip active={isLiked} onClick={() => likeMut.mutate()} disabled={likeMut.isPending}>
|
||
<svg className="w-4 h-4" fill={isLiked ? "currentColor" : "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>
|
||
{isLiked ? "Liked" : "Like"}
|
||
</Chip>
|
||
<Chip active={isDisliked} onClick={() => dislikeMut.mutate()} disabled={dislikeMut.isPending} title="Not for me">
|
||
<svg className="w-4 h-4" fill={isDisliked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3H10z"/>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 2h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
|
||
</svg>
|
||
</Chip>
|
||
</>
|
||
)}
|
||
|
||
|
||
{video?.id && (
|
||
<Chip active={isQueued} onClick={() => queueMut.mutate()} disabled={queueMut.isPending}>
|
||
<svg className="w-4 h-4" fill={isQueued ? "currentColor" : "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>
|
||
{isQueued ? "Queued" : "Watch Later"}
|
||
</Chip>
|
||
)}
|
||
|
||
{fileReady && document.pictureInPictureEnabled && (
|
||
<Chip onClick={handlePiP} title="Picture in picture">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<rect x="2" y="4" width="20" height="16" rx="2" strokeWidth="2"/>
|
||
<rect x="12" y="12" width="8" height="6" rx="1.5" strokeWidth="2"/>
|
||
</svg>
|
||
</Chip>
|
||
)}
|
||
|
||
<Chip active={theater} onClick={() => setTheater(t => !t)} title={theater ? "Exit theater" : "Theater mode"}>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
{theater ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 5V4h5M15 9l5-5m0 5V4h-5M9 15l-5 5m0-5v5h5M15 15l5 5m0-5v5h-5" />
|
||
) : (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V5a1 1 0 011-1h3M4 16v3a1 1 0 001 1h3m10-4v3a1 1 0 01-1 1h-3M20 8V5a1 1 0 00-1-1h-3" />
|
||
)}
|
||
</svg>
|
||
</Chip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="h-px bg-zinc-800" />
|
||
|
||
{/* Channel row */}
|
||
<div className="flex items-center gap-3">
|
||
<Link to={`/channels/${video?.channel_id}`} className="shrink-0">
|
||
{channel?.thumbnail_url ? (
|
||
<img src={channel.thumbnail_url} alt={channelName}
|
||
className="w-9 h-9 sm:w-11 sm:h-11 rounded-full object-cover" />
|
||
) : (
|
||
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
|
||
style={{ backgroundColor: avatarColor(channelName) }}>
|
||
{channelName?.[0]?.toUpperCase()}
|
||
</div>
|
||
)}
|
||
</Link>
|
||
<div className="flex-1 min-w-0">
|
||
<Link to={`/channels/${video?.channel_id}`}
|
||
className="font-semibold text-white text-sm hover:text-zinc-300 transition-colors block truncate">
|
||
{channelName ?? "Unknown channel"}
|
||
</Link>
|
||
{subs && !(userSettings?.hide_subscriber_counts) && <p className="text-xs text-zinc-500">{subs} subscribers</p>}
|
||
</div>
|
||
{video?.channel_youtube_id && !isFollowed && (
|
||
<button
|
||
onClick={() => followMut.mutate()}
|
||
disabled={followMut.isPending}
|
||
className="shrink-0 px-4 py-1.5 rounded-full bg-zinc-100 text-zinc-900 text-sm font-semibold hover:bg-white transition-colors disabled:opacity-60"
|
||
>
|
||
Follow
|
||
</button>
|
||
)}
|
||
{isFollowed && (
|
||
<span className="shrink-0 text-xs text-zinc-500 font-medium">Following ✓</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{video?.description && (
|
||
<DescriptionBox text={video.description} />
|
||
)}
|
||
|
||
{/* Tags */}
|
||
{tags.length > 0 && (
|
||
<div className="hidden sm:flex flex-wrap gap-1.5">
|
||
{tags.map(tag => (
|
||
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Collections */}
|
||
{video?.id && <AddToCollectionSection videoId={video.id} />}
|
||
|
||
{/* Bookmarks */}
|
||
{video?.id && (
|
||
<BookmarksSection videoId={video.id} videoRef={videoRef} />
|
||
)}
|
||
|
||
{/* Comments */}
|
||
{video?.youtube_video_id && (
|
||
<CommentsSection youtubeVideoId={video.youtube_video_id} />
|
||
)}
|
||
|
||
{/* Keyboard shortcuts hint */}
|
||
<p className={`hidden sm:block text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
||
</p>
|
||
|
||
{/* ── Theater mode sidebar (grid below info) ───────────────────── */}
|
||
{theater && hasSidebar && (
|
||
<div className="flex flex-col gap-8 pt-6 border-t border-zinc-800">
|
||
{nextVideo && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Up Next {nextVideoSource === "queue" ? <span className="text-accent normal-case font-normal">· from queue</span> : nextVideoSource === "related" ? <span className="normal-case font-normal">· suggested</span> : null}</h2>
|
||
<AutoplayToggle value={autoplay} onChange={handleAutoplayChange} />
|
||
</div>
|
||
<div className="max-w-[240px]">
|
||
<VideoCard video={nextVideo} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{channelSidebarVideos.length > 0 && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">From {channelName}</h2>
|
||
{video?.channel_id && (
|
||
<Link to={`/channels/${video.channel_id}`} className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors">
|
||
See all →
|
||
</Link>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||
{channelSidebarVideos.map(v => <VideoCard key={v.youtube_video_id} video={v} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{discoveryVideos.length > 0 && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Recommended for you</h2>
|
||
<SidebarModeToggle mode={sidebarMode} onToggle={handleSidebarModeToggle} />
|
||
</div>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||
{discoveryVideos.map(v => <VideoCard key={v.youtube_video_id} video={v} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Normal sidebar (right column) ────────────────────────────────── */}
|
||
{!theater && hasSidebar && (
|
||
<div className="xl:w-[360px] shrink-0 flex flex-col gap-6">
|
||
|
||
{/* Up Next */}
|
||
{nextVideo && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Up Next {nextVideoSource === "queue" ? <span className="text-accent normal-case font-normal">· from queue</span> : nextVideoSource === "related" ? <span className="normal-case font-normal">· suggested</span> : null}</h2>
|
||
<AutoplayToggle value={autoplay} onChange={handleAutoplayChange} />
|
||
</div>
|
||
<VideoRow video={nextVideo} />
|
||
</div>
|
||
)}
|
||
|
||
{/* More from channel */}
|
||
{channelSidebarVideos.length > 0 && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||
From {channelName}
|
||
</h2>
|
||
{video?.channel_id && (
|
||
<Link to={`/channels/${video.channel_id}`}
|
||
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors">
|
||
See all →
|
||
</Link>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{channelSidebarVideos.map(v => (
|
||
<VideoRow key={v.youtube_video_id} video={v} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Recommended from discovery */}
|
||
{discoveryVideos.length > 0 && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||
Recommended for you
|
||
</h2>
|
||
<SidebarModeToggle mode={sidebarMode} onToggle={handleSidebarModeToggle} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{discoveryVideos.map(v => (
|
||
<VideoRow key={v.youtube_video_id} video={v} showChannel />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|