@nationaldesignstudio/react 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/component-registry.md +1286 -40
- package/dist/index.d.ts +1572 -133
- package/dist/index.js +2245 -257
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +680 -0
- package/package.json +20 -1
- package/src/components/atoms/blurred-video-backdrop/blurred-video-backdrop.tsx +447 -0
- package/src/components/atoms/button/icon-button.tsx +10 -4
- package/src/components/atoms/select/select.tsx +202 -49
- package/src/components/atoms/video-player/caption-overlay.tsx +107 -0
- package/src/components/atoms/video-player/video-player.tsx +811 -0
- package/src/components/molecules/dialog/dialog.tsx +526 -0
- package/src/components/molecules/video-dialog/video-dialog.tsx +272 -0
- package/src/components/molecules/video-with-backdrop/video-with-backdrop.tsx +383 -0
- package/src/hooks/index.ts +16 -0
- package/src/hooks/use-breakpoint.ts +145 -0
- package/src/hooks/use-captions.ts +247 -0
- package/src/hooks/use-video-keyboard.ts +230 -0
- package/src/lib/utils.ts +8 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Design system breakpoint values in pixels.
|
|
7
|
+
* These match the primitive breakpoint tokens from packages/tokens.
|
|
8
|
+
*/
|
|
9
|
+
export const BREAKPOINTS = {
|
|
10
|
+
sm: 320,
|
|
11
|
+
md: 768,
|
|
12
|
+
lg: 1440,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type Breakpoint = keyof typeof BREAKPOINTS;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the current breakpoint based on viewport width.
|
|
19
|
+
*/
|
|
20
|
+
function getCurrentBreakpoint(width: number): Breakpoint {
|
|
21
|
+
if (width >= BREAKPOINTS.lg) return "lg";
|
|
22
|
+
if (width >= BREAKPOINTS.md) return "md";
|
|
23
|
+
return "sm";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook to get the current responsive breakpoint.
|
|
28
|
+
* Returns the active breakpoint name based on viewport width.
|
|
29
|
+
*
|
|
30
|
+
* @returns The current breakpoint: 'sm' | 'md' | 'lg'
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* function Component() {
|
|
35
|
+
* const breakpoint = useBreakpoint();
|
|
36
|
+
*
|
|
37
|
+
* return (
|
|
38
|
+
* <div>
|
|
39
|
+
* {breakpoint === 'sm' && <MobileLayout />}
|
|
40
|
+
* {breakpoint === 'md' && <TabletLayout />}
|
|
41
|
+
* {breakpoint === 'lg' && <DesktopLayout />}
|
|
42
|
+
* </div>
|
|
43
|
+
* );
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useBreakpoint(): Breakpoint {
|
|
48
|
+
const [breakpoint, setBreakpoint] = React.useState<Breakpoint>(() => {
|
|
49
|
+
if (typeof window === "undefined") return "sm";
|
|
50
|
+
return getCurrentBreakpoint(window.innerWidth);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
const handleResize = () => {
|
|
55
|
+
setBreakpoint(getCurrentBreakpoint(window.innerWidth));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Set initial value
|
|
59
|
+
handleResize();
|
|
60
|
+
|
|
61
|
+
window.addEventListener("resize", handleResize);
|
|
62
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
return breakpoint;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Hook to check if viewport is at or above a specific breakpoint.
|
|
70
|
+
*
|
|
71
|
+
* @param breakpoint - The minimum breakpoint to check against
|
|
72
|
+
* @returns boolean indicating if viewport is at or above the breakpoint
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```tsx
|
|
76
|
+
* function Component() {
|
|
77
|
+
* const isDesktop = useMinBreakpoint('lg');
|
|
78
|
+
* const isTabletUp = useMinBreakpoint('md');
|
|
79
|
+
*
|
|
80
|
+
* return isDesktop ? <DesktopView /> : <MobileView />;
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function useMinBreakpoint(breakpoint: Breakpoint): boolean {
|
|
85
|
+
const [matches, setMatches] = React.useState<boolean>(() => {
|
|
86
|
+
if (typeof window === "undefined") return false;
|
|
87
|
+
return window.innerWidth >= BREAKPOINTS[breakpoint];
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
const mediaQuery = window.matchMedia(
|
|
92
|
+
`(min-width: ${BREAKPOINTS[breakpoint]}px)`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
setMatches(mediaQuery.matches);
|
|
96
|
+
|
|
97
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
98
|
+
setMatches(event.matches);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
102
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
103
|
+
}, [breakpoint]);
|
|
104
|
+
|
|
105
|
+
return matches;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hook to check if viewport is below a specific breakpoint.
|
|
110
|
+
*
|
|
111
|
+
* @param breakpoint - The breakpoint to check against
|
|
112
|
+
* @returns boolean indicating if viewport is below the breakpoint
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```tsx
|
|
116
|
+
* function Component() {
|
|
117
|
+
* const isMobile = useMaxBreakpoint('md'); // Below tablet
|
|
118
|
+
*
|
|
119
|
+
* return isMobile ? <MobileNav /> : <DesktopNav />;
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function useMaxBreakpoint(breakpoint: Breakpoint): boolean {
|
|
124
|
+
const [matches, setMatches] = React.useState<boolean>(() => {
|
|
125
|
+
if (typeof window === "undefined") return false;
|
|
126
|
+
return window.innerWidth < BREAKPOINTS[breakpoint];
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
const mediaQuery = window.matchMedia(
|
|
131
|
+
`(max-width: ${BREAKPOINTS[breakpoint] - 1}px)`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
setMatches(mediaQuery.matches);
|
|
135
|
+
|
|
136
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
137
|
+
setMatches(event.matches);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
141
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
142
|
+
}, [breakpoint]);
|
|
143
|
+
|
|
144
|
+
return matches;
|
|
145
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Represents a single caption cue parsed from VTT.
|
|
7
|
+
*/
|
|
8
|
+
export interface CaptionCue {
|
|
9
|
+
/** Unique identifier for the cue */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Start time in seconds */
|
|
12
|
+
startTime: number;
|
|
13
|
+
/** End time in seconds */
|
|
14
|
+
endTime: number;
|
|
15
|
+
/** Caption text content */
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse VTT timestamp to seconds.
|
|
21
|
+
* Handles formats: HH:MM:SS.mmm or MM:SS.mmm
|
|
22
|
+
*/
|
|
23
|
+
function parseVttTimestamp(timestamp: string): number {
|
|
24
|
+
const parts = timestamp.trim().split(":");
|
|
25
|
+
let hours = 0;
|
|
26
|
+
let minutes = 0;
|
|
27
|
+
let seconds = 0;
|
|
28
|
+
|
|
29
|
+
if (parts.length === 3) {
|
|
30
|
+
hours = Number.parseInt(parts[0], 10);
|
|
31
|
+
minutes = Number.parseInt(parts[1], 10);
|
|
32
|
+
seconds = Number.parseFloat(parts[2]);
|
|
33
|
+
} else if (parts.length === 2) {
|
|
34
|
+
minutes = Number.parseInt(parts[0], 10);
|
|
35
|
+
seconds = Number.parseFloat(parts[1]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse VTT content string into an array of caption cues.
|
|
43
|
+
*/
|
|
44
|
+
function parseVtt(vttContent: string): CaptionCue[] {
|
|
45
|
+
const cues: CaptionCue[] = [];
|
|
46
|
+
const lines = vttContent.trim().split("\n");
|
|
47
|
+
|
|
48
|
+
let i = 0;
|
|
49
|
+
|
|
50
|
+
// Skip WEBVTT header and any metadata
|
|
51
|
+
while (i < lines.length && !lines[i].includes("-->")) {
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
while (i < lines.length) {
|
|
56
|
+
const line = lines[i].trim();
|
|
57
|
+
|
|
58
|
+
// Look for timestamp line (contains -->)
|
|
59
|
+
if (line.includes("-->")) {
|
|
60
|
+
const [startStr, endStr] = line.split("-->").map((s) => s.trim());
|
|
61
|
+
|
|
62
|
+
// Handle optional cue settings after timestamp
|
|
63
|
+
const endParts = endStr.split(" ");
|
|
64
|
+
const endTime = parseVttTimestamp(endParts[0]);
|
|
65
|
+
const startTime = parseVttTimestamp(startStr);
|
|
66
|
+
|
|
67
|
+
// Collect text lines until empty line or next timestamp
|
|
68
|
+
const textLines: string[] = [];
|
|
69
|
+
i++;
|
|
70
|
+
while (
|
|
71
|
+
i < lines.length &&
|
|
72
|
+
lines[i].trim() !== "" &&
|
|
73
|
+
!lines[i].includes("-->")
|
|
74
|
+
) {
|
|
75
|
+
// Skip cue identifier lines (numbers only)
|
|
76
|
+
if (!/^\d+$/.test(lines[i].trim())) {
|
|
77
|
+
textLines.push(lines[i].trim());
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (textLines.length > 0) {
|
|
83
|
+
cues.push({
|
|
84
|
+
id: `cue-${cues.length}`,
|
|
85
|
+
startTime,
|
|
86
|
+
endTime,
|
|
87
|
+
text: textLines.join("\n"),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return cues;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Strip VTT formatting tags from text.
|
|
100
|
+
* Removes <v>, <c>, <i>, <b>, <u>, etc.
|
|
101
|
+
*/
|
|
102
|
+
function stripVttTags(text: string): string {
|
|
103
|
+
return text
|
|
104
|
+
.replace(/<\/?[^>]+(>|$)/g, "") // Remove HTML-like tags
|
|
105
|
+
.replace(/ /g, " ")
|
|
106
|
+
.replace(/&/g, "&")
|
|
107
|
+
.replace(/</g, "<")
|
|
108
|
+
.replace(/>/g, ">")
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface UseCaptionsOptions {
|
|
113
|
+
/** VTT file URL to fetch */
|
|
114
|
+
src?: string;
|
|
115
|
+
/** Pre-loaded VTT content string */
|
|
116
|
+
content?: string;
|
|
117
|
+
/** Strip VTT formatting tags from caption text */
|
|
118
|
+
stripTags?: boolean;
|
|
119
|
+
/** Current playback time in seconds (alternative to setCurrentTime) */
|
|
120
|
+
currentTime?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface UseCaptionsReturn {
|
|
124
|
+
/** All parsed caption cues */
|
|
125
|
+
cues: CaptionCue[];
|
|
126
|
+
/** Currently active cue based on current time */
|
|
127
|
+
activeCue: CaptionCue | null;
|
|
128
|
+
/** Update the current playback time to get active cue */
|
|
129
|
+
setCurrentTime: (time: number) => void;
|
|
130
|
+
/** Loading state */
|
|
131
|
+
isLoading: boolean;
|
|
132
|
+
/** Error state */
|
|
133
|
+
error: Error | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hook for parsing VTT captions and tracking the active cue.
|
|
138
|
+
*
|
|
139
|
+
* @param options - Caption source options
|
|
140
|
+
* @returns Parsed cues, active cue, and state
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```tsx
|
|
144
|
+
* function VideoPlayer() {
|
|
145
|
+
* const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
146
|
+
* const { activeCue, setCurrentTime } = useCaptions({
|
|
147
|
+
* src: '/captions.vtt',
|
|
148
|
+
* });
|
|
149
|
+
*
|
|
150
|
+
* // Sync with video time
|
|
151
|
+
* React.useEffect(() => {
|
|
152
|
+
* const video = videoRef.current;
|
|
153
|
+
* if (!video) return;
|
|
154
|
+
*
|
|
155
|
+
* const handleTimeUpdate = () => setCurrentTime(video.currentTime);
|
|
156
|
+
* video.addEventListener('timeupdate', handleTimeUpdate);
|
|
157
|
+
* return () => video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
158
|
+
* }, [setCurrentTime]);
|
|
159
|
+
*
|
|
160
|
+
* return (
|
|
161
|
+
* <div>
|
|
162
|
+
* <video ref={videoRef} src="/video.mp4" />
|
|
163
|
+
* {activeCue && <div className="caption">{activeCue.text}</div>}
|
|
164
|
+
* </div>
|
|
165
|
+
* );
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export function useCaptions(
|
|
170
|
+
options: UseCaptionsOptions = {},
|
|
171
|
+
): UseCaptionsReturn {
|
|
172
|
+
const { src, content, stripTags = true, currentTime: externalTime } = options;
|
|
173
|
+
|
|
174
|
+
const [cues, setCues] = React.useState<CaptionCue[]>([]);
|
|
175
|
+
const [internalTime, setInternalTime] = React.useState(0);
|
|
176
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
177
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
178
|
+
|
|
179
|
+
// Use external time if provided, otherwise use internal state
|
|
180
|
+
const currentTime = externalTime ?? internalTime;
|
|
181
|
+
|
|
182
|
+
// Parse content or fetch from URL
|
|
183
|
+
React.useEffect(() => {
|
|
184
|
+
if (content) {
|
|
185
|
+
// Use provided content directly
|
|
186
|
+
const parsed = parseVtt(content);
|
|
187
|
+
setCues(
|
|
188
|
+
stripTags
|
|
189
|
+
? parsed.map((cue) => ({ ...cue, text: stripVttTags(cue.text) }))
|
|
190
|
+
: parsed,
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!src) {
|
|
196
|
+
setCues([]);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setIsLoading(true);
|
|
201
|
+
setError(null);
|
|
202
|
+
|
|
203
|
+
fetch(src)
|
|
204
|
+
.then((response) => {
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`Failed to fetch captions: ${response.status}`);
|
|
207
|
+
}
|
|
208
|
+
return response.text();
|
|
209
|
+
})
|
|
210
|
+
.then((vttContent) => {
|
|
211
|
+
const parsed = parseVtt(vttContent);
|
|
212
|
+
setCues(
|
|
213
|
+
stripTags
|
|
214
|
+
? parsed.map((cue) => ({ ...cue, text: stripVttTags(cue.text) }))
|
|
215
|
+
: parsed,
|
|
216
|
+
);
|
|
217
|
+
})
|
|
218
|
+
.catch((err) => {
|
|
219
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
220
|
+
})
|
|
221
|
+
.finally(() => {
|
|
222
|
+
setIsLoading(false);
|
|
223
|
+
});
|
|
224
|
+
}, [src, content, stripTags]);
|
|
225
|
+
|
|
226
|
+
// Find active cue for current time
|
|
227
|
+
const activeCue = React.useMemo(() => {
|
|
228
|
+
return (
|
|
229
|
+
cues.find(
|
|
230
|
+
(cue) => currentTime >= cue.startTime && currentTime <= cue.endTime,
|
|
231
|
+
) ?? null
|
|
232
|
+
);
|
|
233
|
+
}, [cues, currentTime]);
|
|
234
|
+
|
|
235
|
+
// Memoize setCurrentTime to avoid unnecessary re-renders
|
|
236
|
+
const handleSetCurrentTime = React.useCallback((time: number) => {
|
|
237
|
+
setInternalTime(time);
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
cues,
|
|
242
|
+
activeCue,
|
|
243
|
+
setCurrentTime: handleSetCurrentTime,
|
|
244
|
+
isLoading,
|
|
245
|
+
error,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface UseVideoKeyboardOptions {
|
|
8
|
+
/** Ref to the video element */
|
|
9
|
+
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
10
|
+
/** Whether keyboard handling is enabled (default: true) */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Seek amount in seconds for arrow keys (default: 5) */
|
|
13
|
+
seekAmount?: number;
|
|
14
|
+
/** Volume change amount for arrow keys (default: 0.1) */
|
|
15
|
+
volumeStep?: number;
|
|
16
|
+
/** Callback when play/pause is toggled */
|
|
17
|
+
onTogglePlay?: () => void;
|
|
18
|
+
/** Callback when fullscreen is toggled */
|
|
19
|
+
onToggleFullscreen?: () => void;
|
|
20
|
+
/** Callback when captions are toggled */
|
|
21
|
+
onToggleCaptions?: () => void;
|
|
22
|
+
/** Callback when controls should be shown */
|
|
23
|
+
onShowControls?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseVideoKeyboardReturn {
|
|
27
|
+
/** Key down handler to attach to container element */
|
|
28
|
+
handleKeyDown: (e: React.KeyboardEvent) => void;
|
|
29
|
+
/** Props to spread on the container element */
|
|
30
|
+
containerProps: {
|
|
31
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
32
|
+
tabIndex: number;
|
|
33
|
+
role: string;
|
|
34
|
+
"aria-label": string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Hook
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hook for handling keyboard shortcuts in a video player.
|
|
44
|
+
*
|
|
45
|
+
* Supported shortcuts:
|
|
46
|
+
* - Space: Play/pause
|
|
47
|
+
* - Left Arrow: Seek backward
|
|
48
|
+
* - Right Arrow: Seek forward
|
|
49
|
+
* - Up Arrow: Volume up
|
|
50
|
+
* - Down Arrow: Volume down
|
|
51
|
+
* - M: Toggle mute
|
|
52
|
+
* - F: Toggle fullscreen
|
|
53
|
+
* - C: Toggle captions
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* const { containerProps } = useVideoKeyboard({
|
|
58
|
+
* videoRef,
|
|
59
|
+
* onTogglePlay: () => video.paused ? video.play() : video.pause(),
|
|
60
|
+
* onToggleFullscreen: () => toggleFullscreen(),
|
|
61
|
+
* onShowControls: () => setControlsVisible(true),
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* return <div {...containerProps}>...</div>;
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useVideoKeyboard({
|
|
68
|
+
videoRef,
|
|
69
|
+
enabled = true,
|
|
70
|
+
seekAmount = 5,
|
|
71
|
+
volumeStep = 0.1,
|
|
72
|
+
onTogglePlay,
|
|
73
|
+
onToggleFullscreen,
|
|
74
|
+
onToggleCaptions,
|
|
75
|
+
onShowControls,
|
|
76
|
+
}: UseVideoKeyboardOptions): UseVideoKeyboardReturn {
|
|
77
|
+
const handleKeyDown = React.useCallback(
|
|
78
|
+
(e: React.KeyboardEvent) => {
|
|
79
|
+
if (!enabled) return;
|
|
80
|
+
|
|
81
|
+
const video = videoRef.current;
|
|
82
|
+
if (!video) return;
|
|
83
|
+
|
|
84
|
+
// Don't handle if focus is on interactive elements
|
|
85
|
+
const target = e.target as HTMLElement;
|
|
86
|
+
if (
|
|
87
|
+
target.tagName === "BUTTON" ||
|
|
88
|
+
target.tagName === "INPUT" ||
|
|
89
|
+
target.tagName === "TEXTAREA" ||
|
|
90
|
+
target.closest("button") ||
|
|
91
|
+
target.closest("input") ||
|
|
92
|
+
target.closest("[role='slider']")
|
|
93
|
+
) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let handled = false;
|
|
98
|
+
|
|
99
|
+
switch (e.key) {
|
|
100
|
+
// Play/Pause
|
|
101
|
+
case " ":
|
|
102
|
+
case "Spacebar":
|
|
103
|
+
case "k": // YouTube-style shortcut
|
|
104
|
+
if (onTogglePlay) {
|
|
105
|
+
onTogglePlay();
|
|
106
|
+
} else {
|
|
107
|
+
if (video.paused) {
|
|
108
|
+
video.play().catch(() => {});
|
|
109
|
+
} else {
|
|
110
|
+
video.pause();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
handled = true;
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
// Seek backward
|
|
117
|
+
case "ArrowLeft":
|
|
118
|
+
case "j": // YouTube-style shortcut
|
|
119
|
+
video.currentTime = Math.max(0, video.currentTime - seekAmount);
|
|
120
|
+
handled = true;
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
// Seek forward
|
|
124
|
+
case "ArrowRight":
|
|
125
|
+
case "l": // YouTube-style shortcut
|
|
126
|
+
video.currentTime = Math.min(
|
|
127
|
+
video.duration || 0,
|
|
128
|
+
video.currentTime + seekAmount,
|
|
129
|
+
);
|
|
130
|
+
handled = true;
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
// Volume up
|
|
134
|
+
case "ArrowUp":
|
|
135
|
+
video.volume = Math.min(1, video.volume + volumeStep);
|
|
136
|
+
video.muted = false;
|
|
137
|
+
handled = true;
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
// Volume down
|
|
141
|
+
case "ArrowDown":
|
|
142
|
+
video.volume = Math.max(0, video.volume - volumeStep);
|
|
143
|
+
handled = true;
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
// Toggle mute
|
|
147
|
+
case "m":
|
|
148
|
+
case "M":
|
|
149
|
+
video.muted = !video.muted;
|
|
150
|
+
handled = true;
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
// Toggle fullscreen
|
|
154
|
+
case "f":
|
|
155
|
+
case "F":
|
|
156
|
+
onToggleFullscreen?.();
|
|
157
|
+
handled = true;
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
// Toggle captions
|
|
161
|
+
case "c":
|
|
162
|
+
case "C":
|
|
163
|
+
onToggleCaptions?.();
|
|
164
|
+
handled = true;
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
// Jump to start
|
|
168
|
+
case "Home":
|
|
169
|
+
video.currentTime = 0;
|
|
170
|
+
handled = true;
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
// Jump to end
|
|
174
|
+
case "End":
|
|
175
|
+
video.currentTime = video.duration || 0;
|
|
176
|
+
handled = true;
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
// Number keys for percentage seeking (0-9)
|
|
180
|
+
case "0":
|
|
181
|
+
case "1":
|
|
182
|
+
case "2":
|
|
183
|
+
case "3":
|
|
184
|
+
case "4":
|
|
185
|
+
case "5":
|
|
186
|
+
case "6":
|
|
187
|
+
case "7":
|
|
188
|
+
case "8":
|
|
189
|
+
case "9":
|
|
190
|
+
if (video.duration) {
|
|
191
|
+
const percent = parseInt(e.key, 10) / 10;
|
|
192
|
+
video.currentTime = video.duration * percent;
|
|
193
|
+
handled = true;
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (handled) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
onShowControls?.();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[
|
|
205
|
+
enabled,
|
|
206
|
+
videoRef,
|
|
207
|
+
seekAmount,
|
|
208
|
+
volumeStep,
|
|
209
|
+
onTogglePlay,
|
|
210
|
+
onToggleFullscreen,
|
|
211
|
+
onToggleCaptions,
|
|
212
|
+
onShowControls,
|
|
213
|
+
],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const containerProps = React.useMemo(
|
|
217
|
+
() => ({
|
|
218
|
+
onKeyDown: handleKeyDown,
|
|
219
|
+
tabIndex: 0,
|
|
220
|
+
role: "application" as const,
|
|
221
|
+
"aria-label": "Video player, press space to play or pause",
|
|
222
|
+
}),
|
|
223
|
+
[handleKeyDown],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
handleKeyDown,
|
|
228
|
+
containerProps,
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/lib/utils.ts
CHANGED