feat(player): step through the feed, reachable native controls, hover-intent
Three in-app player refinements: - Prev/next stepping: the modal now takes the feed's loaded order as a queue, with faint arrow zones flanking the card and Shift+Left/Right shortcuts (plain arrows seek ±5s). The queue is frozen at open so marking the current video watched can't drop it and reload a different one; auto-advance-on-end stays off for the feed (it isn't a playlist) — playlists keep theirs. - Reachable native YouTube controls: the interaction overlay (wheel volume / click pause / keyboard focus) now covers only the centre band (top-[12%] bottom-[22%]), leaving the top-right cluster (volume/CC/settings) and the bottom bar (seek / More videos / fullscreen) clickable. Verified live: settings menu + fullscreen work. - Hover-intent description: the title popover now waits ~400ms so a quick pass no longer flashes it.
This commit is contained in:
parent
249946b5f5
commit
53eba7a57d
2 changed files with 104 additions and 12 deletions
|
|
@ -446,6 +446,8 @@ export default function Feed({
|
|||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={ytItems}
|
||||
autoAdvance={false}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
onState={onState}
|
||||
onOpenChannel={onOpenChannel}
|
||||
|
|
@ -654,6 +656,8 @@ export default function Feed({
|
|||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={items}
|
||||
autoAdvance={false}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
onState={onState}
|
||||
onOpenChannel={onOpenChannel}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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 { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
|
|
@ -42,8 +42,9 @@ function loadYouTubeApi(): Promise<any> {
|
|||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
queue,
|
||||
queue: queueProp,
|
||||
startIndex,
|
||||
autoAdvance = true,
|
||||
onClose,
|
||||
onState,
|
||||
onOpenChannel,
|
||||
|
|
@ -57,6 +58,9 @@ export default function PlayerModal({
|
|||
// resume / auto-watch / progress logic.
|
||||
queue?: Video[];
|
||||
startIndex?: number;
|
||||
// Auto-play the next queue item when one ends. On for playlists; off for the feed queue, where
|
||||
// stepping is explicit (the feed isn't a playlist — auto-advancing it would surprise).
|
||||
autoAdvance?: boolean;
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
// Open the active video's channel page in-app (closes the player first). The small external
|
||||
|
|
@ -67,6 +71,11 @@ export default function PlayerModal({
|
|||
const qc = useQueryClient();
|
||||
// Browser/mouse Back closes the player instead of leaving the page.
|
||||
useBackToClose(onClose);
|
||||
// Freeze the queue at mount. The feed's list is view-filtered + live, so marking the current
|
||||
// video watched would drop it (and reindex → reload a different video mid-play). A frozen
|
||||
// snapshot keeps the current item put and lets you step back to ones you've watched. (New feed
|
||||
// pages loaded while the player is open won't appear in the sequence — reopen to include them.)
|
||||
const queue = useRef(queueProp).current;
|
||||
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||
const fullDate = (d: string | null | undefined) =>
|
||||
d
|
||||
|
|
@ -81,7 +90,9 @@ export default function PlayerModal({
|
|||
// 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
|
||||
// A caller with an explicit index (playlist) starts there; otherwise locate the opened
|
||||
// `video` in the queue (the feed passes its whole list + the clicked video, no index).
|
||||
startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id
|
||||
);
|
||||
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
||||
if (index < 0) index = 0;
|
||||
|
|
@ -183,6 +194,26 @@ export default function PlayerModal({
|
|||
}
|
||||
};
|
||||
|
||||
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
||||
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
||||
// current position, not a stale closure.
|
||||
const navRef = useRef({ queue, index });
|
||||
navRef.current = { queue, index };
|
||||
const goPrev = () => {
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if (q && i > 0) setPlayingId(q[i - 1].id);
|
||||
};
|
||||
const goNext = () => {
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
|
||||
};
|
||||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||
const nudgeSeek = (delta: number) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -194,8 +225,10 @@ export default function PlayerModal({
|
|||
);
|
||||
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const openDesc = () => {
|
||||
const openTimer = useRef<number | undefined>(undefined);
|
||||
const openDescNow = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
const el = titleRef.current;
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
|
|
@ -205,7 +238,15 @@ export default function PlayerModal({
|
|||
}
|
||||
setShowDesc(true);
|
||||
};
|
||||
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
|
||||
// quick pass over it doesn't flash the popover.
|
||||
const scheduleOpenDesc = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
openTimer.current = window.setTimeout(openDescNow, 400);
|
||||
};
|
||||
const scheduleCloseDesc = () => {
|
||||
window.clearTimeout(openTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||||
};
|
||||
const detail = useQuery({
|
||||
|
|
@ -241,6 +282,18 @@ export default function PlayerModal({
|
|||
if (typing || tag === "button" || tag === "a") return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
const forward = e.key === "ArrowRight";
|
||||
if (e.shiftKey) {
|
||||
if (forward) goNext();
|
||||
else goPrev();
|
||||
} else {
|
||||
nudgeSeek(forward ? 5 : -5);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
|
@ -251,6 +304,7 @@ export default function PlayerModal({
|
|||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
|
@ -339,7 +393,10 @@ export default function PlayerModal({
|
|||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
}
|
||||
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
|
||||
if (autoAdvance) {
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
|
||||
}
|
||||
}
|
||||
},
|
||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||
|
|
@ -380,6 +437,36 @@ export default function PlayerModal({
|
|||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Faint prev/next zones flanking the card — step through the feed's order (or a playlist).
|
||||
They fade out at the ends. stopPropagation so a click steps instead of closing. */}
|
||||
{hasQueue && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
disabled={index === 0}
|
||||
aria-label={t("player.previous")}
|
||||
title={t("player.previous")}
|
||||
className="absolute left-1 sm:left-3 top-1/2 -translate-y-1/2 z-20 grid h-16 w-10 place-items-center rounded-xl text-white/40 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-0"
|
||||
>
|
||||
<ChevronLeft className="h-7 w-7" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
disabled={index >= queue!.length - 1}
|
||||
aria-label={t("player.next")}
|
||||
title={t("player.next")}
|
||||
className="absolute right-1 sm:right-3 top-1/2 -translate-y-1/2 z-20 grid h-16 w-10 place-items-center rounded-xl text-white/40 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-0"
|
||||
>
|
||||
<ChevronRight className="h-7 w-7" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
|
|
@ -393,10 +480,11 @@ export default function PlayerModal({
|
|||
{/* 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. */}
|
||||
{/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume)
|
||||
and click (play/pause), and stops the iframe stealing keyboard focus. It deliberately
|
||||
leaves the top AND bottom edges uncovered so YouTube's native controls — the top-right
|
||||
cluster (volume / CC / settings) and the bottom bar (seek / More videos / fullscreen)
|
||||
— stay clickable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||
{playerError == null && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
|
|
@ -406,7 +494,7 @@ export default function PlayerModal({
|
|||
}}
|
||||
title={t("player.shortcutsHint")}
|
||||
aria-label={t("player.shortcutsHint")}
|
||||
className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer"
|
||||
className="absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||
|
|
@ -473,7 +561,7 @@ export default function PlayerModal({
|
|||
<span
|
||||
ref={titleRef}
|
||||
className="cursor-default"
|
||||
onMouseEnter={openDesc}
|
||||
onMouseEnter={scheduleOpenDesc}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
||||
|
|
@ -499,7 +587,7 @@ export default function PlayerModal({
|
|||
bottom: descRect.bottom,
|
||||
width: descRect.width,
|
||||
}}
|
||||
onMouseEnter={openDesc}
|
||||
onMouseEnter={openDescNow}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue