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 777d818c9a
commit 9332420ddf
3 changed files with 236 additions and 4 deletions

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 (
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={() => video.status === "new" && undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className
@ -116,11 +136,13 @@ export default function VideoCard({
view,
onState,
onChannelFilter,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video) => void;
}) {
const watched = video.status === "watched";
const meta = (
@ -134,6 +156,7 @@ export default function VideoCard({
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
>
{video.title}
@ -148,7 +171,7 @@ export default function VideoCard({
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">
{title}
<a
@ -174,7 +197,7 @@ export default function VideoCard({
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">
{video.channel_thumbnail && (
<img