import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; // Turn a description into clickable nodes: // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player // - a YouTube link to the *current* video → seek (to its t= if any) // - a YouTube link to *another* video → load it in the inline player // - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab const URL_RE = /(https?:\/\/[^\s<>]+)/g; // One pass over plain text for the three inline token kinds (email | hashtag | timestamp). const INLINE_RE = /([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu; function tsToSeconds(ts: string): number { const parts = ts.split(":").map((n) => parseInt(n, 10)); return parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1]; } // Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s". function parseStart(t: string | null): number | null { if (!t) return null; if (/^\d+$/.test(t)) return parseInt(t, 10) || null; const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); if (!m) return null; const total = parseInt(m[1] || "0", 10) * 3600 + parseInt(m[2] || "0", 10) * 60 + parseInt(m[3] || "0", 10); return total || null; } // Extract a video id (+ optional start) from a YouTube URL, else null. function parseYouTube(url: string): { id: string; start: number | null } | null { let u: URL; try { u = new URL(url); } catch { return null; } const host = u.hostname.replace(/^www\./, ""); let id: string | null = null; if (host === "youtu.be") { id = u.pathname.slice(1) || null; } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { if (u.pathname === "/watch") id = u.searchParams.get("v"); else { const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/); if (m) id = m[1]; } } if (!id) return null; return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) }; } function renderDescription( text: string, opts: { currentId: string; onSeek: (seconds: number) => void; onLoadVideo: (id: string, start: number | null) => void; t: TFunction; } ): ReactNode[] { const { t } = opts; const out: ReactNode[] = []; let key = 0; const linkCls = "text-accent hover:underline break-all"; // Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely // (they're just noise in the popover), keeping single line breaks. const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim(); for (const chunk of clean.split(URL_RE)) { if (/^https?:\/\//.test(chunk)) { const href = chunk.replace(/[.,;:!?)\]]+$/, ""); const yt = parseYouTube(href); if (yt) { const sameVideo = yt.id === opts.currentId; out.push( ); } else { out.push( {href} ); } continue; } // Plain text: pull out emails, hashtags and bare timestamps. INLINE_RE.lastIndex = 0; let last = 0; let m: RegExpExecArray | null; while ((m = INLINE_RE.exec(chunk))) { if (m.index > last) out.push({chunk.slice(last, m.index)}); if (m[1]) { // Email — trim trailing punctuation the domain rule may have swallowed. const email = m[1].replace(/[.,;:]+$/, ""); out.push( {email} ); if (m[1].length > email.length) out.push({m[1].slice(email.length)}); } else if (m[2]) { // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). const tag = m[2].slice(1); out.push( {m[2]} ); } else { const ts = m[3]; out.push( ); } last = m.index + m[0].length; } if (last < chunk.length) out.push({chunk.slice(last)}); } return out; } // 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, }: { 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; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); // 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); // 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, }); // ESC + background scroll lock. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); 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); }; }, [onClose]); // 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: { 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. */}
{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 ?? ""} ) ) : ( {active.channel_title} )} {!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 && ( )}
); }