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 { channelYouTubeUrl, 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 && (
{/* 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. */}