@@ -35,6 +35,7 @@ import {
} from "../lib/format" ;
import { renderDescription } from "../lib/descriptionLinks" ;
import { useBackToClose } from "../lib/history" ;
import { LS , useAccountPersistedObject } from "../lib/storage" ;
import { useScrollFade } from "../lib/useScrollFade" ;
// Experiment (branch experiment/inline-player): play the video in-app via the
@@ -155,6 +156,21 @@ export default function PlayerModal({
// 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 ) ;
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
// the native fullscreen button — permanently hidden after pressing F.
const [ isFullscreen , setIsFullscreen ] = useState ( false ) ;
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
// contradict it.
const [ playerPrefs , patchPlayerPrefs ] = useAccountPersistedObject ( LS . ytPlayerPrefs , {
volume : 100 ,
} ) ;
// The window/wheel listeners below are bound once, so they must read the LIVE prefs.
const prefsRef = useRef ( playerPrefs ) ;
prefsRef . current = playerPrefs ;
const focusModal = ( ) = > cardRef . current ? . focus ( ) ;
const togglePlay = ( ) = > {
@@ -175,15 +191,26 @@ export default function PlayerModal({
window . clearTimeout ( volTimerRef . current ) ;
volTimerRef . current = window . setTimeout ( ( ) = > setVolumeUi ( null ) , 1200 ) ;
} ;
/** Push a level (0– 100) to the player and remember it; 0 also mutes (a player left at zero
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
const applyVolume = ( level : number , flash = true ) = > {
const next = Math . max ( 0 , Math . min ( 100 , Math . round ( level ) ) ) ;
const p = playerRef . current ;
if ( p && typeof p . setVolume === "function" ) {
if ( next > 0 ) p . unMute ? . ( ) ;
p . setVolume ( next ) ;
if ( next === 0 ) p . mute ? . ( ) ;
}
patchPlayerPrefs ( { volume : next } ) ;
if ( flash ) flashVolume ( next ) ;
} ;
const nudgeVolume = ( delta : number ) = > {
const p = playerRef . current ;
if ( ! p || typeof p . getVolume !== "function" ) return ;
const cur = p . isMuted ? . ( ) ? 0 : ( p . getVolume ? . ( ) ? ? 50 ) ;
const next = Math . max ( 0 , Math . min ( 100 , Math . round ( cur + delta ) ) ) ;
if ( next > 0 ) p . unMute ? . ( ) ;
p . setVolume ? . ( next ) ;
if ( next === 0 ) p . mute ? . ( ) ;
flashVolume ( next ) ;
// Read the live player (the user may have used YouTube's own slider), falling back to the
// stored level when the player can't answer yet.
const cur = p . isMuted ? . ( ) ? 0 : ( p . getVolume ? . ( ) ? ? prefsRef . current . volume ) ;
applyVolume ( cur + delta ) ;
} ;
// The player can navigate to other videos (YouTube links in a description). The
@@ -396,6 +423,12 @@ export default function PlayerModal({
if ( typing || tag === "button" || tag === "a" ) return ;
e . preventDefault ( ) ;
togglePlay ( ) ;
} else if ( e . key === "ArrowUp" || e . key === "ArrowDown" ) {
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
// fullscreen, where the wheel goes to YouTube's own iframe.
if ( typing ) return ;
e . preventDefault ( ) ;
nudgeVolume ( e . key === "ArrowUp" ? 5 : - 5 ) ;
} 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).
@@ -431,6 +464,9 @@ export default function PlayerModal({
const el = dialogRef . current ;
if ( ! el ) return ;
const onWheel = ( e : WheelEvent ) = > {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
if ( e . ctrlKey || e . metaKey ) return ;
e . preventDefault ( ) ;
focusModal ( ) ; // re-grab focus if it had slipped into the native controls
nudgeVolume ( e . deltaY < 0 ? 5 : - 5 ) ;
@@ -439,6 +475,16 @@ export default function PlayerModal({
return ( ) = > el . removeEventListener ( "wheel" , onWheel ) ;
} , [ ] ) ;
// Track whether we're the fullscreen element (F, our button, or the browser's own Esc exit).
useEffect ( ( ) = > {
const onChange = ( ) = >
setIsFullscreen (
! ! document . fullscreenElement && document . fullscreenElement === stageRef . current ,
) ;
document . addEventListener ( "fullscreenchange" , onChange ) ;
return ( ) = > document . removeEventListener ( "fullscreenchange" , onChange ) ;
} , [ ] ) ;
// 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
@@ -532,8 +578,12 @@ export default function PlayerModal({
vq : "hd1080" ,
} ,
events : {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
onReady : ( ) = > focusModal ( ) ,
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
// restore the remembered volume — a fresh player always starts at 100.
onReady : ( ) = > {
focusModal ( ) ;
applyVolume ( prefsRef . current . volume , false ) ;
} ,
onStateChange : ( e : any ) = > {
// 1 === playing → sync the navigated-to video's title/author for display.
if ( e ? . data === 1 ) {
@@ -665,7 +715,8 @@ export default function PlayerModal({
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. */ }
so the "Open on YouTube" CTA is clickable, and fully yielded in fullscreen (YouTube
reveals its control bar only while the iframe itself sees the mouse). */ }
{ playerError == null && (
< div
onClick = { ( ) = > {
@@ -675,7 +726,7 @@ export default function PlayerModal({
title = { t ( "player.shortcutsHint" ) }
aria-label = { t ( "player.shortcutsHint" ) }
className = { ` absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
nativeControls || isFullscreen ? "pointer-events-none" : ""
} ` }
/ >
) }