import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; import { api, type Video } from "../lib/api"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; import { useBackToClose } from "../lib/history"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position // and resume where the user left off. The modal closes via the in-card Close // button, the backdrop, or ESC (ESC only while focus is on our page, not inside // the cross-origin player iframe — a browser security boundary we can't cross). // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; // --- IFrame Player API loader (singleton) --- let apiPromise: Promise | null = null; function loadYouTubeApi(): Promise { if (apiPromise) return apiPromise; apiPromise = new Promise((resolve) => { const w = window as any; if (w.YT && w.YT.Player) return resolve(w.YT); const prev = w.onYouTubeIframeAPIReady; w.onYouTubeIframeAPIReady = () => { if (typeof prev === "function") prev(); resolve(w.YT); }; const tag = document.createElement("script"); tag.src = "https://www.youtube.com/iframe_api"; document.head.appendChild(tag); }); return apiPromise; } export default function PlayerModal({ video, startAt, queue, startIndex, onClose, onState, onOpenChannel, }: { video: Video; // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // to resume from the server-saved position carried on the video. startAt?: number | null; // Optional playback queue (e.g. a playlist). When present, the player advances through // it (auto-advance on end + prev/next), recreating per item to reuse the single-video // resume / auto-watch / progress logic. queue?: Video[]; startIndex?: number; onClose: () => void; onState: (id: string, status: string) => void; // Open the active video's channel page in-app (closes the player first). The small external // icon next to the name keeps the open-on-YouTube behaviour. onOpenChannel?: (channelId: string, channelName?: string) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Browser/mouse Back closes the player instead of leaving the page. useBackToClose(onClose); // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). const fullDate = (d: string | null | undefined) => d ? new Date(d).toLocaleDateString(i18n.language, { year: "numeric", month: "short", day: "numeric", }) : ""; // Track the playing item by id (not a frozen index) so it survives the queue changing // under us — e.g. an item removed in another tab. The index is derived from the *live* // queue, so the N / M counter and prev/next neighbours stay correct without disrupting // playback of the current video. const [playingId, setPlayingId] = useState( queue?.[startIndex ?? 0]?.id ?? video.id ); let index = queue ? queue.findIndex((v) => v.id === playingId) : 0; if (index < 0) index = 0; const active = queue && queue[index] ? queue[index] : video; const hasQueue = !!queue && queue.length > 1; // startAt only applies to the item we opened with; advanced items resume their own pos. const resumeAt = startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); const autoMarkedRef = useRef(false); // Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player // iframe) so F / Space / Esc keep working until the user clicks into YouTube's native // controls; the interaction overlay below also stops the iframe stealing focus on a video // click. `stageRef` is the element we fullscreen (so the volume overlay stays visible). const cardRef = useRef(null); const stageRef = useRef(null); const overlayRef = useRef(null); const volTimerRef = useRef(undefined); // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. const [volumeUi, setVolumeUi] = useState(null); const focusModal = () => cardRef.current?.focus(); const togglePlay = () => { const p = playerRef.current; if (!p || typeof p.getPlayerState !== "function") return; if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing else p.playVideo?.(); }; const toggleFullscreen = () => { const el = stageRef.current; if (!el) return; if (document.fullscreenElement) document.exitFullscreen?.(); else el.requestFullscreen?.(); }; const flashVolume = (level: number) => { setVolumeUi(level); window.clearTimeout(volTimerRef.current); volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200); }; const nudgeVolume = (delta: number) => { const p = playerRef.current; if (!p || typeof p.getVolume !== "function") return; const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50; const next = Math.max(0, Math.min(100, Math.round(cur + delta))); if (next > 0) p.unMute?.(); p.setVolume?.(next); if (next === 0) p.mute?.(); flashVolume(next); }; // The player can navigate to other videos (YouTube links in a description). The // currently-playing id may differ from the active item. const [currentVideoId, setCurrentVideoId] = useState(active.id); const currentIdRef = useRef(active.id); const navigated = currentVideoId !== active.id; // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); // IFrame Player API error code, when the current video can't play in the embed (e.g. an // auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private → // 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error. const [playerError, setPlayerError] = useState(null); const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; // Explicit start wins; otherwise resume the active item, start others at 0. const startSeconds = start != null ? start : id === active.id ? resumeAt : 0; setPlayerError(null); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); currentIdRef.current = id; setCurrentVideoId(id); setLiveData(null); }; // Local mirror of watch status so the toggle reacts instantly; changes are // propagated to the feed (and server) via onState. const [status, setStatus] = useState(active.status); const watched = status === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus(next); onState(active.id, next); }; // On queue advance (active changes), sync local state to the new item. useEffect(() => { setStatus(active.status); setCurrentVideoId(active.id); currentIdRef.current = active.id; // eslint-disable-next-line react-hooks/exhaustive-deps }, [active.id]); const seekTo = (seconds: number) => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { p.seekTo(seconds, true); p.playVideo?.(); } }; // Lazy description (fetched only when the title is hovered). The popover is // portaled to with fixed positioning so the modal card's overflow-y-auto // can't clip it. A small close grace lets the mouse travel title → popover. const [showDesc, setShowDesc] = useState(false); // `bottom` anchors the popover just above the title so it grows upward (over the // player) instead of downward off-screen / behind the OS taskbar. const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>( null ); const titleRef = useRef(null); const closeTimer = useRef(undefined); const openDesc = () => { window.clearTimeout(closeTimer.current); const el = titleRef.current; if (el) { const r = el.getBoundingClientRect(); const width = Math.min(560, window.innerWidth - 32); const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16)); setDescRect({ left, bottom: window.innerHeight - r.top + 8, width }); } setShowDesc(true); }; const scheduleCloseDesc = () => { closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); }; const detail = useQuery({ queryKey: ["video-detail", currentVideoId], queryFn: () => api.videoDetail(currentVideoId), // On hover (for the description) and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. enabled: showDesc || navigated, staleTime: 5 * 60_000, }); // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock. // These fire only while focus is on our page, not inside the cross-origin player iframe — so // we focus the modal card on open and again on player-ready (autoplay can grab focus). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (document.fullscreenElement) return; onClose(); return; } const el = document.activeElement as HTMLElement | null; const tag = (el?.tagName || "").toLowerCase(); const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable; if (e.key === "f" || e.key === "F") { if (typing) return; e.preventDefault(); toggleFullscreen(); } else if (e.key === " " || e.code === "Space") { // Let Space activate a focused button/link; otherwise toggle playback (and stop the // page from scrolling). if (typing || tag === "button" || tag === "a") return; e.preventDefault(); togglePlay(); } }; window.addEventListener("keydown", onKey); focusModal(); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; window.clearTimeout(closeTimer.current); window.clearTimeout(volTimerRef.current); }; }, [onClose]); // Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the // interaction overlay above it (non-passive so preventDefault stops the modal scrolling). useEffect(() => { const el = overlayRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); focusModal(); // re-grab focus if it had slipped into the native controls nudgeVolume(e.deltaY < 0 ? 5 : -5); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); // Re-attach if the overlay remounts (it's hidden while the embed shows an error). }, [playerError]); // Create the player, resume from the saved position, persist progress, and // auto-mark watched once playback reaches the end. useEffect(() => { let cancelled = false; const id = active.id; autoMarkedRef.current = false; setPlayerError(null); // Auto-watch only applies to the active item — not to other videos the player // navigated to via description links. const maybeAutoWatch = (current: number, duration: number): boolean => { if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; if (current > duration - FINISH_MARGIN) { autoMarkedRef.current = true; setWatched(true); return true; } return false; }; const persist = (): Promise | void => { const p = playerRef.current; if (!p || typeof p.getCurrentTime !== "function") return; try { const cur = p.getCurrentTime(); const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; // If we just auto-marked watched, skip the near-end progress checkpoint: it would // only clear the resume position, and firing both at once needlessly races the // watched write against the progress row it deletes. if (maybeAutoWatch(cur, dur)) return; // Only checkpoint the feed video we opened with — a navigated-to video may not // be in this user's library, so the server would 404 on it. if (currentIdRef.current === id) { return api.saveProgress(id, cur, dur).catch(() => {}); } } catch { /* player may be tearing down */ } }; loadYouTubeApi().then((YT) => { if (cancelled || !mountRef.current) return; playerRef.current = new YT.Player(mountRef.current, { width: "100%", height: "100%", videoId: id, playerVars: { autoplay: 1, start: resumeAt || undefined, rel: 0, // limit "related" to the same channel (full removal is no longer possible) enablejsapi: 1, origin: window.location.origin, playsinline: 1, }, events: { // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away. onReady: () => focusModal(), onStateChange: (e: any) => { // 1 === playing → sync the navigated-to video's title/author for display. if (e?.data === 1) { const p = playerRef.current; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); } // 0 === ended → mark watched, then advance to the next queue item if any. if (e?.data === 0 && currentIdRef.current === id) { if (!autoMarkedRef.current) { autoMarkedRef.current = true; setWatched(true); } if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id); } }, // The embed couldn't play this video (embedding disabled, removed, private…). // Surface our own message + an "Open on YouTube" escape hatch. onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1), }, }); }); // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. const timer = window.setInterval(persist, 5000); return () => { cancelled = true; window.clearInterval(timer); // Final flush, then refresh the feed + playlists so resume bars reflect this session. Promise.resolve(persist()).finally(() => { qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["playlist"] }); }); const p = playerRef.current; if (p && typeof p.destroy === "function") { try { p.destroy(); } catch { /* ignore */ } } playerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [active.id]); return (
e.stopPropagation()} >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */}
{/* Interaction layer over the video: catches the scroll wheel (volume) and click (play/pause), and stops the iframe stealing keyboard focus. The bottom strip is left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen) stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */} {playerError == null && (
{ focusModal(); togglePlay(); }} title={t("player.shortcutsHint")} aria-label={t("player.shortcutsHint")} className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer" /> )} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} {volumeUi != null && (
{volumeUi === 0 ? : }
{volumeUi}
)} {playerError != null && (

{t("player.unavailableTitle")}

{playerError === 101 || playerError === 150 ? t("player.embedDisabledBody") : t("player.unavailableBody")}

{t("player.openOnYoutube")}
)}
{hasQueue && (
{index + 1} / {queue!.length}
)}
{/* Title row — title (with hover description) on the left, Close on the right. */}

{/* Hover target is the text itself (inline), not the whole row. */} {navigated ? liveData?.title ?? t("player.loading") : active.title}

{navigated && ( )} {showDesc && descRect && createPortal(
{t("player.description")}
{detail.isLoading ? (
{t("player.loading")}
) : detail.data?.description ? (
{renderDescription(detail.data.description, { currentId: currentVideoId, onSeek: seekTo, onLoadVideo: loadVideo, t, })}
) : (
{t("player.noDescription")}
)}
, document.body )}
{/* Channel + meta on one line, with the watched toggle pushed to the right. When navigated to a linked video we only have its author (from the player), so we show that as plain text and hide feed-video-specific bits. */}
{!navigated && ( )} {navigated ? ( detail.data?.channel_id ? ( {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} ) : ( {liveData?.author ?? ""} ) ) : (
)} {!navigated ? (
{active.view_count != null && ( · {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })} )} · {relativeTime(active.published_at)} {active.published_at && ` · ${fullDate(active.published_at)}`} {active.duration_seconds != null && ( · {formatDuration(active.duration_seconds)} )} {active.live_status === "was_live" && ( {t("player.stream")} )}
) : ( // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( · {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })} )} {detail.data?.published_at && ( · {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)} )} {detail.data?.duration_seconds != null && ( · {formatDuration(detail.data.duration_seconds)} )}
)} {!navigated && ( )} {!navigated && ( )} {!navigated && ( )}
); }