mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
feat(skin): add captions button to video skins (#612)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user