siftlode/frontend/src/components/PlayerModal.tsx

662 lines
29 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
// 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,
queue,
startIndex,
onClose,
onState,
onOpenChannel,
}: {
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;
// Optional playback queue (e.g. a playlist). When present, the player advances through
// it (auto-advance on end + prev/next), recreating per item to reuse the single-video
// resume / auto-watch / progress logic.
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: string) => void;
// Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour.
onOpenChannel?: (channelId: string, channelName?: string) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) =>
d
? new Date(d).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})
: "";
// Track the playing item by id (not a frozen index) so it survives the queue changing
// under us — e.g. an item removed in another tab. The index is derived from the *live*
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
// playback of the current video.
const [playingId, setPlayingId] = useState<string>(
queue?.[startIndex ?? 0]?.id ?? video.id
);
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
if (index < 0) index = 0;
const active = queue && queue[index] ? queue[index] : video;
const hasQueue = !!queue && queue.length > 1;
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
// click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
const cardRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const volTimerRef = useRef<number | undefined>(undefined);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
const focusModal = () => cardRef.current?.focus();
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
const toggleFullscreen = () => {
const el = stageRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.();
};
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
const nudgeVolume = (delta: number) => {
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
if (next > 0) p.unMute?.();
p.setVolume?.(next);
if (next === 0) p.mute?.();
flashVolume(next);
};
// The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.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);
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
const [playerError, setPlayerError] = useState<number | 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 active item, start others at 0.
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
setPlayerError(null);
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(active.status);
const watched = status === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus(next);
onState(active.id, next);
};
// On queue advance (active changes), sync local state to the new item.
useEffect(() => {
setStatus(active.status);
setCurrentVideoId(active.id);
currentIdRef.current = active.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
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,
});
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (document.fullscreenElement) return;
onClose();
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
}
};
window.addEventListener("keydown", onKey);
focusModal();
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);
window.clearTimeout(volTimerRef.current);
};
}, [onClose]);
// Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the
// interaction overlay above it (non-passive so preventDefault stops the modal scrolling).
useEffect(() => {
const el = overlayRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// Re-attach if the overlay remounts (it's hidden while the embed shows an error).
}, [playerError]);
// 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 = active.id;
autoMarkedRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player
// navigated to via description links.
const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
if (current > duration - FINISH_MARGIN) {
autoMarkedRef.current = true;
setWatched(true);
return true;
}
return false;
};
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;
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
// only clear the resume position, and firing both at once needlessly races the
// watched write against the progress row it deletes.
if (maybeAutoWatch(cur, dur)) return;
// 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: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
onReady: () => focusModal(),
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, then advance to the next queue item if any.
if (e?.data === 0 && currentIdRef.current === id) {
if (!autoMarkedRef.current) {
autoMarkedRef.current = true;
setWatched(true);
}
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
}
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
},
});
});
// 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 + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["playlist"] });
});
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
}, [active.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
ref={cardRef}
tabIndex={-1}
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
{/* Interaction layer over the video: catches the scroll wheel (volume) and click
(play/pause), and stops the iframe stealing keyboard focus. The bottom strip is
left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen)
stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
ref={overlayRef}
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer"
/>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</div>
{hasQueue && (
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
<button
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
disabled={index === 0}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
disabled={index === queue!.length - 1}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
</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 ?? t("player.loading") : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
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" />
{t("player.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">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.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,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body
)}
<button
onClick={onClose}
title={t("player.closeEsc")}
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"
>
{t("player.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={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
onClick={() => {
if (!onOpenChannel) return onClose();
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after App's popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
onOpenChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{active.view_count != null && (
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
)}
<span>
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.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">
{t("player.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>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"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 ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>
</div>
</div>
</div>
);
}