import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, CheckCheck, X } from "lucide-react"; import { api, type Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; // 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); // 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); }; // Lazy description (fetched only when the title is hovered). const [showDesc, setShowDesc] = useState(false); const detail = useQuery({ queryKey: ["video-detail", video.id], queryFn: () => api.videoDetail(video.id), enabled: showDesc, 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; }; }, [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; const maybeAutoWatch = (current: number, duration: number) => { if (autoMarkedRef.current || duration <= 0) 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(id, cur, dur); } 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) => { // 0 === ended → mark watched even if the timer hasn't fired yet. if (e?.data === 0 && !autoMarkedRef.current) { 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. */}
setShowDesc(true)} onMouseLeave={() => setShowDesc(false)} >

{video.title}

{showDesc && (
Description
{detail.isLoading ? (
Loading…
) : detail.data?.description ? (
{detail.data.description}
) : (
No description.
)}
)}
{/* Channel + meta on one line, with the watched toggle pushed to the right. */}
{video.channel_thumbnail && ( )} {video.channel_title}
{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 )}
); }