Files
youclonedl/frontend/src/pages/Watch.jsx
Mattias Thall 27f17c16ef Fix subtitle playback: vtt format, track elements, fast disk scan
- Convert subs to .vtt (was .srt which browsers don't support in <track>)
- Add GET /subtitle-files endpoint: instant disk scan for .vtt sidecar files,
  no yt-dlp call needed
- Inject <track> elements into the video player for each .vtt on disk;
  browser CC button appears automatically
- Before download: CC chip triggers YouTube availability check (slow, on demand)
- After download with subs: shows "CC ✓" — subtitles live in the player controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:11:58 +02:00

1290 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, getAvailableSubs, getSubtitleFiles,
} 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 &amp; 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 [isRedownloading, setIsRedownloading] = useState(false);
const [selectedQuality, setSelectedQuality] = useState(null);
const [selectedSubLang, setSelectedSubLang] = useState("");
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 [subsRequested, setSubsRequested] = useState(false);
useEffect(() => { setSubsRequested(false); setSelectedSubLang(""); }, [youtubeVideoId]);
const { data: availableSubs, isLoading: subsLoading } = useQuery({
queryKey: ["available-subs", youtubeVideoId],
queryFn: () => getAvailableSubs(youtubeVideoId).then(r => r.data),
enabled: subsRequested && !!youtubeVideoId,
staleTime: 30 * 60_000,
});
const { data: subtitleFiles = [] } = useQuery({
queryKey: ["subtitle-files", youtubeVideoId],
queryFn: () => getSubtitleFiles(youtubeVideoId).then(r => r.data),
enabled: fileReady,
staleTime: Infinity,
});
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: ({ quality, subLang } = {}) =>
createDownload(youtubeVideoId, quality ?? selectedQuality, subLang ?? selectedSubLang),
onSuccess: (res) => {
setDownloadId(res.data.id);
refetchVideo();
},
});
const handlePlay = useCallback(() => setPlayRequested(true), []);
const handleDownloadAndPlay = useCallback(() => {
setPlayRequested(true);
downloadMut.mutate({});
}, [downloadMut]);
const handleRedownload = useCallback(async (quality) => {
const dlId = downloadId ?? allDownloads.find(
d => d.youtube_video_id === youtubeVideoId && d.status === "complete"
)?.id;
if (!dlId) return;
setIsRedownloading(true);
try { await deleteDownload(dlId); } catch (_) {}
setFileReady(false);
setConfirmedFileUrl(null);
setDownloadId(null);
setPlayRequested(false);
setIsRedownloading(false);
qc.invalidateQueries({ queryKey: ["downloads"] });
refetchVideo();
downloadMut.mutate({ quality });
}, [downloadId, allDownloads, youtubeVideoId, downloadMut, refetchVideo, qc]);
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 downloadedResolution = dlStatus?.resolution ?? video?.download_resolution;
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(() => {});
}}
>
{subtitleFiles.map((s, i) => (
<track
key={s.lang}
kind="subtitles"
src={s.url}
srcLang={s.lang}
label={s.lang}
default={i === 0}
/>
))}
</video>
) : (
<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">
{!isDownloading && !downloadMut.isPending && !isRedownloading && (
<select
value={selectedQuality ?? "best"}
onChange={(e) => {
const q = e.target.value;
setSelectedQuality(q);
if (dlComplete) handleRedownload(q);
}}
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>
)}
{(() => {
// After download: subtitle files on disk are served via <track> in the player
if (fileReady && subtitleFiles.length > 0) return (
<span className="flex items-center gap-1 px-3 py-1.5 rounded-full bg-zinc-800 text-zinc-400 text-xs" title="Subtitles loaded — use the CC button in the player">
CC
</span>
);
// Before download: let user pick a lang to download with
if (dlComplete) return null;
if (!subsRequested) return (
<button
onClick={() => setSubsRequested(true)}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors"
title="Check available subtitles on YouTube"
>
CC
</button>
);
if (subsLoading) return (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-zinc-800 text-zinc-500 text-xs">
<span className="w-3 h-3 border border-zinc-600 border-t-transparent rounded-full animate-spin inline-block" />
CC
</span>
);
const manual = new Set(availableSubs?.manual ?? []);
const auto = (availableSubs?.auto ?? []).filter(l => !manual.has(l));
if (!manual.size && !auto.length) return (
<span className="px-3 py-1.5 rounded-full bg-zinc-800 text-zinc-600 text-xs">No CC</span>
);
return (
<select
value={selectedSubLang}
onChange={(e) => setSelectedSubLang(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"
title="Subtitle language to download"
>
<option value="">No subtitles</option>
{[...manual].map(l => <option key={l} value={l}>{l}</option>)}
{auto.map(l => <option key={l} value={l}>{l} (auto)</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{downloadedResolution ? ` · ${downloadedResolution}` : ""}</>
) : 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 &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; 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>
);
}