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
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
|
queue={ytItems}
|
||||||
|
autoAdvance={false}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
@ -654,6 +656,8 @@ export default function Feed({
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
|
queue={items}
|
||||||
|
autoAdvance={false}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
|
|
@ -42,8 +42,9 @@ function loadYouTubeApi(): Promise<any> {
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
startAt,
|
||||||
queue,
|
queue: queueProp,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
autoAdvance = true,
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
|
|
@ -57,6 +58,9 @@ export default function PlayerModal({
|
||||||
// resume / auto-watch / progress logic.
|
// resume / auto-watch / progress logic.
|
||||||
queue?: Video[];
|
queue?: Video[];
|
||||||
startIndex?: number;
|
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;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
// Open the active video's channel page in-app (closes the player first). The small external
|
// 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();
|
const qc = useQueryClient();
|
||||||
// Browser/mouse Back closes the player instead of leaving the page.
|
// Browser/mouse Back closes the player instead of leaving the page.
|
||||||
useBackToClose(onClose);
|
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").
|
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||||
const fullDate = (d: string | null | undefined) =>
|
const fullDate = (d: string | null | undefined) =>
|
||||||
d
|
d
|
||||||
|
|
@ -81,7 +90,9 @@ export default function PlayerModal({
|
||||||
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
||||||
// playback of the current video.
|
// playback of the current video.
|
||||||
const [playingId, setPlayingId] = useState<string>(
|
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;
|
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
||||||
if (index < 0) index = 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
|
// 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
|
// 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.
|
// 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 titleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const closeTimer = useRef<number | undefined>(undefined);
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const openDesc = () => {
|
const openTimer = useRef<number | undefined>(undefined);
|
||||||
|
const openDescNow = () => {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
const el = titleRef.current;
|
const el = titleRef.current;
|
||||||
if (el) {
|
if (el) {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
|
@ -205,7 +238,15 @@ export default function PlayerModal({
|
||||||
}
|
}
|
||||||
setShowDesc(true);
|
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 = () => {
|
const scheduleCloseDesc = () => {
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||||||
};
|
};
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
|
|
@ -241,6 +282,18 @@ export default function PlayerModal({
|
||||||
if (typing || tag === "button" || tag === "a") return;
|
if (typing || tag === "button" || tag === "a") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlay();
|
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);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
@ -251,6 +304,7 @@ export default function PlayerModal({
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
document.body.style.overflow = prevOverflow;
|
document.body.style.overflow = prevOverflow;
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
window.clearTimeout(volTimerRef.current);
|
window.clearTimeout(volTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
@ -339,7 +393,10 @@ export default function PlayerModal({
|
||||||
autoMarkedRef.current = true;
|
autoMarkedRef.current = true;
|
||||||
setWatched(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…).
|
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||||
|
|
@ -380,6 +437,36 @@ export default function PlayerModal({
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
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
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
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
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||||
through our overlay. */}
|
through our overlay. */}
|
||||||
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
|
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
|
||||||
{/* Interaction layer over the video: catches the scroll wheel (volume) and click
|
{/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume)
|
||||||
(play/pause), and stops the iframe stealing keyboard focus. The bottom strip is
|
and click (play/pause), and stops the iframe stealing keyboard focus. It deliberately
|
||||||
left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen)
|
leaves the top AND bottom edges uncovered so YouTube's native controls — the top-right
|
||||||
stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
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 && (
|
{playerError == null && (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
|
|
@ -406,7 +494,7 @@ export default function PlayerModal({
|
||||||
}}
|
}}
|
||||||
title={t("player.shortcutsHint")}
|
title={t("player.shortcutsHint")}
|
||||||
aria-label={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. */}
|
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||||
|
|
@ -473,7 +561,7 @@ export default function PlayerModal({
|
||||||
<span
|
<span
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={scheduleOpenDesc}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
||||||
|
|
@ -499,7 +587,7 @@ export default function PlayerModal({
|
||||||
bottom: descRect.bottom,
|
bottom: descRect.bottom,
|
||||||
width: descRect.width,
|
width: descRect.width,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={openDescNow}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue