@@ -1,4 +1,14 @@
import { Fragment , lazy , Suspense , useCallback , useEffect , useRef , useState , type ReactNode } from "react" ;
import {
Fragment ,
lazy ,
Suspense ,
useCallback ,
useEffect ,
useRef ,
useState ,
type ReactNode ,
type WheelEvent as ReactWheelEvent ,
} from "react" ;
import { useTranslation } from "react-i18next" ;
import { useQuery , useQueryClient } from "@tanstack/react-query" ;
import Hls from "hls.js" ;
@@ -8,6 +18,7 @@ import {
Download ,
Info ,
Keyboard ,
Languages ,
Maximize ,
Minimize ,
Pause ,
@@ -22,10 +33,71 @@ import {
X ,
} from "lucide-react" ;
import { api , type PlexMarker } from "../lib/api" ;
import { useAccountPersistedObject } from "../lib/storage" ;
import { useDismiss } from "../lib/useDismiss" ;
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy ( ( ) = > import ( "./PlexInfo" ) ) ;
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
// ordinal means different languages in different files, so we re-resolve the saved language to this
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
type AudioStream = { ord : number ; language? : string | null ; default : boolean } ;
type SubStream = { ord : number ; language? : string | null ; text : boolean } ;
function audioOrdForLang ( streams : AudioStream [ ] , lang : string ) : number | null {
if ( ! lang ) return null ; // "" = use the file's default first track (keeps direct-play, no forced HLS)
const i = streams . findIndex ( ( s ) = > ( s . language ? ? "" ) === lang ) ;
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
// the restored language silently fall back to track 0.)
return i <= 0 ? null : streams [ i ] . ord ;
}
function subOrdForLang ( subs : SubStream [ ] , lang : string ) : number | null {
if ( ! lang ) return null ; // "" = subtitles off
const m = subs . find ( ( s ) = > s . text && ( s . language ? ? "" ) === lang ) ;
return m ? m.ord : null ; // preferred language not available here → off
}
// Per-account player preferences, persisted so they survive F5 and carry across items.
type PlexPlayerPrefs = {
volume : number ; // 0..1
muted : boolean ;
wasPlaying : boolean ; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
audioLang : string ; // "" = default first audio
subLang : string ; // "" = subtitles off
subOffset : number ; // seconds, +later / -earlier (client-side cue shift)
audioOffset : number ; // seconds, +later / -earlier (server-side -itsoffset)
barAutoHide : boolean ; // auto-hide the control bar during playback
seekStep : number ; // plain ←/→ jump (s)
fineSeekStep : number ; // Ctrl+←/→ jump (s)
subSize : number ; // subtitle font size (%)
subColor : string ; // subtitle text color (hex)
subBg : string ; // subtitle background ("transparent" or hex)
subPos : number ; // subtitle vertical position (cue line %, higher = lower on screen)
} ;
const DEFAULT_PREFS : PlexPlayerPrefs = {
volume : 1 ,
muted : false ,
wasPlaying : true ,
audioLang : "" ,
subLang : "" ,
subOffset : 0 ,
audioOffset : 0 ,
barAutoHide : true ,
seekStep : 10 ,
fineSeekStep : 1 ,
subSize : 100 ,
subColor : "#ffffff" ,
subBg : "transparent" ,
subPos : 88 ,
} ;
const clamp01 = ( n : number ) = > Math . max ( 0 , Math . min ( 1 , n ) ) ;
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
const HLS_SUB_LAG = 1.0 ;
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
@@ -65,8 +137,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [ playing , setPlaying ] = useState ( false ) ;
const [ abs , setAbs ] = useState ( 0 ) ; // absolute current time
const [ seekableEnd , setSeekableEnd ] = useState ( 0 ) ; // absolute end of the loaded region
const [ volume , setVolume ] = useState ( 1 ) ;
const [ muted , setMuted ] = useState ( false ) ;
const [ fs , setFs ] = useState ( false ) ;
const [ ready , setReady ] = useState ( false ) ;
const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
@@ -75,11 +145,46 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [ audioOrd , setAudioOrd ] = useState < number | null > ( null ) ;
const [ subOrd , setSubOrd ] = useState < number | null > ( null ) ;
const [ menuOpen , setMenuOpen ] = useState ( false ) ;
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
// this and re-fetches when a seek-restart changes it.
const [ sessionK , setSessionK ] = useState ( 0 ) ;
// Per-account persisted player prefs — survive F5, carry across items.
const [ prefs , patchPrefs ] = useAccountPersistedObject < PlexPlayerPrefs > (
"siftlode.plexPlayerPrefs" ,
DEFAULT_PREFS ,
) ;
const { volume , muted } = prefs ;
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
const barAutoHideRef = useRef ( prefs . barAutoHide ) ;
barAutoHideRef . current = prefs . barAutoHide ;
const seekStepRef = useRef ( prefs . seekStep ) ;
seekStepRef . current = prefs . seekStep ;
const fineSeekStepRef = useRef ( prefs . fineSeekStep ) ;
fineSeekStepRef . current = prefs . fineSeekStep ;
const aoffRef = useRef ( prefs . audioOffset ) ;
aoffRef . current = prefs . audioOffset ;
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [ subOffApplied , setSubOffApplied ] = useState ( prefs . subOffset ) ;
useEffect ( ( ) = > {
const tmr = window . setTimeout ( ( ) = > setSubOffApplied ( prefs . subOffset ) , 250 ) ;
return ( ) = > window . clearTimeout ( tmr ) ;
} , [ prefs . subOffset ] ) ;
const [ menuOpen , setMenuOpen ] = useState ( false ) ; // gear = tuning (tabbed)
const [ tracksOpen , setTracksOpen ] = useState ( false ) ; // separate quick menu = audio/subtitle tracks
const [ settingsTab , setSettingsTab ] = useState < "sync" | "playback" | "subtitle" > ( "sync" ) ;
const audioRef = useRef < number | null > ( null ) ;
const menuOpenRef = useRef ( false ) ;
const menuRef = useRef < HTMLDivElement > ( null ) ;
const menuTriggerRef = useRef < HTMLButtonElement > ( null ) ;
const tracksRef = useRef < HTMLDivElement > ( null ) ;
const tracksTriggerRef = useRef < HTMLButtonElement > ( null ) ;
audioRef . current = audioOrd ;
menuOpenRef . current = menuOpen ;
menuOpenRef . current = menuOpen || tracksOpen ; // keep the control bar up while either menu is open
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss ( menuOpen , ( ) = > setMenuOpen ( false ) , [ menuRef , menuTriggerRef ] ) ;
useDismiss ( tracksOpen , ( ) = > setTracksOpen ( false ) , [ tracksRef , tracksTriggerRef ] ) ;
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [ helpOpen , setHelpOpen ] = useState ( false ) ;
const helpOpenRef = useRef ( false ) ;
@@ -96,11 +201,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
durationRef . current = duration ;
// A new item starts with default tracks (its stream layout differs from the last one's).
useEffect ( ( ) = > {
setAudioOrd ( null ) ;
setSubOrd ( null ) ;
} , [ id ] ) ;
// Track selection is (re)derived from the saved language prefs when each item's detail arrives
// (see the detail effect below) — nothing to reset on id change.
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
const loadSession = useCallback (
@@ -111,7 +213,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setLoadError ( null ) ;
let sess ;
try {
sess = await api . plexSession ( id , startAt , audioRef . current ) ;
sess = await api . plexSession ( id , startAt , audioRef . current , aoffRef . current ) ;
} catch ( e : any ) {
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
@@ -125,7 +227,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
) ;
return ;
}
sessionStartRef . current = sess . start_s ;
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
const trueStart = sess . mode === "hls" && sess . start_s > 0 ? sess . start_s - HLS_SUB_LAG : sess.start_s ;
sessionStartRef . current = trueStart ;
setSessionK ( trueStart ) ;
if ( hlsRef . current ) {
hlsRef . current . destroy ( ) ;
hlsRef . current = null ;
@@ -164,6 +271,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Start playback when the detail arrives (resume from the saved position unless already watched).
useEffect ( ( ) = > {
if ( ! detail ) return ;
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang ( detail . audio_streams ? ? [ ] , prefs . audioLang ) ;
audioRef . current = aOrd ;
setAudioOrd ( aOrd ) ;
setSubOrd ( subOrdForLang ( detail . subtitle_streams ? ? [ ] , prefs . subLang ) ) ;
const resume = detail . status !== "watched" ? detail . position_seconds || 0 : 0 ;
loadSession ( resume ) ;
return ( ) = > {
@@ -172,6 +285,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef . current = null ;
}
} ;
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ detail , loadSession ] ) ;
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
@@ -210,8 +326,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/* ignore */
}
} ;
const onPlay = ( ) = > setPlaying ( true ) ;
const onPause = ( ) = > setPlaying ( false ) ;
const onPlay = ( ) = > {
setPlaying ( true ) ;
patchPrefs ( { wasPlaying : true } ) ; // remember play intent for F5 (best-effort; autoplay may be blocked)
} ;
const onPause = ( ) = > {
setPlaying ( false ) ;
patchPrefs ( { wasPlaying : false } ) ;
} ;
video . addEventListener ( "timeupdate" , onTime ) ;
video . addEventListener ( "play" , onPlay ) ;
video . addEventListener ( "pause" , onPause ) ;
@@ -220,7 +342,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video . removeEventListener ( "play" , onPlay ) ;
video . removeEventListener ( "pause" , onPause ) ;
} ;
} , [ id ] ) ;
} , [ id , patchPrefs ] ) ;
// Periodic + on-unmount progress checkpoint.
const absRef = useRef ( 0 ) ;
@@ -267,19 +389,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const applyVolume = useCallback ( ( vol : number , mute : boolean ) = > {
const v = videoRef . current ;
if ( ! v ) return ;
v . volume = Math . max ( 0 , Math . min ( 1 , vol ) ) ;
v . volume = clamp01 ( vol ) ;
v . muted = mute ;
} , [ ] ) ;
const nudgeVolume = useCallback (
( d : number ) = > {
setVolume ( ( prev ) = > {
const nv = Math . max ( 0 , Math . min ( 1 , prev + d ) ) ;
applyVolume ( nv , false ) ;
setMuted ( nv === 0 ) ;
return nv ;
} ) ;
// Set volume/mute on the <video> AND persist (survives F5).
const setVol = useCallback (
( vol : number , mute : boolean ) = > {
applyVolume ( vol , mute ) ;
patchPrefs ( { volume : clamp01 ( vol ) , muted : mute } ) ;
} ,
[ applyVolume ] ,
[ applyVolume , patchPrefs ] ,
) ;
const nudgeVolume = useCallback ( ( d : number ) = > setVol ( clamp01 ( prefs . volume + d ) , clamp01 ( prefs . volume + d ) === 0 ) , [ prefs . volume , setVol ] ) ;
const toggleMute = useCallback ( ( ) = > setVol ( prefs . volume , ! prefs . muted ) , [ prefs . volume , prefs . muted , setVol ] ) ;
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect ( ( ) = > {
if ( ready ) applyVolume ( prefs . volume , prefs . muted ) ;
} , [ ready , applyVolume , prefs . volume , prefs . muted ] ) ;
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
const onWheel = useCallback (
( e : ReactWheelEvent ) = > {
if ( infoOpenRef . current || helpOpenRef . current ) return ;
nudgeVolume ( e . deltaY < 0 ? 0.05 : - 0.05 ) ;
} ,
[ nudgeVolume ] ,
) ;
const toggleFs = useCallback ( ( ) = > {
@@ -325,37 +459,76 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
( ord : number | null ) = > {
audioRef . current = ord ;
setAudioOrd ( ord ) ;
setMenuOpen ( false ) ;
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
patchPrefs ( { audioLang : ord == null ? "" : ( detail ? . audio_streams . find ( ( s ) = > s . ord === ord ) ? . language ? ? "" ) } ) ;
loadSession ( absRef . current ) ;
} ,
[ loadSession ] ,
[ loadSession , detail , patchPrefs ] ,
) ;
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
const changeSubtitle = useCallback ( ( ord : number | null ) = > {
setSubOrd ( ord ) ;
setMenuOpen ( false ) ;
} , [ ] ) ;
const changeSubtitle = useCallback (
( ord : number | null ) = > {
setSubOrd ( ord ) ;
patchPrefs ( { subLang : ord == null ? "" : ( detail ? . subtitle_streams . find ( ( s ) = > s . ord === ord ) ? . language ? ? "" ) } ) ;
} ,
[ detail , patchPrefs ] ,
) ;
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback ( ( ) = > {
const streams = detail ? . audio_streams ? ? [ ] ;
if ( streams . length < 2 ) return ;
const idx = streams . findIndex ( ( s ) = > s . ord === ( audioRef . current ? ? 0 ) ) ;
const next = streams [ ( idx + 1 ) % streams . length ] ;
changeAudio ( next . ord === 0 ? null : next . ord ) ;
} , [ detail , changeAudio ] ) ;
const cycleSubtitle = useCallback ( ( ) = > {
const subs = ( detail ? . subtitle_streams ? ? [ ] ) . filter ( ( s ) = > s . text ) ;
const order : ( number | null ) [ ] = [ null , . . . subs . map ( ( s ) = > s . ord ) ] ;
const i = order . findIndex ( ( o ) = > o === subOrd ) ;
changeSubtitle ( order [ ( i + 1 ) % order . length ] ) ;
} , [ detail , subOrd , changeSubtitle ] ) ;
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
// dragging doesn't respawn ffmpeg on every step.
const aoffTimer = useRef < number | null > ( null ) ;
const changeAudioOffset = useCallback (
( v : number ) = > {
patchPrefs ( { audioOffset : v } ) ;
aoffRef . current = v ;
if ( aoffTimer . current ) window . clearTimeout ( aoffTimer . current ) ;
aoffTimer . current = window . setTimeout ( ( ) = > loadSession ( absRef . current ) , 500 ) ;
} ,
[ patchPrefs , loadSession ] ,
) ;
// Text subtitles offered to the browser as <track>s ; image subs (PGS/VobSub) can't be shown as text.
// Text subtitles offered in the picker ; image subs (PGS/VobSub) can't be shown as text.
const textSubs = ( detail ? . subtitle_streams ? ? [ ] ) . filter ( ( s ) = > s . text ) ;
// Apply the selected subtitle: show the match ing <track>, disable the rest. The <track> DOM order
// matches `textSubs`, so textTracks[i] ↔ textSubs[i]. Setting mode!="disabled" triggers the VTT fetch .
// Render ONLY the selected sub as a s ingle <track> (below) — rendering all of them and flipping
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart .
const selectedSub = textSubs . find ( ( s ) = > s . ord === subOrd ) ? ? null ;
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied ;
const settingsTabs = [
{ key : "sync" as const , label : t ( "plex.player.sync" ) } ,
{ key : "playback" as const , label : t ( "plex.player.playback" ) } ,
. . . ( textSubs . length > 0 ? [ { key : "subtitle" as const , label : t ( "plex.player.subStyle" ) } ] : [ ] ) ,
] ;
const activeTab = settingsTabs . some ( ( x ) = > x . key === settingsTab ) ? settingsTab : "sync" ;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
useEffect ( ( ) = > {
const video = videoRef . current ;
if ( ! video ) return ;
const tracks = Array . from ( video . textTracks ) ;
tracks . forEach ( ( tr , i ) = > {
const s = textSubs [ i ] ;
tr . mode = s && s . ord === subOrd ? "showing" : "disabled" ;
} ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ subOrd , id , textSubs . length ] ) ;
for ( const tr of Array . from ( video . textTracks ) ) tr . mode = "showing" ;
} , [ subOrd , id , subTrackOffset ] ) ;
// Lift subtitle cues off the very bottom edge . The video element fills the viewport height, so a
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
// Position subtitle cues per the user's vertical setting (prefs.subPos) . The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect ( ( ) = > {
const video = videoRef . current ;
if ( ! video ) return ;
@@ -364,7 +537,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
for ( const c of Array . from ( tr . cues || [ ] ) ) {
try {
( c as VTTCue ) . snapToLines = false ;
( c as VTTCue ) . line = 88 ;
( c as VTTCue ) . line = prefs . subPos ;
} catch {
/* not a positionable cue */
}
@@ -380,11 +553,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}
} ;
video . textTracks . addEventListener ( "addtrack" , onAddTrack as EventListener ) ;
placeAll ( ) ;
return ( ) = > {
video . textTracks . removeEventListener ( "addtrack" , onAddTrack as EventListener ) ;
for ( const tr of Array . from ( video . textTracks ) ) tr . removeEventListener ( "cuechange" , placeAll ) ;
} ;
} , [ id ] ) ;
} , [ id , prefs . subPos ] ) ;
// Auto-advance to the next episode when one finishes.
useEffect ( ( ) = > {
@@ -402,6 +576,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const wake = useCallback ( ( ) = > {
setUiVisible ( true ) ;
if ( hideTimer . current ) window . clearTimeout ( hideTimer . current ) ;
if ( ! barAutoHideRef . current ) return ; // per-user setting: never auto-hide the control bar
hideTimer . current = window . setTimeout ( ( ) = > {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if ( ! videoRef . current ? . paused && ! menuOpenRef . current ) setUiVisible ( false ) ;
@@ -426,12 +601,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "ArrowLeft" :
e . preventDefault ( ) ;
if ( e . shiftKey ) go ( prevId ) ;
else seekTo ( absRef . current - 10 ) ;
else seekTo ( absRef . current - ( e . ctrlKey ? fineSeekStepRef.current : seekStepRef.current ) ) ;
break ;
case "ArrowRight" :
e . preventDefault ( ) ;
if ( e . shiftKey ) go ( nextId ) ;
else seekTo ( absRef . current + 10 ) ;
else seekTo ( absRef . current + ( e . ctrlKey ? fineSeekStepRef.current : seekStepRef.current ) ) ;
break ;
case "ArrowUp" :
e . preventDefault ( ) ;
@@ -442,10 +617,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
nudgeVolume ( - 0.05 ) ;
break ;
case "m" :
setMuted ( ( m ) = > {
applyVolume ( volume , ! m ) ;
return ! m ;
} ) ;
toggleMute ( ) ;
break ;
case "a" :
case "A" :
cycleAudio ( ) ;
break ;
case "s" :
case "S" :
cycleSubtitle ( ) ;
break ;
case "h" :
case "H" :
@@ -458,12 +638,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "Backspace" :
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
e . preventDefault ( ) ;
if ( helpOpenRef . current ) setHelpOpen ( false ) ;
if ( menuOpenRef . current ) {
setMenuOpen ( false ) ;
setTracksOpen ( false ) ;
} else if ( helpOpenRef . current ) setHelpOpen ( false ) ;
else if ( infoOpenRef . current ) setInfoOpen ( false ) ;
else onClose ( ) ;
break ;
case "Escape" :
if ( helpOpenRef . current ) setHelpOpen ( false ) ;
if ( menuOpenRef . current ) {
setMenuOpen ( false ) ;
setTracksOpen ( false ) ;
} else if ( helpOpenRef . current ) setHelpOpen ( false ) ;
else if ( infoOpenRef . current ) setInfoOpen ( false ) ;
else if ( ! document . fullscreenElement ) onClose ( ) ;
break ;
@@ -471,7 +657,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
} ;
window . addEventListener ( "keydown" , onKey ) ;
return ( ) = > window . removeEventListener ( "keydown" , onKey ) ;
} , [ togglePlay , toggleFs , seekTo , go , nudgeVolume , applyVolume , volume , detail , prevId , nextId , onClose , wake ] ) ;
} , [ togglePlay , toggleFs , seekTo , go , nudgeVolume , toggleMute , cycleAudio , cycleSubtitle , detail , prevId , nextId , onClose , wake ] ) ;
const activeMarker : PlexMarker | undefined = detail ? . markers . find (
( m ) = > abs >= m . start_s && abs < m . end_s - 1 ,
@@ -486,8 +672,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
className = "fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
onMouseMove = { wake }
onClick = { wake }
onWheel = { onWheel }
style = { { cursor : uiVisible ? "default" : "none" } }
>
{ /* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
player owns the only <video> on screen. */ }
< style > { ` video::cue{font-size: ${ prefs . subSize } %;color: ${ prefs . subColor } ;background-color: ${ prefs . subBg } ;} ` } < / style >
< video
ref = { videoRef }
className = "max-h-full max-w-full w-auto h-auto"
@@ -496,15 +686,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
>
{ /* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
above flips modes; the browser fetches a track's VTT the first time it's shown. */ }
{ textSubs . map ( ( s ) = > (
{ selectedSub && (
< track
key = { s . ord }
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
key = { ` ${ id } - ${ selectedSub . ord } - ${ subTrackOffset . toFixed ( 2 ) } ` }
kind = "subtitles"
src = { api . plexSubtitleUrl ( id , s . ord ) }
srcLang = { s . language ? ? undefined }
label = { s . label }
src = { api . plexSubtitleUrl ( id , selectedSub . ord , subTrackOffset ) }
srcLang = { selectedSub . language ? ? undefined }
label = { selectedSub . label }
default
/ >
) ) }
) }
< / video >
{ ! ready && (
@@ -588,7 +781,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
< / div >
< div className = "flex items-center gap-3 text-white" >
< Ctrl label = { t ( "plex.player.playPause" ) } onClick = { togglePlay } >
< Ctrl label = { t ( "plex.player.playPause" ) } onClick = { togglePlay } align = "start" >
{ playing ? < Pause className = "w-6 h-6" / > : < Play className = "w-6 h-6" / > }
< / Ctrl >
< Ctrl label = { t ( "plex.player.restart" ) } onClick = { ( ) = > seekTo ( 0 ) } >
@@ -609,15 +802,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
) }
< div className = "flex items-center gap-1.5 group/vol" >
< Ctrl
label = { t ( "plex.player.mute" ) }
onClick = { ( ) = >
setMuted ( ( m ) = > {
applyVolume ( volume , ! m ) ;
return ! m ;
} )
}
>
< Ctrl label = { t ( "plex.player.mute" ) } onClick = { toggleMute } >
{ muted || volume === 0 ? < VolumeX className = "w-5 h-5" / > : < Volume2 className = "w-5 h-5" / > }
< / Ctrl >
< input
@@ -628,9 +813,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
value = { muted ? 0 : volume }
onChange = { ( e ) = > {
const nv = Number ( e . target . value ) ;
setVolume ( nv ) ;
setMuted ( nv === 0 ) ;
applyVolume ( nv , false ) ;
setVol ( nv , nv === 0 ) ;
} }
className = "w-20 accent-accent"
/ >
@@ -642,22 +825,26 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
< / div >
< div className = "ml-auto flex items-center gap-2" >
{ /* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */ }
{ detail && ( detail . audio_streams . length > 1 || textSubs . length > 0 ) && (
< div className = "relative" >
< button
onClick = { ( ) = > setMenuOpen ( ( o ) = > ! o ) }
ref = { tracksTriggerRef }
onClick = { ( ) = > setTracksOpen ( ( o ) = > ! o ) }
aria-label = { t ( "plex.player.tracks" ) }
className = { ` p-1 hover:text-accent ${ menuOpen ? "text-accent" : "" } ` }
className = { ` p-1 hover:text-accent ${ tracksOpen ? "text-accent" : "" } ` }
>
< Settings className = "w-5 h-5" / >
< Languages className = "w-5 h-5" / >
< / button >
{ menuOpen && (
< div className = "absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm" >
{ tracksOpen && (
< div
ref = { tracksRef }
onClick = { ( e ) = > e . stopPropagation ( ) }
className = "glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
>
{ detail . audio_streams . length > 1 && (
< >
< div className = "px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50" >
{ t ( "plex.player.audio" ) }
< / div >
< PanelHead > { t ( "plex.player.audio" ) } < / PanelHead >
{ detail . audio_streams . map ( ( a ) = > (
< button
key = { a . ord }
@@ -673,9 +860,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
) }
{ textSubs . length > 0 && (
< >
< div className = "px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50" >
{ t ( "plex.player.subtitles" ) }
< / div >
< PanelHead > { t ( "plex.player.subtitles" ) } < / PanelHead >
< button
onClick = { ( ) = > changeSubtitle ( null ) }
className = { ` block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
@@ -701,6 +886,149 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
) }
< / div >
) }
{ /* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */ }
{ detail && (
< div className = "relative" >
< button
ref = { menuTriggerRef }
onClick = { ( ) = > setMenuOpen ( ( o ) = > ! o ) }
aria-label = { t ( "plex.player.settings" ) }
className = { ` p-1 hover:text-accent ${ menuOpen ? "text-accent" : "" } ` }
>
< Settings className = "w-5 h-5" / >
< / button >
{ menuOpen && (
< div
ref = { menuRef }
onWheel = { ( e ) = > e . stopPropagation ( ) }
onClick = { ( e ) = > e . stopPropagation ( ) }
className = "glass-menu absolute bottom-full right-0 mb-2 w-72 rounded-xl p-2 text-sm text-white shadow-2xl"
>
< div className = "mb-1.5 flex gap-1" >
{ settingsTabs . map ( ( tab ) = > (
< button
key = { tab . key }
onClick = { ( ) = > setSettingsTab ( tab . key ) }
className = { ` flex-1 rounded px-2 py-1 text-[11px] ${
activeTab === tab . key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
} ` }
>
{ tab . label }
< / button >
) ) }
< / div >
{ activeTab === "sync" && (
< >
< Slider
label = { t ( "plex.player.subOffset" ) }
value = { prefs . subOffset }
min = { - 10 }
max = { 10 }
step = { 0.1 }
suffix = "s"
onChange = { ( v ) = > patchPrefs ( { subOffset : v } ) }
onReset = { ( ) = > patchPrefs ( { subOffset : 0 } ) }
/ >
< Slider
label = { t ( "plex.player.audioOffset" ) }
value = { prefs . audioOffset }
min = { - 5 }
max = { 5 }
step = { 0.1 }
suffix = "s"
onChange = { changeAudioOffset }
onReset = { ( ) = > changeAudioOffset ( 0 ) }
/ >
< / >
) }
{ activeTab === "playback" && (
< >
< Slider
label = { t ( "plex.player.seekStep" ) }
value = { prefs . seekStep }
min = { 5 }
max = { 30 }
step = { 1 }
suffix = "s"
onChange = { ( v ) = > patchPrefs ( { seekStep : v } ) }
/ >
< Slider
label = { t ( "plex.player.fineSeek" ) }
value = { prefs . fineSeekStep }
min = { 0.1 }
max = { 5 }
step = { 0.1 }
suffix = "s"
onChange = { ( v ) = > patchPrefs ( { fineSeekStep : v } ) }
/ >
< Toggle
label = { t ( "plex.player.autoHide" ) }
checked = { prefs . barAutoHide }
onChange = { ( v ) = > patchPrefs ( { barAutoHide : v } ) }
/ >
< / >
) }
{ activeTab === "subtitle" && (
< >
< Slider
label = { t ( "plex.player.subSize" ) }
value = { prefs . subSize }
min = { 50 }
max = { 250 }
step = { 5 }
suffix = "%"
onChange = { ( v ) = > patchPrefs ( { subSize : v } ) }
/ >
< Slider
label = { t ( "plex.player.subPos" ) }
value = { prefs . subPos }
min = { 50 }
max = { 95 }
step = { 1 }
suffix = "%"
onChange = { ( v ) = > patchPrefs ( { subPos : v } ) }
/ >
< div className = "flex items-center justify-between px-1 py-1.5" >
< span className = "text-[12px] text-white/80" > { t ( "plex.player.subColor" ) } < / span >
< input
type = "color"
value = { prefs . subColor }
onChange = { ( e ) = > patchPrefs ( { subColor : e.target.value } ) }
className = "h-6 w-10 cursor-pointer rounded bg-transparent"
/ >
< / div >
< div className = "flex items-center justify-between px-1 py-1.5" >
< span className = "text-[12px] text-white/80" > { t ( "plex.player.subBg" ) } < / span >
< div className = "flex items-center gap-2" >
< label className = "flex items-center gap-1 text-[11px] text-white/70" >
< input
type = "checkbox"
checked = { prefs . subBg === "transparent" }
onChange = { ( e ) = > patchPrefs ( { subBg : e.target.checked ? "transparent" : "#000000" } ) }
className = "accent-accent"
/ >
{ t ( "plex.player.subBgNone" ) }
< / label >
{ prefs . subBg !== "transparent" && (
< input
type = "color"
value = { prefs . subBg }
onChange = { ( e ) = > patchPrefs ( { subBg : e.target.value } ) }
className = "h-6 w-10 cursor-pointer rounded bg-transparent"
/ >
) }
< / div >
< / div >
< / >
) }
< / div >
) }
< / div >
) }
< Ctrl label = { t ( "plex.player.infoBtn" ) } onClick = { ( ) = > setInfoOpen ( ( v ) = > ! v ) } >
< Info className = "w-5 h-5" / >
< / Ctrl >
@@ -710,7 +1038,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
< Ctrl label = { t ( "plex.player.download" ) } onClick = { download } >
< Download className = "w-5 h-5" / >
< / Ctrl >
< Ctrl label = { t ( "plex.player.fullscreen" ) } onClick = { toggleFs } >
< Ctrl label = { t ( "plex.player.fullscreen" ) } onClick = { toggleFs } align = "end" >
{ fs ? < Minimize className = "w-5 h-5" / > : < Maximize className = "w-5 h-5" / > }
< / Ctrl >
< / div >
@@ -747,11 +1075,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[
[ [ "Space" , "K" ] , "plex.player.keys.playPause" ] ,
[ [ "←" , "→" ] , "plex.player.keys.seek" ] ,
[ [ "Ctrl ←" , "Ctrl →" ] , "plex.player.keys.fineSeek" ] ,
. . . ( detail ? . kind === "episode"
? [ [ [ "⇧ ←" , "⇧ →" ] , "plex.player.keys.episode" ] as [ string [ ] , string ] ]
: [ ] ) ,
[ [ "↑" , "↓" ] , "plex.player.keys.volume" ] ,
[ [ "M" ] , "plex.player.keys.mute" ] ,
[ [ "A" ] , "plex.player.keys.audio" ] ,
[ [ "S" ] , "plex.player.keys.subtitle" ] ,
[ [ "F" ] , "plex.player.keys.fullscreen" ] ,
[ [ "I" ] , "plex.player.keys.info" ] ,
[ [ "⌫" , "Esc" ] , "plex.player.keys.back" ] ,
@@ -800,19 +1131,83 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
) ;
}
// --- settings-panel building blocks ------------------------------------------------------------
function PanelHead ( { children } : { children : ReactNode } ) {
return < div className = "px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50" > { children } < / div > ;
}
function Slider ( {
label ,
value ,
min ,
max ,
step ,
suffix ,
onChange ,
onReset ,
} : {
label : string ;
value : number ;
min : number ;
max : number ;
step : number ;
suffix? : string ;
onChange : ( v : number ) = > void ;
onReset ? : ( ) = > void ;
} ) {
return (
< div className = "px-1 py-1" >
< div className = "mb-1 flex items-center justify-between text-[12px] text-white/80" >
< span > { label } < / span >
< span className = "flex items-center gap-1.5" >
< span className = "tabular-nums text-white/60" >
{ Number . isInteger ( value ) ? value : value.toFixed ( 1 ) }
{ suffix ? ? "" }
< / span >
{ onReset && (
< button onClick = { onReset } className = "text-white/40 hover:text-white" aria-label = "reset" >
< RotateCcw className = "h-3 w-3" / >
< / button >
) }
< / span >
< / div >
< input
type = "range"
min = { min }
max = { max }
step = { step }
value = { value }
onChange = { ( e ) = > onChange ( Number ( e . target . value ) ) }
className = "w-full accent-accent"
/ >
< / div >
) ;
}
function Toggle ( { label , checked , onChange } : { label : string ; checked : boolean ; onChange : ( v : boolean ) = > void } ) {
return (
< label className = "flex cursor-pointer items-center justify-between px-1 py-1.5" >
< span className = "text-[12px] text-white/80" > { label } < / span >
< input type = "checkbox" checked = { checked } onChange = { ( e ) = > onChange ( e . target . checked ) } className = "h-4 w-4 accent-accent" / >
< / label >
) ;
}
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl ( {
label ,
onClick ,
disabled ,
align = "center" ,
children ,
} : {
label : string ;
onClick : ( ) = > void ;
disabled? : boolean ;
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
align ? : "start" | "center" | "end" ;
children : ReactNode ;
} ) {
const pos = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2" ;
return (
< div className = "relative flex group/ctrl" >
< button
@@ -823,7 +1218,9 @@ function Ctrl({
>
{ children }
< / button >
< span className = "pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100" >
< span
className = { ` pointer-events-none absolute bottom-full ${ pos } mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100 ` }
>
{ label }
< / span >
< / div >