feat(player): in-app modal YouTube player with resume

Left-clicking a feed card (Ctrl/Cmd/middle still open youtube.com in a new tab)
opens a modal that plays the video in-app via the YouTube IFrame Player API
instead of leaving the app. Using the JS API (not a bare embed) lets us read the
playback position: it's checkpointed per-video in localStorage and on close, and
restored via the 'start' param when the video is reopened. The modal closes via
a header button, the backdrop, or ESC (ESC only while focus is on our page — a
cross-origin iframe owns its own key events).
This commit is contained in:
npeter83 2026-06-12 17:38:45 +02:00
parent d0b8ef796a
commit 99dfa7691c
3 changed files with 236 additions and 4 deletions

View file

@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-quer
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, type FeedFilters, type Video } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
import PlayerModal from "./PlayerModal";
const PAGE = 60; const PAGE = 60;
@ -31,6 +32,7 @@ export default function Feed({
view: "grid" | "list"; view: "grid" | "list";
}) { }) {
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
const qc = useQueryClient(); const qc = useQueryClient();
const query = useInfiniteQuery({ const query = useInfiniteQuery({
@ -118,6 +120,7 @@ export default function Feed({
view="grid" view="grid"
onState={onState} onState={onState}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={setActiveVideo}
/> />
))} ))}
</div> </div>
@ -130,10 +133,14 @@ export default function Feed({
view="list" view="list"
onState={onState} onState={onState}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={setActiveVideo}
/> />
))} ))}
</div> </div>
)} )}
{activeVideo && (
<PlayerModal video={activeVideo} onClose={() => setActiveVideo(null)} />
)}
<div ref={sentinel} className="h-10" /> <div ref={sentinel} className="h-10" />
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="text-center text-muted py-4">Loading more</div> <div className="text-center text-muted py-4">Loading more</div>

View file

@ -0,0 +1,202 @@
import { useEffect, useRef } from "react";
import { ExternalLink, X } from "lucide-react";
import 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 header X 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).
// --- 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 - 10)) {
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,
}: {
video: Video;
onClose: () => void;
}) {
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
// 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, and persist progress.
useEffect(() => {
let cancelled = false;
const id = video.id;
const persist = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
try {
savePos(id, p.getCurrentTime(), typeof p.getDuration === "function" ? p.getDuration() : 0);
} 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,
},
});
});
// Periodic checkpoint so progress survives a crash/refresh, not just a clean close.
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;
};
}, [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()}
>
{/* Header bar — keeps our close button off the player's own top-right controls. */}
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border">
<span className="truncate text-sm font-medium text-muted">{video.title}</span>
<button
onClick={onClose}
title="Close (Esc)"
aria-label="Close"
className="shrink-0 p-2 -mr-2 rounded-full text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="aspect-video w-full bg-black">
<div ref={mountRef} className="w-full h-full" />
</div>
<div className="p-4 sm:p-5">
<h2 className="text-lg font-semibold leading-snug">{video.title}</h2>
<div className="flex items-center gap-3 mt-3">
{video.channel_thumbnail && (
<img
src={video.channel_thumbnail}
alt=""
className="w-10 h-10 rounded-full shrink-0"
/>
)}
<a
href={video.channel_url}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent truncate"
>
{video.channel_title}
</a>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted mt-3">
{video.view_count != null && <span>{formatViews(video.view_count)} views</span>}
{video.view_count != null && <span>·</span>}
<span>{relativeTime(video.published_at)}</span>
{video.duration_seconds != null && (
<>
<span>·</span>
<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>
<div className="mt-4">
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
Open on YouTube
</a>
</div>
</div>
</div>
</div>
);
}

View file

@ -70,13 +70,33 @@ function Actions({
); );
} }
function Thumb({ video, className }: { video: Video; className?: string }) { // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video) => void
): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
onOpen(video);
}
function Thumb({
video,
className,
onOpen,
}: {
video: Video;
className?: string;
onOpen?: (v: Video) => void;
}) {
return ( return (
<a <a
href={video.watch_url} href={video.watch_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={() => video.status === "new" && undefined} onClick={(e) => openInApp(e, video, onOpen)}
className={clsx( className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow", "block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className className
@ -116,11 +136,13 @@ export default function VideoCard({
view, view,
onState, onState,
onChannelFilter, onChannelFilter,
onOpen,
}: { }: {
video: Video; video: Video;
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video) => void;
}) { }) {
const watched = video.status === "watched"; const watched = video.status === "watched";
const meta = ( const meta = (
@ -134,6 +156,7 @@ export default function VideoCard({
href={video.watch_url} href={video.watch_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
className="font-medium leading-snug line-clamp-2 hover:text-accent" className="font-medium leading-snug line-clamp-2 hover:text-accent"
> >
{video.title} {video.title}
@ -148,7 +171,7 @@ export default function VideoCard({
watched && "opacity-55" watched && "opacity-55"
)} )}
> >
<Thumb video={video} className="w-44 aspect-video shrink-0" /> <Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <a
@ -174,7 +197,7 @@ export default function VideoCard({
watched && "opacity-55" watched && "opacity-55"
)} )}
> >
<Thumb video={video} className="aspect-video" /> <Thumb video={video} className="aspect-video" onOpen={onOpen} />
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5"> <div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
{video.channel_thumbnail && ( {video.channel_thumbnail && (
<img <img