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, } 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.title} )} {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 { data: availableSubs, isLoading: subsLoading } = useQuery({ queryKey: ["available-subs", youtubeVideoId], queryFn: () => getAvailableSubs(youtubeVideoId).then(r => r.data), enabled: !!youtubeVideoId, staleTime: 10 * 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: ({ 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 && ( )} {!dlComplete && (() => { const manual = new Set(availableSubs?.manual ?? []); const auto = (availableSubs?.auto ?? []).filter(l => !manual.has(l)); const allLangs = [...manual, ...auto]; if (subsLoading) return ( CC ); if (!allLangs.length) return null; return ( ); })()} {fileReady && ( )} !dlComplete && !isDownloading && downloadMut.mutate({})} > {dlComplete ? ( <>Saved{downloadedResolution ? ` · ${downloadedResolution}` : ""} ) : isDownloading || downloadMut.isPending ? ( <>Downloading ) : ( <>Download )} {dlComplete && downloadId && ( deleteMut.mutate()} disabled={deleteMut.isPending}> Delete )} {video?.id && ( <> likeMut.mutate()} disabled={likeMut.isPending}> {isLiked ? "Liked" : "Like"} dislikeMut.mutate()} disabled={dislikeMut.isPending} title="Not for me"> )} {video?.id && ( queueMut.mutate()} disabled={queueMut.isPending}> {isQueued ? "Queued" : "Watch Later"} )} {fileReady && document.pictureInPictureEnabled && ( )} setTheater(t => !t)} title={theater ? "Exit theater" : "Theater mode"}> {theater ? ( ) : ( )}
{/* Divider */}
{/* Channel row */}
{channel?.thumbnail_url ? ( {channelName} ) : (
{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 => ( ))}
)}
)}
); }