Video cards show a resume progress bar for started-but-unfinished videos and a hover overlay: Play on every card, Continue + Restart on in-progress ones. The in-app player now resumes from (and checkpoints to) the server position instead of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the feed on close so the card bar reflects the session. Sidebar gains an 'In progress' show filter.
533 lines
20 KiB
TypeScript
533 lines
20 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
|
import Avatar from "./Avatar";
|
|
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(
|
|
<button
|
|
key={key++}
|
|
onClick={() =>
|
|
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
|
}
|
|
className={linkCls}
|
|
title={sameVideo ? "Jump to this time" : "Play in the in-app player"}
|
|
>
|
|
{href}
|
|
</button>
|
|
);
|
|
} else {
|
|
out.push(
|
|
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
|
{href}
|
|
</a>
|
|
);
|
|
}
|
|
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(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
|
if (m[1]) {
|
|
// Email — trim trailing punctuation the domain rule may have swallowed.
|
|
const email = m[1].replace(/[.,;:]+$/, "");
|
|
out.push(
|
|
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
|
{email}
|
|
</a>
|
|
);
|
|
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
|
} else if (m[2]) {
|
|
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
|
const tag = m[2].slice(1);
|
|
out.push(
|
|
<a
|
|
key={key++}
|
|
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={linkCls}
|
|
>
|
|
{m[2]}
|
|
</a>
|
|
);
|
|
} else {
|
|
const ts = m[3];
|
|
out.push(
|
|
<button
|
|
key={key++}
|
|
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
|
className="text-accent hover:underline"
|
|
>
|
|
{ts}
|
|
</button>
|
|
);
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
|
}
|
|
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<any> | null = null;
|
|
function loadYouTubeApi(): Promise<any> {
|
|
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,
|
|
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;
|
|
onClose: () => void;
|
|
onState: (id: string, status: string) => void;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
// Resume point for the video we opened with.
|
|
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
|
const playerRef = useRef<any>(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;
|
|
// Explicit start wins; otherwise resume the opened video, start others at 0.
|
|
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0;
|
|
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 <body> 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<HTMLSpanElement | null>(null);
|
|
const closeTimer = useRef<number | undefined>(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 = (): Promise<unknown> | 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;
|
|
maybeAutoWatch(cur, dur);
|
|
// 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 (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);
|
|
// Final flush, then refresh the feed so the card's resume bar reflects this session.
|
|
Promise.resolve(persist()).finally(() =>
|
|
qc.invalidateQueries({ queryKey: ["feed"] })
|
|
);
|
|
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 (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="aspect-video w-full bg-black rounded-t-2xl overflow-hidden">
|
|
<div ref={mountRef} className="w-full h-full" />
|
|
</div>
|
|
|
|
<div className="p-4 sm:p-5">
|
|
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
|
<div className="flex items-start gap-3">
|
|
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
|
{/* Hover target is the text itself (inline), not the whole row. */}
|
|
<span
|
|
ref={titleRef}
|
|
className="cursor-default"
|
|
onMouseEnter={openDesc}
|
|
onMouseLeave={scheduleCloseDesc}
|
|
>
|
|
{navigated ? liveData?.title ?? "Loading…" : video.title}
|
|
</span>
|
|
</h2>
|
|
{navigated && (
|
|
<button
|
|
onClick={() => loadVideo(video.id, null)}
|
|
title="Back to the original video"
|
|
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back
|
|
</button>
|
|
)}
|
|
{showDesc &&
|
|
descRect &&
|
|
createPortal(
|
|
<div
|
|
className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4"
|
|
style={{
|
|
left: descRect.left,
|
|
bottom: descRect.bottom,
|
|
width: descRect.width,
|
|
}}
|
|
onMouseEnter={openDesc}
|
|
onMouseLeave={scheduleCloseDesc}
|
|
>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
|
Description
|
|
</div>
|
|
{detail.isLoading ? (
|
|
<div className="text-sm text-muted">Loading…</div>
|
|
) : detail.data?.description ? (
|
|
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
|
|
{renderDescription(detail.data.description, {
|
|
currentId: currentVideoId,
|
|
onSeek: seekTo,
|
|
onLoadVideo: loadVideo,
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted">No description.</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
title="Close (Esc)"
|
|
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
|
>
|
|
Close
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="flex items-center gap-3 mt-3">
|
|
{!navigated && (
|
|
<Avatar
|
|
src={video.channel_thumbnail}
|
|
fallback={video.channel_title ?? ""}
|
|
className="w-9 h-9 rounded-full shrink-0"
|
|
/>
|
|
)}
|
|
{navigated ? (
|
|
detail.data?.channel_id ? (
|
|
<a
|
|
href={`https://www.youtube.com/channel/${detail.data.channel_id}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="font-medium hover:text-accent shrink-0"
|
|
>
|
|
{liveData?.author ?? detail.data.channel_title ?? "Channel"}
|
|
</a>
|
|
) : (
|
|
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
|
)
|
|
) : (
|
|
<a
|
|
href={video.channel_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="font-medium hover:text-accent shrink-0"
|
|
>
|
|
{video.channel_title}
|
|
</a>
|
|
)}
|
|
{!navigated ? (
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
|
{video.view_count != null && <span>· {formatViews(video.view_count)} views</span>}
|
|
<span>· {relativeTime(video.published_at)}</span>
|
|
{video.duration_seconds != null && (
|
|
<span>· {formatDuration(video.duration_seconds)}</span>
|
|
)}
|
|
{video.live_status === "was_live" && (
|
|
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
|
stream
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// Linked video's stats come from the (already-fetched) video detail.
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
|
{detail.data?.view_count != null && (
|
|
<span>· {formatViews(detail.data.view_count)} views</span>
|
|
)}
|
|
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
|
|
{detail.data?.duration_seconds != null && (
|
|
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!navigated && (
|
|
<button
|
|
onClick={() => setWatched(!watched)}
|
|
title={watched ? "Watched — click to unmark" : "Mark watched"}
|
|
className={
|
|
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
|
(watched
|
|
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
|
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
|
}
|
|
>
|
|
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
|
{watched ? "Watched" : "Mark watched"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|