mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add Tailwind ejected video skins (#589)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
204af9a4aa
commit
8763a57360
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": false
|
||||
},
|
||||
|
||||
+113
-2
@@ -1,5 +1,116 @@
|
||||
# @videojs/icons
|
||||
|
||||
**Note:** This package is currently a placeholder and will be populated closer to Beta release.
|
||||
SVG icon library for Video.js. Ships optimized icons as **React components** and **HTML strings**, organized into icon sets.
|
||||
|
||||
For now, custom icons are bundled directly with skins.
|
||||
## Icon Sets
|
||||
|
||||
Icons are grouped into visual sets. Each set contains the same icon names with different designs.
|
||||
|
||||
| Set | Description |
|
||||
| --- | --- |
|
||||
| `default` | Standard icon set used by the default skin |
|
||||
| `minimal` | Simplified icon set used by the minimal skin |
|
||||
|
||||
### Available Icons
|
||||
|
||||
| Icon | React Export | HTML Export |
|
||||
| --- | --- | --- |
|
||||
| `fullscreen-enter` | `FullscreenEnterIcon` | `fullscreenEnterIcon` |
|
||||
| `fullscreen-exit` | `FullscreenExitIcon` | `fullscreenExitIcon` |
|
||||
| `pause` | `PauseIcon` | `pauseIcon` |
|
||||
| `pip` | `PipIcon` | `pipIcon` |
|
||||
| `play` | `PlayIcon` | `playIcon` |
|
||||
| `restart` | `RestartIcon` | `restartIcon` |
|
||||
| `seek` | `SeekIcon` | `seekIcon` |
|
||||
| `spinner` | `SpinnerIcon` | `spinnerIcon` |
|
||||
| `volume-high` | `VolumeHighIcon` | `volumeHighIcon` |
|
||||
| `volume-low` | `VolumeLowIcon` | `volumeLowIcon` |
|
||||
| `volume-off` | `VolumeOffIcon` | `volumeOffIcon` |
|
||||
|
||||
## Usage
|
||||
|
||||
### React
|
||||
|
||||
Import icons as React components. They accept standard SVG props and support ref forwarding.
|
||||
|
||||
```tsx
|
||||
import { PlayIcon, PauseIcon } from '@videojs/icons/react';
|
||||
|
||||
function Controls() {
|
||||
return (
|
||||
<button>
|
||||
<PlayIcon className="icon" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Import from a specific icon set:
|
||||
|
||||
```tsx
|
||||
import { PlayIcon } from '@videojs/icons/react/minimal';
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
Import icons as SVG strings for use in plain HTML or web components.
|
||||
|
||||
```ts
|
||||
import { playIcon, pauseIcon } from '@videojs/icons/html';
|
||||
|
||||
button.innerHTML = playIcon;
|
||||
```
|
||||
|
||||
Import from a specific icon set:
|
||||
|
||||
```ts
|
||||
import { playIcon } from '@videojs/icons/html/minimal';
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
All icons use `fill="currentColor"`, so they inherit the text color of their parent element. Size and color can be controlled with CSS:
|
||||
|
||||
```css
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
Icons are designed on an **18×18 grid**. Rendering at `18px` (or exact multiples like `36px`) produces the crispest result. Other sizes may introduce sub-pixel rendering artifacts.
|
||||
|
||||
## Adding Icons
|
||||
|
||||
1. Add an SVG file to `src/assets/<set>/` (e.g., `src/assets/default/my-icon.svg`).
|
||||
2. Run `pnpm -F @videojs/icons build`.
|
||||
3. The build script optimizes the SVG with SVGO and generates React components and HTML string exports.
|
||||
|
||||
The build automatically:
|
||||
|
||||
- Removes hardcoded `fill`, `stroke`, `clip-rule`, and `fill-rule` attributes.
|
||||
- Adds `fill="currentColor"` for dynamic styling.
|
||||
- Preserves the `viewBox` attribute.
|
||||
- Generates `.js`, `.d.ts`, and `.tsx` files for each icon.
|
||||
|
||||
## Adding an Icon Set
|
||||
|
||||
Create a new directory under `src/assets/` (e.g., `src/assets/custom/`), add SVG files, and rebuild. The set is automatically available via:
|
||||
|
||||
```ts
|
||||
import { PlayIcon } from '@videojs/icons/react/custom';
|
||||
import { playIcon } from '@videojs/icons/html/custom';
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Running `pnpm -F @videojs/icons dev` starts the build in watch mode. Any changes to `.svg` files under `src/assets/` trigger an automatic rebuild, including new icons and new icon sets — no restart required.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
pnpm -F @videojs/icons build # Build all icon sets
|
||||
pnpm -F @videojs/icons dev # Build and watch for changes
|
||||
pnpm -F @videojs/icons clean # Remove build output
|
||||
```
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { Video, type VideoProps } from '@/media/video';
|
||||
export * from './minimal-skin';
|
||||
export * from './minimal-skin.tailwind';
|
||||
export * from './skin';
|
||||
export * from './skin.tailwind';
|
||||
|
||||
@@ -70,27 +70,29 @@
|
||||
.media-minimal-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: start;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0));
|
||||
transition-property: opacity;
|
||||
transition-duration: 75ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
backdrop-filter: saturate(1.5) brightness(0.9);
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 500ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: opacity, display;
|
||||
transition-timing-function: ease-out;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
.media-minimal-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay {
|
||||
transition-duration: 50ms;
|
||||
transition-delay: 0ms;
|
||||
opacity: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Media Control Bar UI/Styles */
|
||||
.media-minimal-skin .media-controls {
|
||||
@@ -126,7 +128,7 @@
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 640px) {
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-controls {
|
||||
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
|
||||
gap: 0.875rem;
|
||||
@@ -174,7 +176,7 @@
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
}
|
||||
@container media-root (width > 42rem) {
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-button-group {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
@@ -452,12 +454,12 @@
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 2rem;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PipIcon,
|
||||
PlayIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react/minimal';
|
||||
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 { Controls } from '@/ui/controls';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { MinimalVideoSkinProps } from './minimal-skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
/* ------------------------------------ Reused fragments ------------------------------------- */
|
||||
|
||||
const icon = cn(
|
||||
'[grid-area:1/1] size-4.5',
|
||||
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
const iconHidden = 'hidden opacity-0';
|
||||
|
||||
/* --------------------------------------- Components ---------------------------------------- */
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
// Layout
|
||||
'grid shrink-0 p-2.5 cursor-pointer bg-transparent border-none rounded-md',
|
||||
'text-white select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
// Transitions
|
||||
'transition-[background-color,color,outline-offset] duration-150 ease-out',
|
||||
// Hover / focus / expanded
|
||||
'hover:text-white/80 hover:no-underline',
|
||||
'focus-visible:text-white/80',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
'aria-expanded:text-white/80',
|
||||
// Disabled
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { [iconHidden]: !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { [iconHidden]: ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { [iconHidden]: paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { [iconHidden]: !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Skin ------------------------------------------- */
|
||||
|
||||
export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={cn(
|
||||
// Layout & containment
|
||||
'relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-[var(--media-border-radius,0.75rem)] bg-black',
|
||||
'font-sans text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Box-sizing reset for children
|
||||
'**:box-border',
|
||||
// Outer border ring (::after only)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/15',
|
||||
'dark:after:ring-white/15',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-[250ms]',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div
|
||||
{...props}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 text-white"
|
||||
>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Controls.Root
|
||||
className={cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-0 inset-x-0',
|
||||
'pt-8 px-1.5 pb-1.5 flex items-center gap-2',
|
||||
'text-white z-10',
|
||||
// Transitions
|
||||
'will-change-[translate,filter,opacity]',
|
||||
'transition-[translate,filter,opacity] ease-out',
|
||||
'delay-0 duration-75',
|
||||
// Hidden state
|
||||
'not-data-[visible]:opacity-0 not-data-[visible]:translate-y-full',
|
||||
'not-data-[visible]:blur-[8px] not-data-[visible]:pointer-events-none',
|
||||
'not-data-[visible]:delay-500 not-data-[visible]:duration-500',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-[visible]:duration-100',
|
||||
'motion-reduce:not-data-[visible]:translate-y-0',
|
||||
'motion-reduce:not-data-[visible]:blur-none motion-reduce:not-data-[visible]:scale-100',
|
||||
// Wider container
|
||||
'@sm/media-root:pt-10 @sm/media-root:px-3 @sm/media-root:pb-3',
|
||||
'@sm/media-root:gap-3.5'
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<PlayButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
|
||||
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={icon} />
|
||||
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className={cn('flex flex-row-reverse items-center flex-1 gap-3', '@md/media-controls:flex-row')}>
|
||||
<Time.Group className="flex items-center gap-1">
|
||||
<Time.Value
|
||||
type="current"
|
||||
className={cn(
|
||||
'hidden tabular-nums drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
|
||||
'@md/media-controls:inline'
|
||||
)}
|
||||
/>
|
||||
<Time.Separator className={cn('hidden', '@md/media-controls:inline @md/media-controls:text-white/50')} />
|
||||
<Time.Value
|
||||
type="duration"
|
||||
className={cn(
|
||||
'tabular-nums drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
|
||||
'@md/media-controls:text-white/50'
|
||||
)}
|
||||
/>
|
||||
</Time.Group>
|
||||
|
||||
{/* Temporary spacer */}
|
||||
<span className="flex-1 h-[3px] rounded-full bg-white/20" />
|
||||
</span>
|
||||
|
||||
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PiPButton
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<PipIcon className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<FullscreenButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Controls.Root>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-[visible]/controls:-translate-y-10 peer-data-[visible]/controls:delay-25',
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[42ch] mx-auto text-center flex flex-col items-center">
|
||||
<span className={cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline',
|
||||
)}>
|
||||
An example cue
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Gradient overlay (heavier gradient with positioned stop)
|
||||
'bg-gradient-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
|
||||
// Transitions
|
||||
'transition-opacity ease-out duration-75 delay-0',
|
||||
// Hidden when controls hidden (peer sibling)
|
||||
'peer-not-data-[visible]/controls:opacity-0',
|
||||
'peer-not-data-[visible]/controls:delay-500',
|
||||
'peer-not-data-[visible]/controls:duration-500',
|
||||
// Reduced motion
|
||||
'motion-reduce:peer-not-data-[visible]/controls:duration-100'
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
/>
|
||||
|
||||
<Controls.Root className="media-controls">
|
||||
<div className="media-button-group">
|
||||
<span className="media-button-group">
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
@@ -115,7 +115,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span className="media-time-controls">
|
||||
<Time.Group className="media-time">
|
||||
|
||||
@@ -76,27 +76,29 @@
|
||||
.media-default-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: start;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0));
|
||||
backdrop-filter: saturate(1.5) brightness(0.9);
|
||||
transition: opacity 0.15s ease-out;
|
||||
transition-duration: 100ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 300ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: opacity, display;
|
||||
transition-timing-function: ease-out;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
.media-default-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-overlay {
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 0ms;
|
||||
opacity: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Common Surface Styles - e.g. tooltips, popovers, controls */
|
||||
.media-default-skin .media-surface {
|
||||
@@ -161,7 +163,7 @@
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 42rem) {
|
||||
@container media-root (width > 40rem) {
|
||||
.media-default-skin .media-controls {
|
||||
padding: 0.25rem;
|
||||
gap: 0.125rem;
|
||||
@@ -451,12 +453,12 @@
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 2rem;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-captions {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PipIcon,
|
||||
PlayIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react';
|
||||
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 { Controls } from '@/ui/controls';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { VideoSkinProps } from './skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
/* ------------------------------------ Reused fragments ------------------------------------- */
|
||||
|
||||
const surface = cn(
|
||||
'bg-white/10',
|
||||
'backdrop-blur-3xl backdrop-brightness-90 backdrop-saturate-150',
|
||||
// Border and shadow
|
||||
'ring ring-white/5 ring-inset shadow-sm shadow-black/15',
|
||||
// Border to enhance contrast on lighter videos
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
|
||||
// Reduced transparency for users with preference
|
||||
'[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
|
||||
// High contrast mode
|
||||
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
|
||||
);
|
||||
|
||||
const icon = cn(
|
||||
'[grid-area:1/1] size-4.5 shrink-0',
|
||||
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
const iconHidden = 'hidden opacity-0';
|
||||
|
||||
/* --------------------------------------- Components ---------------------------------------- */
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
// Layout
|
||||
'grid shrink-0 p-2 cursor-pointer bg-transparent border-none rounded-full',
|
||||
'text-white/90 select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
|
||||
// Transitions
|
||||
'transition-[background-color,color,outline-offset] duration-150 ease-out',
|
||||
// Hover / focus / expanded
|
||||
'hover:bg-white/10 hover:text-white hover:no-underline',
|
||||
'focus-visible:bg-white/10 focus-visible:text-white',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
|
||||
'aria-expanded:bg-white/10 aria-expanded:text-white',
|
||||
// Disabled
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { [iconHidden]: !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { [iconHidden]: ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { [iconHidden]: paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { [iconHidden]: !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Skin ------------------------------------------- */
|
||||
|
||||
export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={cn(
|
||||
// Layout & containment
|
||||
'relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-[var(--media-border-radius,2rem)] bg-black',
|
||||
'font-sans text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Box-sizing reset for children
|
||||
'**:box-border',
|
||||
// Inner highlight ring (::before)
|
||||
'before:absolute before:pointer-events-none before:rounded-[inherit] before:z-10',
|
||||
'before:inset-px before:ring-1 before:ring-inset before:ring-white/15',
|
||||
// Outer border ring (::after)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/10',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-[250ms]',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div
|
||||
{...props}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 text-white"
|
||||
>
|
||||
<div className={cn('p-1 rounded-full', surface)}>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Controls.Root
|
||||
className={cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Surface
|
||||
surface,
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-3 inset-x-3',
|
||||
'p-[0.175rem] flex items-center gap-[0.075rem]',
|
||||
'text-white rounded-full z-10',
|
||||
// Transitions
|
||||
'will-change-[scale,transform,filter,opacity]',
|
||||
'transition-[scale,transform,filter,opacity] ease-out',
|
||||
'delay-0 duration-100 origin-bottom',
|
||||
// Hidden state
|
||||
'not-data-[visible]:pointer-events-none not-data-[visible]:blur-[8px]',
|
||||
'not-data-[visible]:scale-90 not-data-[visible]:opacity-0',
|
||||
'not-data-[visible]:delay-500 not-data-[visible]:duration-300',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-[visible]:duration-100',
|
||||
'motion-reduce:not-data-[visible]:blur-none',
|
||||
'motion-reduce:not-data-[visible]:scale-100',
|
||||
// Wider container
|
||||
'@2xl/media-root:p-1 @2xl/media-root:gap-0.5'
|
||||
)}
|
||||
>
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<PlayButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
|
||||
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={icon} />
|
||||
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Time.Group className="@container/media-time flex items-center flex-1 gap-3 px-2">
|
||||
<Time.Value
|
||||
type="current"
|
||||
className="hidden @2xs/media-time:block drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25 tabular-nums"
|
||||
/>
|
||||
{/* Temporary spacer */}
|
||||
<div className="flex-1 h-1 rounded-full bg-white/20" />
|
||||
<Time.Value
|
||||
type="duration"
|
||||
className="drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25 tabular-nums"
|
||||
/>
|
||||
</Time.Group>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PiPButton
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<PipIcon className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<FullscreenButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Controls.Root>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-[visible]/controls:-translate-y-12 peer-data-[visible]/controls:delay-25',
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[42ch] mx-auto text-center flex flex-col items-center">
|
||||
<span className={cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline',
|
||||
)}>
|
||||
An example cue
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Gradient overlay
|
||||
'bg-gradient-to-t from-black/50 via-black/30 to-transparent',
|
||||
'backdrop-saturate-150 backdrop-brightness-90',
|
||||
// Transitions
|
||||
'transition-opacity ease-out duration-100 delay-0',
|
||||
// Hidden when controls hidden (peer sibling)
|
||||
'peer-not-data-[visible]/controls:opacity-0',
|
||||
'peer-not-data-[visible]/controls:delay-500',
|
||||
'peer-not-data-[visible]/controls:duration-300',
|
||||
// Reduced motion
|
||||
'motion-reduce:peer-not-data-[visible]/controls:duration-100'
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user