import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; 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; } ): ReactNode[] { 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; } // --- Per-video resume position, persisted in localStorage so it survives a refresh --- const posKey = (id: string) => `subfeed:player-pos:${id}`; function savePos(id: string, seconds: number, duration: number): void { // Don't store trivially-early or near-finished positions (start fresh next time). if (!Number.isFinite(seconds) || seconds < 5 || (duration > 0 && seconds > duration - FINISH_MARGIN)) { localStorage.removeItem(posKey(id)); return; } localStorage.setItem(posKey(id), String(Math.floor(seconds))); } function loadPos(id: string): number { const v = localStorage.getItem(posKey(id)); const n = v ? parseInt(v, 10) : 0; return Number.isFinite(n) && n > 0 ? n : 0; } export default function PlayerModal({ video, onClose, onState, }: { video: Video; onClose: () => void; onState: (id: string, status: string) => void; }) { 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 feed video we opened with. const [currentVideoId, setCurrentVideoId] = useState(video.id); const currentIdRef = useRef(video.id); const navigated = currentVideoId !== video.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); const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; const startSeconds = start != null ? start : loadPos(id); 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(video.status); const watched = status === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus(next); onState(video.id, next); }; 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 = video.id; // Auto-watch only applies to the feed video we opened with — not to other // videos the player navigated to via description links. const maybeAutoWatch = (current: number, duration: number) => { if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (current > duration - FINISH_MARGIN) { autoMarkedRef.current = true; setWatched(true); } }; const persist = () => { 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; maybeAutoWatch(cur, dur); savePos(currentIdRef.current, cur, dur); // key progress by what's actually playing } 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: loadPos(id) || 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 (guarded to the feed video inside maybeAutoWatch). if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { autoMarkedRef.current = true; setWatched(true); } }, }, }); }); // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. const timer = window.setInterval(persist, 5000); return () => { cancelled = true; window.clearInterval(timer); persist(); 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 }, [video.id]); return (
e.stopPropagation()} >
{/* 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 ?? "Loading…" : video.title}

{navigated && ( )} {showDesc && descRect && createPortal(
Description
{detail.isLoading ? (
Loading…
) : detail.data?.description ? (
{renderDescription(detail.data.description, { currentId: currentVideoId, onSeek: seekTo, onLoadVideo: loadVideo, })}
) : (
No description.
)}
, 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 && video.channel_thumbnail && ( )} {navigated ? ( detail.data?.channel_id ? ( {liveData?.author ?? detail.data.channel_title ?? "Channel"} ) : ( {liveData?.author ?? ""} ) ) : ( {video.channel_title} )} {!navigated ? (
{video.view_count != null && · {formatViews(video.view_count)} views} · {relativeTime(video.published_at)} {video.duration_seconds != null && ( · {formatDuration(video.duration_seconds)} )} {video.live_status === "was_live" && ( stream )}
) : ( // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( · {formatViews(detail.data.view_count)} views )} {detail.data?.published_at && · {relativeTime(detail.data.published_at)}} {detail.data?.duration_seconds != null && ( · {formatDuration(detail.data.duration_seconds)} )}
)} {!navigated && ( )}
); }