@nationaldesignstudio/react 0.5.5 → 0.6.0
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 +1858 -139
- package/dist/index.js +2388 -326
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +680 -0
- package/package.json +22 -2
- 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/components/organisms/card/card.tsx +87 -12
- package/src/components/sections/hero/hero.tsx +35 -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 +2 -2
- package/src/theme/index.ts +4 -0
- package/src/theme/theme-provider.tsx +48 -8
|
@@ -12,6 +12,16 @@ const cardVariants = tv({
|
|
|
12
12
|
* Use with Background components for images/gradients.
|
|
13
13
|
*/
|
|
14
14
|
overlay: "w-full flex-col",
|
|
15
|
+
/**
|
|
16
|
+
* Profile layout - square image with stacked content below.
|
|
17
|
+
* Ideal for team member cards, user profiles, testimonials.
|
|
18
|
+
*/
|
|
19
|
+
profile: "w-full flex-col",
|
|
20
|
+
/**
|
|
21
|
+
* Compact layout - small thumbnail with condensed horizontal content.
|
|
22
|
+
* Ideal for news items, article previews, resource lists.
|
|
23
|
+
*/
|
|
24
|
+
compact: "w-full flex-row items-center gap-16",
|
|
15
25
|
},
|
|
16
26
|
},
|
|
17
27
|
defaultVariants: {
|
|
@@ -48,16 +58,26 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
|
48
58
|
Card.displayName = "Card";
|
|
49
59
|
|
|
50
60
|
const cardImageVariants = tv({
|
|
51
|
-
base:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
base: "relative shrink-0 bg-bg-muted",
|
|
62
|
+
variants: {
|
|
63
|
+
layout: {
|
|
64
|
+
vertical: "aspect-video w-full",
|
|
65
|
+
horizontal: "aspect-auto w-2/5 self-stretch",
|
|
66
|
+
overlay: "aspect-video w-full",
|
|
67
|
+
/** Profile: square aspect ratio for headshots/avatars */
|
|
68
|
+
profile: "aspect-square w-full",
|
|
69
|
+
/** Compact: fixed small size for thumbnails */
|
|
70
|
+
compact: "size-80 rounded-8",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
defaultVariants: {
|
|
74
|
+
layout: "vertical",
|
|
75
|
+
},
|
|
58
76
|
});
|
|
59
77
|
|
|
60
|
-
export interface CardImageProps
|
|
78
|
+
export interface CardImageProps
|
|
79
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
80
|
+
VariantProps<typeof cardImageVariants> {
|
|
61
81
|
/**
|
|
62
82
|
* The image source URL
|
|
63
83
|
*/
|
|
@@ -69,15 +89,18 @@ export interface CardImageProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
/**
|
|
72
|
-
* Card image area
|
|
73
|
-
*
|
|
92
|
+
* Card image area with layout-specific styling.
|
|
93
|
+
* - vertical: 16:9 aspect ratio, full width
|
|
94
|
+
* - horizontal: ~40% width, stretches to content height
|
|
95
|
+
* - profile: square aspect ratio for headshots/avatars
|
|
96
|
+
* - compact: fixed small size for thumbnails
|
|
74
97
|
*/
|
|
75
98
|
const CardImage = React.forwardRef<HTMLDivElement, CardImageProps>(
|
|
76
|
-
({ className, src, alt = "", ...props }, ref) => {
|
|
99
|
+
({ className, src, alt = "", layout, ...props }, ref) => {
|
|
77
100
|
return (
|
|
78
101
|
<div
|
|
79
102
|
ref={ref}
|
|
80
|
-
className={cardImageVariants({ class: className })}
|
|
103
|
+
className={cardImageVariants({ layout, class: className })}
|
|
81
104
|
{...props}
|
|
82
105
|
>
|
|
83
106
|
{src && (
|
|
@@ -263,6 +286,56 @@ const CardActions = React.forwardRef<HTMLDivElement, CardActionsProps>(
|
|
|
263
286
|
);
|
|
264
287
|
CardActions.displayName = "CardActions";
|
|
265
288
|
|
|
289
|
+
const cardLinkVariants = tv({
|
|
290
|
+
base: "group/link flex items-center gap-4 text-text-muted transition-colors duration-200 hover:text-text-primary",
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
export interface CardLinkProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
294
|
+
/**
|
|
295
|
+
* Whether to show the arrow indicator
|
|
296
|
+
* @default true
|
|
297
|
+
*/
|
|
298
|
+
showArrow?: boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Inline link element for cards with optional animated arrow.
|
|
303
|
+
* Commonly used for "Read More →" or "Learn More →" patterns.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```tsx
|
|
307
|
+
* <Card layout="compact">
|
|
308
|
+
* <CardImage src="/thumb.jpg" layout="compact" />
|
|
309
|
+
* <CardContent>
|
|
310
|
+
* <CardTitle>Article Title</CardTitle>
|
|
311
|
+
* <CardLink>Read More</CardLink>
|
|
312
|
+
* </CardContent>
|
|
313
|
+
* </Card>
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
const CardLink = React.forwardRef<HTMLSpanElement, CardLinkProps>(
|
|
317
|
+
({ className, children, showArrow = true, ...props }, ref) => {
|
|
318
|
+
return (
|
|
319
|
+
<span
|
|
320
|
+
ref={ref}
|
|
321
|
+
className={cardLinkVariants({ class: className })}
|
|
322
|
+
{...props}
|
|
323
|
+
>
|
|
324
|
+
<span>{children}</span>
|
|
325
|
+
{showArrow && (
|
|
326
|
+
<span
|
|
327
|
+
aria-hidden="true"
|
|
328
|
+
className="inline-block transition-transform duration-200 ease-out group-hover/link:translate-x-4"
|
|
329
|
+
>
|
|
330
|
+
→
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
</span>
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
CardLink.displayName = "CardLink";
|
|
338
|
+
|
|
266
339
|
export {
|
|
267
340
|
Card,
|
|
268
341
|
cardVariants,
|
|
@@ -280,4 +353,6 @@ export {
|
|
|
280
353
|
cardBodyVariants,
|
|
281
354
|
CardActions,
|
|
282
355
|
cardActionsVariants,
|
|
356
|
+
CardLink,
|
|
357
|
+
cardLinkVariants,
|
|
283
358
|
};
|
|
@@ -30,6 +30,7 @@ const heroVariants = tv({
|
|
|
30
30
|
"md:p-56",
|
|
31
31
|
],
|
|
32
32
|
title: DEFAULT_TITLE_TYPOGRAPHY,
|
|
33
|
+
indicator: "absolute inset-x-0 bottom-0 z-10 flex justify-center pb-24",
|
|
33
34
|
},
|
|
34
35
|
variants: {
|
|
35
36
|
variant: {
|
|
@@ -46,6 +47,21 @@ const heroVariants = tv({
|
|
|
46
47
|
content: ["items-center justify-center", "lg:p-64"],
|
|
47
48
|
},
|
|
48
49
|
},
|
|
50
|
+
/**
|
|
51
|
+
* Vertical alignment of content within the hero.
|
|
52
|
+
* Provides a simpler API than variant for basic alignment needs.
|
|
53
|
+
*/
|
|
54
|
+
contentAlign: {
|
|
55
|
+
top: {
|
|
56
|
+
content: "justify-start",
|
|
57
|
+
},
|
|
58
|
+
center: {
|
|
59
|
+
content: "items-center justify-center",
|
|
60
|
+
},
|
|
61
|
+
bottom: {
|
|
62
|
+
content: "justify-end",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
49
65
|
colorScheme: {
|
|
50
66
|
dark: {
|
|
51
67
|
root: "bg-bg-page",
|
|
@@ -144,11 +160,24 @@ export interface HeroProps
|
|
|
144
160
|
* - light: Light text for use on dark backgrounds
|
|
145
161
|
*/
|
|
146
162
|
colorScheme?: "dark" | "light";
|
|
163
|
+
// Note: contentAlign is provided by VariantProps<typeof heroVariants>
|
|
164
|
+
// Options: "top" | "center" | "bottom"
|
|
147
165
|
/**
|
|
148
166
|
* Content for the top slot (full-width, no padding).
|
|
149
167
|
* Use for USGovBanner, Navigation, etc.
|
|
150
168
|
*/
|
|
151
169
|
top?: React.ReactNode;
|
|
170
|
+
/**
|
|
171
|
+
* Indicator slot for scroll hints, arrows, or other visual cues.
|
|
172
|
+
* Rendered at the bottom of the hero, below the main content.
|
|
173
|
+
* @example
|
|
174
|
+
* ```tsx
|
|
175
|
+
* <Hero indicator={<ChevronDown className="animate-bounce" />}>
|
|
176
|
+
* <h1>Welcome</h1>
|
|
177
|
+
* </Hero>
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
indicator?: React.ReactNode;
|
|
152
181
|
/**
|
|
153
182
|
* Background for the hero. Can be:
|
|
154
183
|
* - A color string (hex, rgb, etc.) for solid backgrounds
|
|
@@ -235,7 +264,9 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
|
|
|
235
264
|
title,
|
|
236
265
|
titleClassName,
|
|
237
266
|
colorScheme = "dark",
|
|
267
|
+
contentAlign,
|
|
238
268
|
top,
|
|
269
|
+
indicator,
|
|
239
270
|
variant,
|
|
240
271
|
background,
|
|
241
272
|
overlayOpacity = 0,
|
|
@@ -251,6 +282,7 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
|
|
|
251
282
|
const hasMediaBackground = background && !isColor;
|
|
252
283
|
const styles = heroVariants({
|
|
253
284
|
variant,
|
|
285
|
+
contentAlign,
|
|
254
286
|
colorScheme,
|
|
255
287
|
hasBackground: !!background,
|
|
256
288
|
});
|
|
@@ -295,6 +327,9 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
|
|
|
295
327
|
{/* Children - always render if provided */}
|
|
296
328
|
{children}
|
|
297
329
|
</div>
|
|
330
|
+
|
|
331
|
+
{/* Indicator slot - scroll hints, arrows, etc. */}
|
|
332
|
+
{indicator && <div className={styles.indicator()}>{indicator}</div>}
|
|
298
333
|
</section>
|
|
299
334
|
);
|
|
300
335
|
},
|
package/src/hooks/index.ts
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
|
+
// Utility hooks
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
BREAKPOINTS,
|
|
5
|
+
type Breakpoint,
|
|
6
|
+
useBreakpoint,
|
|
7
|
+
useMaxBreakpoint,
|
|
8
|
+
useMinBreakpoint,
|
|
9
|
+
} from "./use-breakpoint";
|
|
10
|
+
// Video player hooks
|
|
11
|
+
export { type CaptionCue, useCaptions } from "./use-captions";
|
|
1
12
|
export { useEventListener } from "./use-event-listener";
|
|
13
|
+
export {
|
|
14
|
+
type UseVideoKeyboardOptions,
|
|
15
|
+
type UseVideoKeyboardReturn,
|
|
16
|
+
useVideoKeyboard,
|
|
17
|
+
} from "./use-video-keyboard";
|
|
@@ -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
|
+
}
|