- Watched: an explicit toggle in the modal (Mark watched / Watched→unmark) plus
auto-mark when playback reaches the end (within 10s, or on the ended event).
- Compact layout: drop the header bar and the redundant 'Open on YouTube' button
(the embed's own YouTube logo already jumps out); Close moves to the title row,
channel + meta share one line — fits without a scrollbar at higher zoom.
- Card actions reflect status: watched shows a double-check, saved a filled
bookmark, with matching tooltips.
- Description: new GET /api/videos/{id} exposes the already-stored description,
shown in a popover when hovering the modal title (fetched lazily).
270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
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<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;
|
|
}
|
|
|
|
// --- 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<HTMLDivElement | null>(null);
|
|
const playerRef = useRef<any>(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 (
|
|
<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">
|
|
<div
|
|
className="relative min-w-0 flex-1"
|
|
onMouseEnter={() => setShowDesc(true)}
|
|
onMouseLeave={() => setShowDesc(false)}
|
|
>
|
|
<h2 className="text-lg font-semibold leading-snug cursor-default">
|
|
{video.title}
|
|
</h2>
|
|
{showDesc && (
|
|
<div className="absolute left-0 top-full mt-2 z-10 w-full max-w-2xl glass-card rounded-xl shadow-2xl p-4">
|
|
<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 max-h-64 overflow-y-auto leading-relaxed">
|
|
{detail.data.description}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted">No description.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<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. */}
|
|
<div className="flex items-center gap-3 mt-3">
|
|
{video.channel_thumbnail && (
|
|
<img
|
|
src={video.channel_thumbnail}
|
|
alt=""
|
|
className="w-9 h-9 rounded-full shrink-0"
|
|
/>
|
|
)}
|
|
<a
|
|
href={video.channel_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="font-medium hover:text-accent shrink-0"
|
|
>
|
|
{video.channel_title}
|
|
</a>
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
}
|