11import type React from 'react' ;
2- import { useState , useRef , useEffect , useMemo } from 'react' ;
2+ import { useState , useRef , useEffect , useMemo , useCallback } from 'react' ;
33import {
44 Play ,
55 Pause ,
@@ -19,8 +19,12 @@ interface NetflixStylePlayerProps {
1919 description : string ;
2020}
2121
22+ const CONTROLS_HIDE_DELAY_MS = 3000 ;
23+
2224export default function NetflixStylePlayer ( {
2325 videoSrc,
26+ title,
27+ description,
2428} : NetflixStylePlayerProps ) {
2529 const [ isPlaying , setIsPlaying ] = useState ( false ) ;
2630 const [ progress , setProgress ] = useState ( 0 ) ;
@@ -32,23 +36,34 @@ export default function NetflixStylePlayer({
3236 const [ duration , setDuration ] = useState ( 0 ) ;
3337 const videoRef = useRef < HTMLVideoElement > ( null ) ;
3438 const containerRef = useRef < HTMLDivElement > ( null ) ;
39+ const hideControlsTimeoutRef = useRef < NodeJS . Timeout | undefined > ( undefined ) ;
3540
3641 const resolvedSrc = useMemo ( ( ) => convertFileSrc ( videoSrc ) , [ videoSrc ] ) ;
3742
38- useEffect ( ( ) => {
39- let timeout : NodeJS . Timeout ;
40- const showControlsTemporarily = ( ) => {
41- setShowControls ( true ) ;
42- clearTimeout ( timeout ) ;
43- timeout = setTimeout ( ( ) => setShowControls ( false ) , 3000 ) ;
44- } ;
43+ // Netflix-style behaviour: controls always show while paused; while
44+ // playing they're only shown temporarily (hover/touch/focus) and hidden
45+ // controls must not remain interactive (pointer-events must follow).
46+ const controlsVisible = ! isPlaying || showControls ;
4547
46- const container = containerRef . current ;
47- if ( container ) {
48- container . addEventListener ( 'mousemove' , showControlsTemporarily ) ;
49- container . addEventListener ( 'mouseenter' , showControlsTemporarily ) ;
50- }
48+ const revealControlsTemporarily = useCallback ( ( ) => {
49+ setShowControls ( true ) ;
50+ clearTimeout ( hideControlsTimeoutRef . current ) ;
51+ hideControlsTimeoutRef . current = setTimeout (
52+ ( ) => setShowControls ( false ) ,
53+ CONTROLS_HIDE_DELAY_MS ,
54+ ) ;
55+ } , [ ] ) ;
5156
57+ const hideControlsNow = useCallback ( ( ) => {
58+ clearTimeout ( hideControlsTimeoutRef . current ) ;
59+ setShowControls ( false ) ;
60+ } , [ ] ) ;
61+
62+ useEffect ( ( ) => {
63+ return ( ) => clearTimeout ( hideControlsTimeoutRef . current ) ;
64+ } , [ ] ) ;
65+
66+ useEffect ( ( ) => {
5267 const handleKeyDown = ( e : KeyboardEvent ) => {
5368 const activeEl = document . activeElement ;
5469 if (
@@ -64,29 +79,29 @@ export default function NetflixStylePlayer({
6479 case ' ' :
6580 e . preventDefault ( ) ;
6681 togglePlay ( ) ;
67- showControlsTemporarily ( ) ;
82+ revealControlsTemporarily ( ) ;
6883 break ;
6984 case 'ArrowLeft' :
7085 e . preventDefault ( ) ;
7186 skipTime ( - 10 ) ;
72- showControlsTemporarily ( ) ;
87+ revealControlsTemporarily ( ) ;
7388 break ;
7489 case 'ArrowRight' :
7590 e . preventDefault ( ) ;
7691 skipTime ( 10 ) ;
77- showControlsTemporarily ( ) ;
92+ revealControlsTemporarily ( ) ;
7893 break ;
7994 case 'm' :
8095 case 'M' :
8196 e . preventDefault ( ) ;
8297 toggleMute ( ) ;
83- showControlsTemporarily ( ) ;
98+ revealControlsTemporarily ( ) ;
8499 break ;
85100 case 'f' :
86101 case 'F' :
87102 e . preventDefault ( ) ;
88103 toggleFullScreen ( ) ;
89- showControlsTemporarily ( ) ;
104+ revealControlsTemporarily ( ) ;
90105 break ;
91106 default :
92107 break ;
@@ -96,12 +111,7 @@ export default function NetflixStylePlayer({
96111 document . addEventListener ( 'keydown' , handleKeyDown ) ;
97112
98113 return ( ) => {
99- if ( container ) {
100- container . removeEventListener ( 'mousemove' , showControlsTemporarily ) ;
101- container . removeEventListener ( 'mouseenter' , showControlsTemporarily ) ;
102- }
103114 document . removeEventListener ( 'keydown' , handleKeyDown ) ;
104- clearTimeout ( timeout ) ;
105115 } ;
106116 } , [ ] ) ;
107117
@@ -217,8 +227,45 @@ export default function NetflixStylePlayer({
217227 }
218228 } ;
219229
230+ const handleSeekBarKeyDown = ( e : React . KeyboardEvent < HTMLDivElement > ) => {
231+ const video = videoRef . current ;
232+ if ( ! video ) return ;
233+ const maxTime = Number . isFinite ( video . duration ) ? video . duration : 0 ;
234+
235+ switch ( e . key ) {
236+ case 'ArrowLeft' :
237+ case 'ArrowRight' :
238+ e . preventDefault ( ) ;
239+ e . stopPropagation ( ) ;
240+ skipTime ( e . key === 'ArrowLeft' ? - 5 : 5 ) ;
241+ break ;
242+ case 'Home' :
243+ e . preventDefault ( ) ;
244+ e . stopPropagation ( ) ;
245+ video . currentTime = 0 ;
246+ setCurrentTime ( 0 ) ;
247+ break ;
248+ case 'End' :
249+ e . preventDefault ( ) ;
250+ e . stopPropagation ( ) ;
251+ video . currentTime = maxTime ;
252+ setCurrentTime ( maxTime ) ;
253+ break ;
254+ default :
255+ break ;
256+ }
257+ } ;
258+
220259 return (
221- < div ref = { containerRef } className = "relative aspect-video w-full bg-black" >
260+ < div
261+ ref = { containerRef }
262+ className = "relative aspect-video w-full bg-black"
263+ onMouseMove = { revealControlsTemporarily }
264+ onMouseEnter = { revealControlsTemporarily }
265+ onMouseLeave = { ( ) => isPlaying && hideControlsNow ( ) }
266+ onTouchStart = { revealControlsTemporarily }
267+ onFocus = { revealControlsTemporarily }
268+ >
222269 { /* Clickable play/pause area above progress bar */ }
223270 < div
224271 className = "absolute inset-0 flex items-center justify-center"
@@ -238,13 +285,45 @@ export default function NetflixStylePlayer({
238285 />
239286 </ div >
240287
288+ { /* Title / description overlay. Non-interactive (pointer-events-none)
289+ so it never steals clicks from the play/pause area beneath it. */ }
290+ { ( title || description ) && (
291+ < div
292+ className = { `pointer-events-none absolute top-0 right-0 left-0 bg-gradient-to-b from-black/80 to-transparent px-6 pt-6 pb-16 transition-opacity ${
293+ controlsVisible ? 'opacity-100' : 'opacity-0'
294+ } `}
295+ >
296+ { title && (
297+ < h2 className = "text-xl font-semibold text-white" > { title } </ h2 >
298+ ) }
299+ { description && (
300+ < p className = "mt-1 max-w-2xl text-sm text-gray-300" >
301+ { description }
302+ </ p >
303+ ) }
304+ </ div >
305+ ) }
306+
241307 { /* Progress Bar */ }
242308 < div
243- className = { `absolute right-0 bottom-16 left-0 px-4 transition-opacity ${ showControls ? 'opacity-100' : 'opacity-0' } ` }
309+ className = { `absolute right-0 bottom-16 left-0 px-4 transition-opacity ${
310+ controlsVisible
311+ ? 'pointer-events-auto opacity-100'
312+ : 'pointer-events-none opacity-0'
313+ } `}
244314 >
245315 < div
246- className = "h-1 w-full cursor-pointer bg-gray-600"
316+ role = "slider"
317+ tabIndex = { 0 }
318+ aria-label = "Seek"
319+ aria-valuemin = { 0 }
320+ aria-valuemax = { Number . isFinite ( duration ) ? duration : 0 }
321+ aria-valuenow = { currentTime }
322+ aria-valuetext = { `${ formatTime ( currentTime ) } of ${ formatTime ( duration ) } ` }
323+ className = "h-1 w-full cursor-pointer bg-gray-600 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-hidden"
247324 onClick = { handleProgressBarClick }
325+ onKeyDown = { handleSeekBarKeyDown }
326+ onFocus = { revealControlsTemporarily }
248327 >
249328 < div
250329 className = "h-full bg-red-600"
@@ -255,16 +334,32 @@ export default function NetflixStylePlayer({
255334
256335 { /* Controls */ }
257336 < div
258- className = { `absolute right-0 bottom-4 left-0 flex items-center justify-between px-4 transition-opacity ${ showControls ? 'opacity-100' : 'opacity-0' } ` }
337+ className = { `absolute right-0 bottom-4 left-0 flex items-center justify-between px-4 transition-opacity ${
338+ controlsVisible
339+ ? 'pointer-events-auto opacity-100'
340+ : 'pointer-events-none opacity-0'
341+ } `}
259342 >
260343 < div className = "flex items-center space-x-4" >
261- < button onClick = { ( ) => skipTime ( - 10 ) } className = "p-2 text-white" >
344+ < button
345+ onClick = { ( ) => skipTime ( - 10 ) }
346+ className = "p-2 text-white"
347+ aria-label = "Rewind 10 seconds"
348+ >
262349 < Rewind size = { 24 } />
263350 </ button >
264- < button onClick = { togglePlay } className = "p-2 text-white" >
351+ < button
352+ onClick = { togglePlay }
353+ className = "p-2 text-white"
354+ aria-label = { isPlaying ? 'Pause' : 'Play' }
355+ >
265356 { isPlaying ? < Pause size = { 24 } /> : < Play size = { 24 } /> }
266357 </ button >
267- < button onClick = { ( ) => skipTime ( 10 ) } className = "p-2 text-white" >
358+ < button
359+ onClick = { ( ) => skipTime ( 10 ) }
360+ className = "p-2 text-white"
361+ aria-label = "Fast forward 10 seconds"
362+ >
268363 < FastForward size = { 24 } />
269364 </ button >
270365 < div className = "text-white" >
@@ -274,7 +369,11 @@ export default function NetflixStylePlayer({
274369
275370 { /* Volume and Fullscreen */ }
276371 < div className = "flex items-center space-x-4" >
277- < button onClick = { toggleMute } className = "text-white" >
372+ < button
373+ onClick = { toggleMute }
374+ className = "text-white"
375+ aria-label = { isMuted ? 'Unmute' : 'Mute' }
376+ >
278377 { isMuted ? < VolumeX size = { 24 } /> : < Volume2 size = { 24 } /> }
279378 </ button >
280379 < Slider
@@ -284,8 +383,13 @@ export default function NetflixStylePlayer({
284383 value = { [ isMuted ? 0 : volume ] }
285384 onValueChange = { handleVolumeChange }
286385 className = "w-24"
386+ aria-label = "Volume"
287387 />
288- < button onClick = { toggleFullScreen } className = "text-white" >
388+ < button
389+ onClick = { toggleFullScreen }
390+ className = "text-white"
391+ aria-label = { isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }
392+ >
289393 { isFullscreen ? < Minimize2 size = { 24 } /> : < Maximize2 size = { 24 } /> }
290394 </ button >
291395 </ div >
0 commit comments