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:
parent
5b4c134c05
commit
75b73b5933
3 changed files with 236 additions and 4 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
202
frontend/src/components/PlayerModal.tsx
Normal file
202
frontend/src/components/PlayerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue