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) ? (
e.stopPropagation()}
>{part}
) : 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 (
hasMore && setExpanded(v => !v)}
>
{linkify(displayed)}
{hasMore && (
{expanded ? "Show less" : "Show more"}
)}
);
}
// ── Action button ────────────────────────────────────────────────────────────
function Chip({ onClick, active, disabled, children }) {
return (
);
}
// ── Sidebar mode toggle ──────────────────────────────────────────────────────
function SidebarModeToggle({ mode, onToggle }) {
const isRandom = mode === "random";
return (
);
}
// ── Autoplay toggle ──────────────────────────────────────────────────────────
function AutoplayToggle({ value, onChange }) {
return (
);
}
// ── Sidebar video row ────────────────────────────────────────────────────────
function VideoRow({ video: v, showChannel = false }) {
return (
{v.thumbnail_url && (

)}
{v.duration_seconds && (
{formatDuration(v.duration_seconds)}
)}
{v.is_watched && (
✓
)}
{v.watch_progress_seconds > 0 && v.duration_seconds > 0 && (
)}
{v.title}
{showChannel && v.channel_name && (
{v.channel_name}
)}
{v.published_at && (
{new Date(v.published_at).toLocaleDateString("en-US", { year: "numeric", month: "short" })}
)}
);
}
// ── 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 (
{video?.thumbnail_url && (

)}
{dlStatus?.status === "failed" ? (
Download failed
) : active ? (
<>
{pct.toFixed(0)}%
Preparing…
>
) : polling ? (
) : onPlay ? (
) : onDownloadAndPlay ? (
) : null}
);
}
// ── 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 (
Collections
{open && (
{collections.length === 0 ? (
No collections yet — create one on the Collections page.
) : collections.map(col => (
))}
)}
);
}
// ── 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 }) => (
{!isChapter && editingId === bm.id ? (
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"
/>
) : (
{ 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 && Add note…)}
)}
);
return (
{/* Chapters (auto) */}
{chapters.length > 0 && (
Chapters
{chapters.map(bm => )}
)}
{/* Manual bookmarks */}
Bookmarks
{manual.length === 0 ? (
No bookmarks yet.
) : (
{manual.map(bm => )}
)}
);
}
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 (
No comments loaded yet
);
}
return (
Top comments
{comments.map((c, i) => (
{c.author}
{c.is_pinned && (
Pinned
)}
{c.likes > 0 && (
👍 {c.likes >= 1000 ? `${(c.likes / 1000).toFixed(c.likes >= 10000 ? 0 : 1)}K` : c.likes}
)}
{c.text}
))}
);
}
// ── 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 (
{/* Back */}
{/* ── Left: video + info ───────────────────────────────────────────── */}
{/* Player */}
{fileReady && playRequested ? (
) : (
)}
{/* Download progress */}
{isDownloading && (
)}
{/* Title */}
{title}
{/* Meta + actions row */}
{date && {date}}
{video?.view_count > 0 && <>·{formatViews(video.view_count)}>}
{video?.like_count > 0 && <>·{formatViews(video.like_count).replace(" views", "")} likes>}
{video?.category && <>·{video.category}>}
{video?.duration_seconds && (
<>·
{formatDuration(currentTime || startAt)}
/
{formatDuration(video.duration_seconds)}
>
)}
{fileReady && isDownloading && (
saving
)}
{/* Actions */}
{!isDownloading && !downloadMut.isPending && !isRedownloading && (
)}
{(() => {
// After download: subtitle files on disk are served via
{/* Divider */}
{/* Channel row */}
{channel?.thumbnail_url ? (

) : (
{channelName?.[0]?.toUpperCase()}
)}
{channelName ?? "Unknown channel"}
{subs && !(userSettings?.hide_subscriber_counts) &&
{subs} subscribers
}
{video?.channel_youtube_id && !isFollowed && (
)}
{isFollowed && (
Following ✓
)}
{/* Description */}
{video?.description && (
)}
{/* Tags */}
{tags.length > 0 && (
{tags.map(tag => (
{tag}
))}
)}
{/* Collections */}
{video?.id &&
}
{/* Bookmarks */}
{video?.id && (
)}
{/* Comments */}
{video?.youtube_video_id && (
)}
{/* Keyboard shortcuts hint */}
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
{/* ── Theater mode sidebar (grid below info) ───────────────────── */}
{theater && hasSidebar && (
{nextVideo && (
Up Next {nextVideoSource === "queue" ? · from queue : nextVideoSource === "related" ? · suggested : null}
)}
{channelSidebarVideos.length > 0 && (
From {channelName}
{video?.channel_id && (
See all →
)}
{channelSidebarVideos.map(v => )}
)}
{discoveryVideos.length > 0 && (
Recommended for you
{discoveryVideos.map(v => )}
)}
)}
{/* ── Normal sidebar (right column) ────────────────────────────────── */}
{!theater && hasSidebar && (
{/* Up Next */}
{nextVideo && (
Up Next {nextVideoSource === "queue" ? · from queue : nextVideoSource === "related" ? · suggested : null}
)}
{/* More from channel */}
{channelSidebarVideos.length > 0 && (
From {channelName}
{video?.channel_id && (
See all →
)}
{channelSidebarVideos.map(v => (
))}
)}
{/* Recommended from discovery */}
{discoveryVideos.length > 0 && (
Recommended for you
{discoveryVideos.map(v => (
))}
)}
)}
);
}