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:
npeter83 2026-07-04 22:25:37 +02:00
parent 249946b5f5
commit 53eba7a57d
2 changed files with 104 additions and 12 deletions

View file

@ -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}

View file

@ -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">