Skip to content

Commit ac752e9

Browse files
committed
fix(video-player): add accessibility and controls-visibility UX to NetflixStylePlayer (AOSSIE-Org#1333)
Follow-up to AOSSIE-Org#1329/AOSSIE-Org#1339 in the same component. Addresses the remaining accessibility and UX gaps: - Render title/description as an overlay that fades with the controls. It is non-interactive (pointer-events-none) so it never steals clicks from the play/pause area beneath it. - Expose the progress bar as an accessible slider: role="slider", aria-valuemin/max/now, aria-valuetext, focusable, seekable via Left/Right (+/-5s) and Home/End. Stops event propagation so the seek-bar keys don't also trigger the global +/-10s shortcut. - Add descriptive, state-aware aria-labels to every control button and the volume slider (play/pause, mute, fullscreen toggles). Forward aria-label through the shared Slider onto the Radix Thumb. - Rework controls-visibility: visible by default while paused (Netflix-style), auto-hide after a delay and on mouse-leave while playing, reveal on touch and keyboard focus. Hidden controls are non-interactive (pointer-events-none). Adds regression tests for the title/description overlay, seek-bar accessibility and keyboard seeking, control aria-labels, and the paused/playing visibility logic.
1 parent ffc0d48 commit ac752e9

3 files changed

Lines changed: 237 additions & 32 deletions

File tree

frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx

Lines changed: 136 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react';
2-
import { useState, useRef, useEffect, useMemo } from 'react';
2+
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
33
import {
44
Play,
55
Pause,
@@ -19,8 +19,12 @@ interface NetflixStylePlayerProps {
1919
description: string;
2020
}
2121

22+
const CONTROLS_HIDE_DELAY_MS = 3000;
23+
2224
export 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>

frontend/src/components/VideoPlayer/__tests__/NetflixStylePlayer.test.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,105 @@ describe('NetflixStylePlayer', () => {
317317
}
318318
});
319319

320+
test('renders title and description overlay', () => {
321+
render(
322+
<NetflixStylePlayer
323+
videoSrc="video.mp4"
324+
title="My Great Video"
325+
description="A description of the video"
326+
/>,
327+
);
328+
329+
expect(screen.getByText('My Great Video')).toBeInTheDocument();
330+
expect(screen.getByText('A description of the video')).toBeInTheDocument();
331+
});
332+
333+
test('exposes the progress bar as an accessible slider', () => {
334+
render(
335+
<NetflixStylePlayer videoSrc="video.mp4" title="Test" description="" />,
336+
);
337+
338+
const seekBar = screen.getByRole('slider', { name: 'Seek' });
339+
expect(seekBar).toHaveAttribute('aria-valuemin', '0');
340+
expect(seekBar).toHaveAttribute('tabindex', '0');
341+
342+
const video = document.querySelector('video') as HTMLVideoElement;
343+
const videoMockState = mockVideoProperties(video);
344+
Object.defineProperty(video, 'duration', {
345+
value: 100,
346+
writable: true,
347+
configurable: true,
348+
});
349+
350+
act(() => {
351+
seekBar.focus();
352+
fireEvent.keyDown(seekBar, { key: 'ArrowRight' });
353+
});
354+
expect(videoMockState.currentTime).toBe(5);
355+
356+
act(() => {
357+
fireEvent.keyDown(seekBar, { key: 'End' });
358+
});
359+
expect(videoMockState.currentTime).toBe(100);
360+
361+
act(() => {
362+
fireEvent.keyDown(seekBar, { key: 'Home' });
363+
});
364+
expect(videoMockState.currentTime).toBe(0);
365+
});
366+
367+
test('control buttons have descriptive, state-aware aria-labels', () => {
368+
render(
369+
<NetflixStylePlayer videoSrc="video.mp4" title="Test" description="" />,
370+
);
371+
372+
expect(
373+
screen.getByRole('button', { name: 'Rewind 10 seconds' }),
374+
).toBeInTheDocument();
375+
expect(
376+
screen.getByRole('button', { name: 'Fast forward 10 seconds' }),
377+
).toBeInTheDocument();
378+
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
379+
expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
380+
expect(
381+
screen.getByRole('button', { name: 'Enter fullscreen' }),
382+
).toBeInTheDocument();
383+
384+
act(() => {
385+
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
386+
});
387+
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
388+
});
389+
390+
test('controls are visible by default while paused and hide only while playing', () => {
391+
const { container } = render(
392+
<NetflixStylePlayer videoSrc="video.mp4" title="Test" description="" />,
393+
);
394+
395+
const controls = container.querySelector(
396+
'.absolute.right-0.bottom-4',
397+
) as HTMLElement;
398+
expect(controls.className).toContain('opacity-100');
399+
expect(controls.className).toContain('pointer-events-auto');
400+
401+
act(() => {
402+
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
403+
});
404+
expect(controls.className).toContain('opacity-0');
405+
expect(controls.className).toContain('pointer-events-none');
406+
407+
const player = container.firstChild as HTMLElement;
408+
act(() => {
409+
fireEvent.mouseEnter(player);
410+
});
411+
expect(controls.className).toContain('opacity-100');
412+
413+
act(() => {
414+
fireEvent.mouseLeave(player);
415+
});
416+
expect(controls.className).toContain('opacity-0');
417+
});
418+
320419
describe('Tauri CSP Policy Configuration', () => {
321420
test('tauri.conf.json whitelists asset: and http://asset.localhost in media-src CSP', () => {
322421
const configPath = path.resolve(

frontend/src/components/ui/Slider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ function Slider({
99
value,
1010
min = 0,
1111
max = 100,
12+
'aria-label': ariaLabel,
1213
...props
1314
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
1415
const _values = React.useMemo(
@@ -51,6 +52,7 @@ function Slider({
5152
<SliderPrimitive.Thumb
5253
data-slot="slider-thumb"
5354
key={index}
55+
aria-label={ariaLabel}
5456
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
5557
/>
5658
))}

0 commit comments

Comments
 (0)