From 53eba7a57df08b0b9d4f8f0105044703fda71f3a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 22:25:37 +0200 Subject: [PATCH] feat(player): step through the feed, reachable native controls, hover-intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/Feed.tsx | 4 + frontend/src/components/PlayerModal.tsx | 112 +++++++++++++++++++++--- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 8152d59..950ef8e 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -446,6 +446,8 @@ export default function Feed({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} @@ -654,6 +656,8 @@ export default function Feed({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index f8456d9..1fca2c3 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 { 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( - 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 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(null); const closeTimer = useRef(undefined); - const openDesc = () => { + const openTimer = useRef(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 && ( + <> + + + + )}
- {/* 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 && (
)} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} @@ -473,7 +561,7 @@ export default function PlayerModal({ {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} >