feat(player): open YouTube's native settings menu fully + push higher quality

The transparent interaction overlay (click=play/pause, wheel=volume, keeps
keyboard focus off the cross-origin iframe) also blocked YouTube's own settings
menu, which expands down over the video past the overlay: items below the first
few were unclickable, and 'More options' made it worse.

Now the overlay yields to native controls. Clicking a native control (gear/seek/
CC) moves focus into the player iframe — the only cross-origin signal available —
which we detect (window blur + getIframe() focus check) to drop the overlay's
pointer-events, so the whole menu is navigable at any height. A discreet badge
signals native mode; moving the pointer off the video (or the window regaining
focus) re-arms the click/scroll/keyboard shortcuts.

Quality: the IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops now,
so the real lever is the rendered player size — bump the modal max-w-4xl -> 6xl so
YouTube's ABR targets a higher resolution — plus a best-effort 'vq=hd1080' URL hint.
i18n (en/hu/de) for the native-mode badge.
This commit is contained in:
npeter83 2026-07-10 15:52:34 +02:00
parent a2de72420b
commit 9af0b90816
4 changed files with 58 additions and 6 deletions

View file

@ -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, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, 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";
@ -124,6 +124,12 @@ export default function PlayerModal({
const volTimerRef = useRef<number | undefined>(undefined); const volTimerRef = useRef<number | undefined>(undefined);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null); const [volumeUi, setVolumeUi] = useState<number | null>(null);
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
const [nativeControls, setNativeControls] = useState(false);
const focusModal = () => cardRef.current?.focus(); const focusModal = () => cardRef.current?.focus();
const togglePlay = () => { const togglePlay = () => {
@ -396,6 +402,35 @@ export default function PlayerModal({
// Re-attach if the overlay remounts (it's hidden while the embed shows an error). // Re-attach if the overlay remounts (it's hidden while the embed shows an error).
}, [playerError]); }, [playerError]);
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
// card blurs the iframe → the parent window fires 'focus').
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
}, []);
// Create the player, resume from the saved position, persist progress, and // Create the player, resume from the saved position, persist progress, and
// auto-mark watched once playback reaches the end. // auto-mark watched once playback reaches the end.
useEffect(() => { useEffect(() => {
@ -448,6 +483,11 @@ export default function PlayerModal({
enablejsapi: 1, enablejsapi: 1,
origin: window.location.origin, origin: window.location.origin,
playsinline: 1, playsinline: 1,
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
// now (YouTube removed them), but the undocumented `vq` URL param is still honoured for many
// videos and is harmless otherwise. The real quality lever is the player's rendered size
// (see max-w-6xl below) — YouTube's ABR targets a resolution to match the pixel box.
vq: "hd1080",
}, },
events: { events: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away. // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
@ -510,7 +550,7 @@ export default function PlayerModal({
playlist). Each strip sits just outside the card, spans its full height, and fades out at playlist). Each strip sits just outside the card, spans its full height, and fades out at
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
where there's no room beside the card. */} where there's no room beside the card. */}
<div className="relative flex w-full max-w-4xl max-h-full"> <div className="relative flex w-full max-w-6xl max-h-full">
{hasQueue && ( {hasQueue && (
<button <button
onClick={(e) => { onClick={(e) => {
@ -552,9 +592,18 @@ 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-[12%] bottom-[22%] z-10 cursor-pointer" className={`absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/> />
)} )}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* 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. */}
{volumeUi != null && ( {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"> <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">

View file

@ -35,5 +35,6 @@
"unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.", "unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.",
"embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.", "embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.",
"openOnYoutube": "Auf YouTube öffnen", "openOnYoutube": "Auf YouTube öffnen",
"shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild" "shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild",
"nativeControls": "YouTube-Steuerung aktiv"
} }

View file

@ -35,5 +35,6 @@
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.", "unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.", "embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube", "openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll: volume · F: fullscreen" "shortcutsHint": "Click: play/pause · Scroll: volume · F: fullscreen",
"nativeControls": "YouTube controls active"
} }

View file

@ -35,5 +35,6 @@
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.", "unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
"openOnYoutube": "Megnyitás YouTube-on", "openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő: hangerő · F: teljes képernyő" "shortcutsHint": "Kattintás: lejátszás/szünet · Görgő: hangerő · F: teljes képernyő",
"nativeControls": "YouTube vezérlők aktívak"
} }