@@ -35,6 +35,7 @@ import {
} from "../lib/format" ;
} from "../lib/format" ;
import { renderDescription } from "../lib/descriptionLinks" ;
import { renderDescription } from "../lib/descriptionLinks" ;
import { useBackToClose } from "../lib/history" ;
import { useBackToClose } from "../lib/history" ;
import { LS , useAccountPersistedObject } from "../lib/storage" ;
import { useScrollFade } from "../lib/useScrollFade" ;
import { useScrollFade } from "../lib/useScrollFade" ;
// Experiment (branch experiment/inline-player): play the video in-app via the
// 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
// 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.
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
const [ nativeControls , setNativeControls ] = useState ( false ) ;
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 focusModal = ( ) = > cardRef . current ? . focus ( ) ;
const togglePlay = ( ) = > {
const togglePlay = ( ) = > {
@@ -175,15 +191,26 @@ export default function PlayerModal({
window . clearTimeout ( volTimerRef . current ) ;
window . clearTimeout ( volTimerRef . current ) ;
volTimerRef . current = window . setTimeout ( ( ) = > setVolumeUi ( null ) , 1200 ) ;
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 nudgeVolume = ( delta : number ) = > {
const p = playerRef . current ;
const p = playerRef . current ;
if ( ! p || typeof p . getVolume !== "function" ) return ;
if ( ! p || typeof p . getVolume !== "function" ) return ;
const cur = p . isMuted ? . ( ) ? 0 : ( p . getVolume ? . ( ) ? ? 50 ) ;
// Read the live player (the user may have used YouTube's own slider), falling back to the
const next = Math . max ( 0 , Math . min ( 100 , Math . round ( cur + delta ) ) ) ;
// stored level when the player can't answer yet.
if ( next > 0 ) p . unMute ? . ( ) ;
const cur = p . isMuted ? . ( ) ? 0 : ( p . getVolume ? . ( ) ? ? prefsRef . current . volume ) ;
p . setVolume ? . ( next ) ;
applyVolume ( cur + delta ) ;
if ( next === 0 ) p . mute ? . ( ) ;
flashVolume ( next ) ;
} ;
} ;
// The player can navigate to other videos (YouTube links in a description). The
// 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 ;
if ( typing || tag === "button" || tag === "a" ) return ;
e . preventDefault ( ) ;
e . preventDefault ( ) ;
togglePlay ( ) ;
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" ) {
} else if ( e . key === "ArrowLeft" || e . key === "ArrowRight" ) {
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
// 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).
// 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 ;
const el = dialogRef . current ;
if ( ! el ) return ;
if ( ! el ) return ;
const onWheel = ( e : WheelEvent ) = > {
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 ( ) ;
e . preventDefault ( ) ;
focusModal ( ) ; // re-grab focus if it had slipped into the native controls
focusModal ( ) ; // re-grab focus if it had slipped into the native controls
nudgeVolume ( e . deltaY < 0 ? 5 : - 5 ) ;
nudgeVolume ( e . deltaY < 0 ? 5 : - 5 ) ;
@@ -439,6 +475,16 @@ export default function PlayerModal({
return ( ) = > el . removeEventListener ( "wheel" , onWheel ) ;
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
// 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
// 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
// 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" ,
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, and
onReady : ( ) = > focusModal ( ) ,
// restore the remembered volume — a fresh player always starts at 100.
onReady : ( ) = > {
focusModal ( ) ;
applyVolume ( prefsRef . current . volume , false ) ;
} ,
onStateChange : ( e : any ) = > {
onStateChange : ( e : any ) = > {
// 1 === playing → sync the navigated-to video's title/author for display.
// 1 === playing → sync the navigated-to video's title/author for display.
if ( e ? . data === 1 ) {
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
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)
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
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 && (
{ playerError == null && (
< div
< div
onClick = { ( ) = > {
onClick = { ( ) = > {
@@ -675,7 +726,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-[12%] bottom-[22%] z-base cursor-pointer ${
className = { ` absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
nativeControls || isFullscreen ? "pointer-events-none" : ""
} ` }
} ` }
/ >
/ >
) }
) }