feat(skin): add captions button to video skins (#612)

This commit is contained in:
Sam Potts
2026-02-26 11:09:22 +11:00
committed by GitHub
parent cb09079758
commit fbf888fda3
13 changed files with 195 additions and 14 deletions
+8 -8
View File
@@ -25,19 +25,16 @@ const SVGO_CONFIG: Config = {
params: {
overrides: {
removeViewBox: false,
convertColors: {
currentColor: /^black$/,
},
},
},
},
{
name: 'removeAttrs',
params: {
attrs: ['^fill$', '^stroke$', '^clip-rule$', '^fill-rule$'],
},
},
{
name: 'addAttributesToSVGElement',
params: {
attributes: [{ fill: 'currentColor' }],
attrs: ['^clip-rule$', '^fill-rule$'],
},
},
],
@@ -64,7 +61,10 @@ function getSvgFiles(setName: string): string[] {
}
function optimizeSvg(svgContent: string): string {
return optimize(svgContent, SVGO_CONFIG).data;
const optimized = optimize(svgContent, SVGO_CONFIG).data;
return optimized
.replaceAll('fill="black"', 'fill="currentColor"')
.replaceAll('stroke="black"', 'stroke="currentColor"');
}
async function buildReactComponent(svgContent: string, componentName: string): Promise<{ js: string; tsx: string }> {
@@ -0,0 +1,8 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="16" height="12" rx="3" stroke="black" stroke-width="2"/>
<rect x="3" y="8" width="3" height="2" rx="1" fill="black"/>
<rect x="13" y="8" width="2" height="2" rx="1" fill="black"/>
<rect x="11" y="11" width="4" height="2" rx="1" fill="black"/>
<rect x="7" y="8" width="5" height="2" rx="1" fill="black"/>
<rect x="3" y="11" width="7" height="2" rx="1" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 2C16.6569 2 18 3.34315 18 5V13C18 14.6569 16.6569 16 15 16H3C1.34315 16 1.61067e-08 14.6569 0 13V5C1.28853e-07 3.34315 1.34315 2 3 2H15ZM4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H9C9.55228 13 10 12.5523 10 12C10 11.4477 9.55228 11 9 11H4ZM12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13H14C14.5523 13 15 12.5523 15 12C15 11.4477 14.5523 11 14 11H12ZM4 8C3.44772 8 3 8.44772 3 9C3 9.55228 3.44772 10 4 10H5C5.55228 10 6 9.55228 6 9C6 8.44772 5.55228 8 5 8H4ZM8 8C7.44772 8 7 8.44772 7 9C7 9.55228 7.44772 10 8 10H11C11.5523 10 12 9.55228 12 9C12 8.44772 11.5523 8 11 8H8ZM14 8C13.4477 8 13 8.44772 13 9C13 9.55228 13.4477 10 14 10C14.5523 10 15 9.55228 15 9C15 8.44772 14.5523 8 14 8Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

@@ -0,0 +1,8 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.75" y="2.75" width="16.5" height="12.5" rx="3" stroke="black" stroke-width="1.5"/>
<rect x="3" y="8.5" width="3" height="1.5" rx="0.75" fill="black"/>
<rect x="13" y="8.5" width="2" height="1.5" rx="0.75" fill="black"/>
<rect x="11" y="11.5" width="4" height="1.5" rx="0.75" fill="black"/>
<rect x="7" y="8.5" width="5" height="1.5" rx="0.75" fill="black"/>
<rect x="3" y="11.5" width="7" height="1.5" rx="0.75" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 2C16.6569 2 18 3.34315 18 5V13C18 14.6569 16.6569 16 15 16H3C1.34315 16 1.61067e-08 14.6569 0 13V5C1.28853e-07 3.34315 1.34315 2 3 2H15ZM3.75 11.5C3.33579 11.5 3 11.8358 3 12.25C3 12.6642 3.33579 13 3.75 13H9.25C9.66421 13 10 12.6642 10 12.25C10 11.8358 9.66421 11.5 9.25 11.5H3.75ZM11.75 11.5C11.3358 11.5 11 11.8358 11 12.25C11 12.6642 11.3358 13 11.75 13H14.25C14.6642 13 15 12.6642 15 12.25C15 11.8358 14.6642 11.5 14.25 11.5H11.75ZM3.75 8.5C3.33579 8.5 3 8.83579 3 9.25C3 9.66421 3.33579 10 3.75 10H5.25C5.66421 10 6 9.66421 6 9.25C6 8.83579 5.66421 8.5 5.25 8.5H3.75ZM7.75 8.5C7.33579 8.5 7 8.83579 7 9.25C7 9.66421 7.33579 10 7.75 10H11.25C11.6642 10 12 9.66421 12 9.25C12 8.83579 11.6642 8.5 11.25 8.5H7.75ZM13.75 8.5C13.3358 8.5 13 8.83579 13 9.25C13 9.66421 13.3358 10 13.75 10H14.25C14.6642 10 15 9.66421 15 9.25C15 8.83579 14.6642 8.5 14.25 8.5H13.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

@@ -103,7 +103,7 @@
inset: 0;
border-radius: inherit;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.7), oklch(0 0 0 / 0.5) 7.5rem, oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.5) brightness(0.9);
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
opacity: 0;
transition-property: opacity, backdrop-filter;
transition-duration: 500ms;
@@ -123,7 +123,7 @@
transition-delay: 0ms;
}
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.5) brightness(0.9);
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
}
/* ==========================================================================
@@ -1,5 +1,7 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
@@ -16,6 +18,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -98,6 +101,16 @@ function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
@@ -278,6 +291,14 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
)}
/>
<CaptionsButton
render={(props, state) => (
<Button variant="icon" {...props}>
<CaptionsButtonIcon state={state} className={icon} />
</Button>
)}
/>
<PiPButton
render={(props) => (
<Button variant="icon" {...props}>
@@ -330,7 +351,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
'backdrop-blur-[0px] backdrop-saturate-150 backdrop-brightness-90',
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
// Transitions
'transition-[opacity,backdrop-filter] ease-out',
'duration-500 delay-500',
@@ -1,5 +1,7 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
@@ -16,6 +18,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -56,6 +59,16 @@ function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
@@ -160,6 +173,14 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
)}
/>
<CaptionsButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<CaptionsButtonIcon state={state} className="media-icon" />
</Button>
)}
/>
<PiPButton
render={(props) => (
<Button {...props} className="media-button--icon">
+2 -2
View File
@@ -106,7 +106,7 @@
inset: 0;
border-radius: inherit;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.5), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.5) brightness(0.9);
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
opacity: 0;
transition-property: opacity, backdrop-filter;
transition-duration: 300ms;
@@ -126,7 +126,7 @@
transition-delay: 0ms;
}
.media-default-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.5) brightness(0.9);
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
}
/* ==========================================================================
@@ -1,5 +1,7 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
@@ -16,6 +18,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -112,6 +115,16 @@ function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
@@ -277,6 +290,14 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
</Time.Group>
<CaptionsButton
render={(props, state) => (
<Button variant="icon" {...props}>
<CaptionsButtonIcon state={state} className={icon} />
</Button>
)}
/>
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
@@ -336,7 +357,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/50 via-black/30 to-transparent',
'backdrop-blur-[0px] backdrop-saturate-150 backdrop-brightness-90',
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
// Transitions
'transition-[opacity,backdrop-filter] ease-out',
'duration-300 delay-500',
+21
View File
@@ -1,5 +1,7 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
@@ -16,6 +18,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -56,6 +59,16 @@ function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
@@ -155,6 +168,14 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
)}
/>
<CaptionsButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<CaptionsButtonIcon state={state} className="media-icon" />
</Button>
)}
/>
<PiPButton
render={(props) => (
<Button {...props} className="media-button--icon">
@@ -0,0 +1,74 @@
'use client';
import type { StateAttrMap } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useButton } from '../hooks/use-button';
// FIXME: Replace with state/props from core.
export type CaptionsButtonState = {
active: boolean;
};
type CaptionButtonCoreProps = {
/** Custom label for the button. */
label?: string | ((state: CaptionsButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
};
const CaptionButtonDataAttrs = {
/** Present when the captions are active. */
active: 'data-active',
} as const satisfies StateAttrMap<CaptionsButtonState>;
export interface CaptionsButtonProps extends UIComponentProps<'button', CaptionsButtonState>, CaptionButtonCoreProps {}
/**
* A button that toggles captions.
*
* @example
* ```tsx
* <CaptionsButton />
*
* <CaptionsButton
* render={(props, state) => (
* <button {...props}>
* {state.active ? <CaptionsOnIcon /> : <CaptionsOffIcon />}
* </button>
* )}
* />
* ```
*/
export const CaptionsButton = forwardRef(function CaptionsButton(
componentProps: CaptionsButtonProps,
forwardedRef: ForwardedRef<HTMLButtonElement>
) {
const { render, className, style, label, disabled, ...elementProps } = componentProps;
// FIXME: Replace with actual captions state
const [isActive, setIsActive] = useState(false);
const { getButtonProps, buttonRef } = useButton({
displayName: 'CaptionsButton',
onActivate: () => setIsActive((active) => !active), // FIXME: Replace with actual toggle logic
isDisabled: () => disabled ?? false,
});
return renderElement(
'button',
{ render, className, style },
{
state: { active: isActive }, // FIXME: Replace with actual toggle logic
stateAttrMap: CaptionButtonDataAttrs,
ref: [forwardedRef, buttonRef],
props: [elementProps, getButtonProps(), { 'aria-pressed': isActive }],
}
);
});
export namespace CaptionsButton {
export type Props = CaptionsButtonProps;
export type State = CaptionsButtonState;
}
@@ -0,0 +1 @@
export { CaptionsButton, type CaptionsButtonProps, type CaptionsButtonState } from './captions-button';