refactor(compiler)!: move component generation to core

This commit is contained in:
Rahim
2026-06-19 19:37:09 -07:00
parent b2c6b3489f
commit f64acdfd15
158 changed files with 2849 additions and 712 deletions
@@ -0,0 +1,121 @@
import { cn } from '@videojs/utils/style';
import { bufferingIndicator as baseBufferingIndicator } from './components/buffering';
import { container as baseContainer } from './components/container';
import { controls as baseControls } from './components/controls';
import { error as baseError } from './components/error';
import { popup as basePopup } from './components/popup';
import { slider as baseSlider } from './components/slider';
import { surface } from './components/surface';
/* ===========================================================================
Container
========================================================================== */
export const container = cn(
baseContainer,
'[--media-text-color:var(--media-color-primary,oklch(0_0_0))]',
'[--media-surface-background-color:oklch(1_0_0/0.5)]',
'[--media-surface-inner-border-color:oklch(1_0_0/0.1)]',
'[--media-surface-outer-border-color:oklch(0_0_0/0.05)]',
'[--media-surface-shadow-color:oklch(0_0_0/0.15)]',
'[--media-surface-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-error-dialog-transition-duration:250ms]',
'[--media-error-dialog-transition-delay:100ms]',
'[--media-popup-transition-duration:100ms]',
'[--media-popup-transition-timing-function:ease-out]',
'[--media-tooltip-side-offset:0.75rem]',
'[--media-tooltip-boundary-offset:0.75rem]',
'[--media-popover-side-offset:0.75rem]',
'[--media-popover-boundary-offset:0.75rem]',
'motion-reduce:[--media-error-dialog-transition-duration:50ms]',
'motion-reduce:[--media-error-dialog-transition-delay:0ms]',
'motion-reduce:[--media-popup-transition-duration:0ms]',
'dark:[--media-surface-background-color:oklch(0_0_0/0.4)]',
'dark:[--media-text-color:var(--media-color-primary,oklch(1_0_0))]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(1_0_0)]',
'contrast-more:[--media-surface-background-color:oklch(1_0_0)]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-outer-border-color:oklch(0_0_0/0.05)]',
'contrast-more:[--media-surface-outer-border-color:oklch(0_0_0/0.05)]',
'dark:[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]',
'dark:contrast-more:[--media-surface-background-color:oklch(0_0_0)]',
'dark:[@media(prefers-reduced-transparency:reduce)]:[--media-surface-inner-border-color:oklch(1_0_0/0.2)]',
'dark:contrast-more:[--media-surface-inner-border-color:oklch(1_0_0/0.2)]',
'dark:[@media(prefers-reduced-transparency:reduce)]:[--media-surface-outer-border-color:transparent]',
'dark:contrast-more:[--media-surface-outer-border-color:transparent]'
);
/* ===========================================================================
Controls
========================================================================== */
export const controls = cn(baseControls, surface, 'text-(--media-text-color)', 'peer-data-open/error:**:invisible');
/* ===========================================================================
Sliders
========================================================================== */
export const slider = {
...baseSlider,
track: cn(baseSlider.track, 'bg-black/10', 'dark:bg-white/20 dark:ring-1 dark:ring-black/5'),
};
/* ===========================================================================
Popup (with audio surface)
========================================================================== */
export const popup = {
...basePopup,
popover: cn(surface, basePopup.popover),
tooltip: cn(surface, basePopup.tooltip),
};
/* ===========================================================================
Buffering (with audio surface)
========================================================================== */
export const bufferingIndicator = {
...baseBufferingIndicator,
container: cn(baseBufferingIndicator.container, surface),
};
/* ===========================================================================
Error (with audio surface)
========================================================================== */
export const error = {
...baseError,
dialog: cn(
'absolute inset-0 z-20 flex items-center gap-3 rounded-full px-5 pr-0.5',
'bg-(--media-surface-background-color) text-(--media-text-color)',
'backdrop-blur-lg backdrop-saturate-150',
'transition-[opacity,filter] ease-out',
'duration-(--media-error-dialog-transition-duration)',
'delay-(--media-error-dialog-transition-delay)',
'group-data-starting-style/error:opacity-0 group-data-starting-style/error:blur-xs',
'group-data-ending-style/error:opacity-0 group-data-ending-style/error:blur-xs',
'group-data-ending-style/error:delay-0'
),
content: 'flex flex-1 items-center gap-2',
};
/* ===========================================================================
Shared components (no overrides)
========================================================================== */
export {
airplayIcon,
captionsIcon,
castIcon,
fullscreenIcon,
muteIcon,
pipIcon,
playIcon,
} from '../../shared/tailwind/icon-state';
export { badge } from './components/badge';
export { button } from './components/button';
export { buttonGroup } from './components/button-group';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { menu } from './components/menu';
export { playbackRate } from './components/playback-rate';
export { seek } from './components/seek';
export { time } from './components/time';
@@ -0,0 +1,2 @@
export const badge =
'rounded-full bg-current/10 px-1.5 py-0.5 text-[0.6875rem] font-medium leading-none text-current/70';
@@ -0,0 +1,4 @@
export const bufferingIndicator = {
root: 'absolute inset-0 hidden items-center justify-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:flex',
container: 'p-1 rounded-full',
};
@@ -0,0 +1,3 @@
import { cn } from '@videojs/utils/style';
export const buttonGroup = cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5');
@@ -0,0 +1,36 @@
import { cn } from '@videojs/utils/style';
export const button = {
base: cn(
'flex items-center justify-center shrink-0 border-none cursor-pointer select-none text-center touch-manipulation min-h-0',
'py-2 px-4 rounded-full',
'outline-2 outline-transparent -outline-offset-2',
'transition-[background-color,outline-offset,scale] will-change-[scale] duration-150 ease-out',
'active:scale-[0.98]',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
'focus-visible:outline-current focus-visible:outline-offset-2',
'data-[availability=unavailable]:hidden',
'data-[availability=unsupported]:hidden'
),
primary: 'bg-white text-black font-medium text-shadow-none',
subtle: cn(
'bg-transparent text-inherit text-shadow-inherit',
'hover:bg-current/10 hover:no-underline',
'focus-visible:bg-current/10',
'aria-expanded:bg-current/10'
),
icon: cn('grid w-9 aspect-square p-0', 'active:scale-90'),
/**
* Live variant: wide pill button with a status dot rendered via `::before`
* (gray -> red at the live edge) and "LIVE" as the button's own text.
*/
live: cn(
'inline-flex items-center gap-1.5',
'aspect-auto w-auto px-3 py-2',
'text-xs font-semibold uppercase tracking-wider leading-none',
'before:inline-block before:size-2 before:shrink-0 before:rounded-full',
'before:bg-current/40 before:transition-colors before:duration-150 before:ease-out',
'before:content-[""]',
'data-[live-edge]:before:bg-red-500'
),
};
@@ -0,0 +1,20 @@
import { cn } from '@videojs/utils/style';
import { reset } from './reset';
export const container = cn(
reset,
// Layout & containment
'block relative isolate h-full w-full @container/media-root',
// Appearance
'rounded-(--media-border-radius,2rem)',
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Focus ring
'outline-2 outline-transparent -outline-offset-4',
'transition-[outline-offset,outline-color] duration-100 ease-out',
'focus-visible:outline-current focus-visible:outline-offset-2',
// Shadow color variables (derived from currentColor lightness)
'[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]',
'[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]',
// Icon sizing
'[--media-icon-size:18px]'
);
@@ -0,0 +1,12 @@
import { cn } from '@videojs/utils/style';
export const controls = cn(
// Peer marker for overlay/captions
'peer/controls',
// Layout
'@container/media-controls',
'p-[0.375rem] flex items-center gap-x-[0.075rem]',
'rounded-3xl',
// Text shadow
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)'
);
@@ -0,0 +1,20 @@
import { cn } from '@videojs/utils/style';
export const error = {
root: 'peer/error group/error hidden data-[open]:flex absolute inset-0 z-20 items-center justify-center outline-none',
dialog: cn(
'flex flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white',
// Animation
'transition-[opacity,scale,transform]',
'duration-(--media-error-dialog-transition-duration)',
'delay-(--media-error-dialog-transition-delay)',
'ease-(--media-error-dialog-transition-timing-function)',
'group-data-starting-style/error:opacity-0 group-data-starting-style/error:scale-50',
'group-data-ending-style/error:opacity-0 group-data-ending-style/error:scale-50',
'group-data-ending-style/error:delay-0'
),
content: 'flex flex-col gap-2 px-2 pt-2 pb-1.5',
title: 'font-semibold leading-tight',
description: 'opacity-70 wrap-anywhere',
actions: 'flex gap-2 *:flex-1',
};
@@ -0,0 +1,11 @@
import { cn } from '@videojs/utils/style';
export const icon = cn(
'block [grid-area:1/1] size-(--media-icon-size) shrink-0',
'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
export const iconHidden = 'hidden opacity-0';
export const iconFlipped = '[scale:-1_1]';
export const iconContainer = 'relative grid';
@@ -0,0 +1,151 @@
import { cn } from '@videojs/utils/style';
/**
* NOTE: tailwind.css is required to support the `@property --media-progress-fill` registration and animation keyframes. You should import from either:
* - "@videojs/html/tailwind.css" for HTML skins
* - "@videojs/react/tailwind.css" for React skins
*/
export const inputFeedback = {
root: cn(
// Layout
'absolute inset-x-0 top-0 bottom-14 pointer-events-none',
'grid grid-cols-3 items-center justify-items-center',
// Shift to full extent in larger containers
'@2xl/media-root:bottom-0',
// Color
'[color:var(--media-color-primary,oklch(1_0_0))]'
),
island: {
base: cn(
'group/input-indicator',
// Surface override (darker than default)
'[--media-surface-background-color:oklch(0_0_0/0.25)]',
// Layout
'absolute top-3 rounded-full origin-top pointer-events-none',
'text-inherit font-medium',
// Transition
'duration-100 ease-out',
'data-starting-style:opacity-0',
'data-ending-style:opacity-0',
'data-starting-style:duration-250',
'data-starting-style:ease-in',
'data-ending-style:duration-250',
'data-ending-style:ease-in',
'pointer-coarse:will-change-[scale,translate,opacity]',
'pointer-coarse:transition-[scale,translate,opacity]',
'pointer-fine:motion-safe:will-change-[scale,translate,filter,opacity]',
'pointer-fine:motion-safe:transition-[scale,translate,filter,opacity]',
'pointer-fine:motion-safe:data-starting-style:blur-sm',
'pointer-fine:motion-safe:data-starting-style:scale-90',
'pointer-fine:motion-safe:data-ending-style:blur-sm',
'pointer-fine:motion-safe:data-ending-style:scale-90',
'motion-safe:data-ending-style:-translate-y-1/4',
// Reduced transparency / high contrast: solid surface background
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]',
'contrast-more:[--media-surface-background-color:oklch(0_0_0)]'
),
content: cn(
'flex justify-between items-center gap-2 px-2.5 py-1 w-full',
// Increase contrast of content via blend mode
'**:mix-blend-difference'
),
// Volume island sizing + progress-fill gradient on the content child
volume: cn(
'w-[min(80%,12rem)]',
'*:[--media-progress-fill:var(--media-volume-fill)]',
'*:rounded-[inherit]',
'*:[background-image:linear-gradient(to_right,currentColor_0%,currentColor_var(--media-progress-fill),transparent_var(--media-progress-fill),transparent_100%)]',
'*:[transition:--media-progress-fill_200ms_linear]'
),
// Shown state - applied on the active item itself
shownVolume: cn(
'data-open:duration-100',
// Boundary shake (keyframes must be registered and media-shake added to @theme - see note at top)
'data-min:animate-media-shake',
'data-max:animate-media-shake',
'motion-reduce:data-min:animate-none',
'motion-reduce:data-max:animate-none'
),
shownStatus: cn('data-open:duration-100'),
// Icon inside island - hidden by default; specific icons opt in via shown* below.
icon: cn('hidden shrink-0'),
// Volume level -> which icon shows
shownVolumeHigh: 'group-data-[level=high]/input-indicator:block',
shownVolumeLow: 'group-data-[level=low]/input-indicator:block',
shownVolumeOff: 'group-data-[level=off]/input-indicator:block',
// Captions state -> which icon shows
shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block',
shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block',
shownFullscreenEnter: cn(
'group-data-[status=fullscreen]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in'
),
shownFullscreenExit: cn(
'group-data-[status=exit-fullscreen]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in'
),
shownPipEnter: cn(
'group-data-[status=pip]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in'
),
shownPipExit: cn(
'group-data-[status=exit-pip]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in'
),
value: 'ml-auto',
},
bubble: {
base: cn(
'group/input-indicator',
// Default placement - center column for status bubbles and undirected seeks
'col-start-2 row-start-1',
'flex flex-col items-center justify-center p-4',
'transition-opacity duration-250 ease-out',
'data-starting-style:opacity-0',
'data-ending-style:opacity-0',
'data-starting-style:duration-200',
'data-starting-style:ease-in',
'data-ending-style:duration-200',
'data-ending-style:ease-in',
'@2xl/media-root:p-8',
'not-data-direction:[transition-property:opacity,scale]',
'not-data-direction:duration-600',
'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]',
'motion-reduce:not-data-direction:transition-opacity',
'motion-reduce:not-data-direction:duration-100',
'motion-reduce:not-data-direction:ease-out',
'not-data-direction:data-starting-style:scale-80',
'not-data-direction:data-ending-style:scale-80',
'not-data-direction:data-starting-style:duration-200',
'not-data-direction:data-starting-style:ease-in',
'not-data-direction:data-ending-style:duration-200',
'not-data-direction:data-ending-style:ease-in',
// Direction placement
'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start',
'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end'
),
// Icons in the bubble
icon: 'hidden w-9 h-9',
// Seek icon: shown for seekStep + seekToPercent; flipped for backward; slides in on active
shownSeek: cn(
'group-data-direction/input-indicator:block',
'group-data-[direction=backward]/input-indicator:-scale-x-100',
// Slide animation (keyframes registered in companion CSS)
'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward',
'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward',
'motion-reduce:group-data-direction/input-indicator:animate-none'
),
// togglePaused: pause icon when paused, play icon when playing
shownPause: cn(
'group-data-[status=pause]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in'
),
shownPlay: cn(
'group-data-[status=play]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in'
),
time: 'tabular-nums',
},
};
@@ -0,0 +1,82 @@
import { cn } from '@videojs/utils/style';
import { popup } from './popup';
const panelBase = cn(
'absolute inset-0 overflow-auto overscroll-none p-1 outline-none translate-none',
'data-starting-style:overflow-hidden data-ending-style:overflow-hidden',
'transition-[translate,filter] duration-(--menu-transition-duration) ease-out will-change-[translate,filter]'
);
const rootView = cn(
panelBase,
'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur'
);
const submenuPanel = cn(
panelBase,
'z-10',
'not-data-open:not-data-ending-style:-translate-x-full',
'not-data-open:not-data-ending-style:transition-none',
'data-starting-style:pointer-events-none data-ending-style:pointer-events-none',
'data-starting-style:blur data-ending-style:blur',
'data-starting-style:data-[direction=forward]:translate-x-full',
'data-ending-style:data-[direction=forward]:-translate-x-full',
'data-starting-style:data-[direction=back]:-translate-x-full',
'data-ending-style:data-[direction=back]:translate-x-full'
);
const itemBase = cn(
'flex cursor-pointer select-none items-center gap-2 rounded-lg py-1.5 px-3',
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)',
'outline-2 -outline-offset-2 outline-transparent',
'transition-colors duration-100 ease-out',
'hover:bg-current/10 data-highlighted:bg-current/10',
'focus-visible:outline-current focus-visible:outline-offset-2',
'[&_.media-icon]:drop-shadow-[0_1px_0_var(--media-current-shadow-color)]'
);
const menuTokens = cn(
'[--menu-transition-duration:250ms] [--menu-max-height:14rem]',
'[--popup-transition-property:,_width,_height] [--popup-transition-duration:,_var(--menu-transition-duration),_var(--menu-transition-duration)]',
'motion-reduce:[--menu-transition-duration:0ms]'
);
const menuHostShell = cn(
popup.popover,
menuTokens,
'max-w-(--media-popover-available-width,none) max-h-[min(var(--media-popover-available-height,var(--menu-max-height)),var(--menu-max-height))]',
'box-border rounded-xl p-1 overscroll-none'
);
export const menu = {
/** Standalone menu popover host (audio playback rate, sandbox demos). */
root: cn(menuHostShell, 'min-w-24 !overflow-auto'),
/** Settings menu viewport host with nested submenu navigation. */
settings: cn(menuHostShell, 'min-w-44 w-(--media-menu-width) h-(--media-menu-height)', '!overflow-hidden'),
group: 'flex flex-col gap-0.5',
item: cn(
itemBase,
'group/menu-item justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50'
),
separator: 'my-1 border-b border-[oklch(0_0_0/0.1)] shadow-[0_1px_0_0_oklch(1_0_0/0.075)]',
tier: 'self-start -ml-1 pt-px text-[0.5625rem] font-semibold leading-none text-current/70',
badge: 'ml-auto',
indicator: cn(
'-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100',
'[&_.media-icon]:drop-shadow-[0_1px_0_var(--media-current-shadow-color)]'
),
/** Root settings view - slides out when a submenu is active. */
rootView,
/** Submenu panel - slides in/out alongside the root view. */
submenuPanel,
back: cn(itemBase, 'mb-0.5 w-full font-medium'),
hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65',
hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap',
chevron: 'size-3.5 first:-ml-1 last:-mr-1',
settingsGroup: 'group/settings',
settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid',
settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0',
};
@@ -0,0 +1,23 @@
import { cn } from '@videojs/utils/style';
export const overlay = cn(
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Default: hidden
'opacity-0',
'bg-linear-to-t from-black/50 via-black/30 via-25% to-transparent',
'backdrop-blur-none backdrop-saturate-100',
// Transitions
'transition-[opacity,backdrop-filter]',
'duration-(--media-controls-transition-duration)',
'ease-out',
// Shown when controls visible
'peer-data-visible/controls:opacity-100',
// Shown when error visible (+ blur)
// Light DOM: peer/error is a direct sibling (React)
'peer-data-open/error:opacity-100',
'peer-data-open/error:duration-(--media-error-dialog-transition-duration)',
'peer-data-open/error:delay-(--media-error-dialog-transition-delay)',
'peer-data-open/error:backdrop-blur-lg peer-data-open/error:backdrop-saturate-150'
);
@@ -0,0 +1,3 @@
export const playbackRate = {
button: `tabular-nums after:w-[4ch] after:content-[attr(data-rate)_'×'] data-[inline-rate-label]:after:content-none`,
};
@@ -0,0 +1,41 @@
import { cn } from '@videojs/utils/style';
const base = cn(
// Reset default popover styles
'm-0 border-0 text-inherit overflow-visible',
// Animation
'transition-[scale,opacity,filter_var(--popup-transition-property,)]',
'duration-[var(--media-popup-transition-duration),var(--media-popup-transition-duration),var(--media-popup-transition-duration)_var(--popup-transition-duration,)]',
'ease-(--media-popup-transition-timing-function)',
'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm',
'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm',
'data-ending-style:duration-[max(0ms,calc(var(--media-popup-transition-duration)-50ms))]',
// Ensure we animate from the correct origin based on the side the popover is on
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left',
// Safe area between trigger and popup
'before:absolute before:pointer-events-[inherit]',
'data-[side=top]:before:left-0 data-[side=top]:before:right-0 data-[side=top]:before:top-full',
'data-[side=bottom]:before:left-0 data-[side=bottom]:before:right-0 data-[side=bottom]:before:bottom-full',
'data-[side=left]:before:top-0 data-[side=left]:before:bottom-0 data-[side=left]:before:left-full',
'data-[side=right]:before:top-0 data-[side=right]:before:bottom-0 data-[side=right]:before:right-full'
);
export const popup = {
popover: cn(
base,
'data-[side=top]:before:h-(--media-popover-side-offset) data-[side=bottom]:before:h-(--media-popover-side-offset)',
'data-[side=left]:before:w-(--media-popover-side-offset) data-[side=right]:before:w-(--media-popover-side-offset)'
),
tooltip: cn(
base,
'py-1 px-2.5 rounded-full text-[0.75rem] whitespace-nowrap',
/* Flex only while open - unconditional `flex` overrides UA `[popover]` `display:none`. */
'data-[open]:flex data-[open]:items-center data-[open]:gap-1',
'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)',
'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)'
),
volume: 'py-3 px-0 rounded-full',
tooltipShortcut: cn(
'min-w-[1.5em] p-[0.1em] bg-current/30 text-[90%] font-semibold font-[inherit] leading-[1.25] text-center rounded'
),
};
@@ -0,0 +1,9 @@
import { cn } from '@videojs/utils/style';
export const poster = cn(
'absolute inset-0 w-full h-full pointer-events-none',
// Fade in/out with the `data-visible` attribute
'transition-opacity duration-250',
'not-data-visible:opacity-0',
'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
);
@@ -0,0 +1,9 @@
import { cn } from '@videojs/utils/style';
export const reset = cn(
'**:box-border',
// Keep authored templates hidden even when component classes set display.
'[&_[hidden][hidden]]:hidden',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]'
);
@@ -0,0 +1,5 @@
export const seek = {
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',
};
@@ -0,0 +1,86 @@
import { cn } from '@videojs/utils/style';
export const slider = {
root: cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none cursor-pointer',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-8',
// Vertical
'data-[orientation=vertical]:w-8 data-[orientation=vertical]:h-20'
),
track: cn(
'relative isolate overflow-hidden rounded-[inherit] select-none',
// Horizontal
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
// Vertical
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full'
),
fill: {
base: 'absolute rounded-[inherit] pointer-events-none',
fill: cn(
'bg-current',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
'data-[orientation=horizontal]:w-(--media-slider-fill)',
'group-data-dragging/slider:data-[orientation=horizontal]:w-(--media-slider-pointer)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
'data-[orientation=vertical]:h-(--media-slider-fill)',
'group-data-dragging/slider:data-[orientation=vertical]:h-(--media-slider-pointer)'
),
buffer: cn(
'bg-current/20 duration-250 ease-out',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)'
),
},
thumb: {
base: cn(
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
'bg-current rounded-full',
'shadow-[0_0_0_1px_var(--media-current-shadow-color,oklch(0_0_0/0.1)),0_1px_3px_0_oklch(0_0_0/0.35),0_1px_2px_-1px_oklch(0_0_0/0.35)]',
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
'outline-4 outline-transparent -outline-offset-4',
'hover:outline-current/15 hover:outline-offset-0',
'focus-visible:outline-current/15 focus-visible:outline-offset-0',
// Focus ring via ::after (matches default slider.css)
'after:absolute after:-inset-1 after:rounded-[inherit]',
'after:shadow-[0_0_0_2px_currentColor] after:pointer-events-none',
'after:transition-[opacity,scale] after:duration-150 after:ease-out',
'after:opacity-0 after:scale-50',
'focus-visible:after:opacity-100 focus-visible:after:scale-100',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
'group-data-dragging/slider:data-[orientation=horizontal]:left-(--media-slider-pointer)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
'group-data-dragging/slider:data-[orientation=vertical]:top-[calc(100%-var(--media-slider-pointer))]'
),
persistent: 'size-3',
interactive: cn(
'size-2.5',
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
'group-active/slider:size-3'
),
},
preview: cn(
'group/preview',
'before:block before:min-w-1 before:h-1 before:bg-current before:rounded-full before:opacity-0 before:scale-50',
'before:shadow-[0_0_0_1px_var(--media-current-shadow-color,oklch(0_0_0/0.15)),0_1px_2px_0_oklch(0_0_0/0.35)]',
'before:transition-[opacity,scale] before:duration-200 before:ease-out',
'data-pointing:not-data-dragging:before:opacity-100 data-pointing:not-data-dragging:before:scale-100',
'peer-has-[[role=img]:not([data-hidden])]/thumbnail:*:hidden'
),
value: cn(
'absolute bottom-9 tabular-nums',
'-translate-x-1/2 translate-y-2 scale-50 opacity-0 blur-lg',
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)',
'transition-[filter,opacity,scale,translate] duration-200 ease-out',
'group-data-pointing/preview:translate-y-0 group-data-pointing/preview:scale-100',
'group-data-pointing/preview:opacity-100 group-data-pointing/preview:blur-none'
),
};
@@ -0,0 +1,8 @@
import { cn } from '@videojs/utils/style';
export const surface = cn(
'bg-(--media-surface-background-color)',
'[backdrop-filter:var(--media-surface-backdrop-filter)]',
'[box-shadow:0_0_0_1px_var(--media-surface-outer-border-color),0_1px_3px_0_var(--media-surface-shadow-color),0_1px_2px_-1px_var(--media-surface-shadow-color)]',
'after:absolute after:inset-0 after:[box-shadow:inset_0_0_0_1px_var(--media-surface-inner-border-color)] after:rounded-[inherit] after:pointer-events-none after:z-10'
);
@@ -0,0 +1,18 @@
import { cn } from '@videojs/utils/style';
export const thumbnail = {
root: cn('group/thumbnail peer/thumbnail pointer-events-none bg-black/90 rounded-xl'),
image: cn(
'block relative overflow-clip rounded-[inherit]',
'transition-opacity duration-150 ease-out',
'after:absolute after:inset-0 after:rounded-[inherit]',
'after:bg-linear-to-t after:from-black/80 after:via-black/30 after:to-black/0',
'data-loading:opacity-0'
),
time: 'absolute bottom-2 inset-x-0 text-center tabular-nums',
spinner: cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0',
'transition-opacity duration-150 ease-out',
'group-not-has-[[role=img][data-loading]]/thumbnail:[--media-spinner-animation:none] group-has-[[role=img][data-loading]]/thumbnail:opacity-100'
),
};
@@ -0,0 +1,5 @@
export const time = {
group: '@container/media-time flex items-center flex-1 gap-3 px-2',
current: 'hidden @2xs/media-time:block tabular-nums',
duration: 'tabular-nums',
};
@@ -0,0 +1,227 @@
import { cn } from '@videojs/utils/style';
import { bufferingIndicator as baseBufferingIndicator } from './components/buffering';
import { buttonGroup as baseButtonGroup } from './components/button-group';
import { container as baseContainer } from './components/container';
import { controls as baseControls } from './components/controls';
import { error as baseError } from './components/error';
import { inputFeedback as baseInputFeedback } from './components/input-feedback';
import { menu as baseMenu } from './components/menu';
import { popup as basePopup } from './components/popup';
import { slider as baseSlider } from './components/slider';
import { surface } from './components/surface';
import { thumbnail as baseThumbnail } from './components/thumbnail';
import { time as baseTime } from './components/time';
/* ===========================================================================
Container
========================================================================== */
export const container = cn(
baseContainer,
'bg-black overflow-clip',
// Inner border ring
'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 dark:after:ring-white/15',
// Video element
'[&_video]:block [&_video]:w-full [&_video]:h-full [&_video]:rounded-[inherit] [&_video]:[object-fit:var(--media-object-fit,contain)] [&_video]:[object-position:var(--media-object-position,center)]',
'[--media-spring-timing-function:linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]',
'[--media-video-border-radius:var(--media-border-radius,2rem)]',
'[--media-controls-transition-duration:100ms]',
'[--media-controls-transition-timing-function:ease-out]',
'[--media-error-dialog-transition-duration:350ms]',
'[--media-error-dialog-transition-delay:100ms]',
'[--media-error-dialog-transition-timing-function:var(--media-spring-timing-function)]',
'[--media-popup-transition-duration:100ms]',
'[--media-popup-transition-timing-function:ease-out]',
'[--media-surface-background-color:oklch(1_0_0/0.1)]',
'[--media-surface-inner-border-color:oklch(1_0_0/0.05)]',
'[--media-surface-outer-border-color:oklch(0_0_0/0.1)]',
'[--media-surface-shadow-color:oklch(0_0_0/0.15)]',
'[--media-surface-backdrop-filter:blur(16px)_saturate(1.5)]',
'motion-reduce:[--media-error-dialog-transition-duration:50ms]',
'motion-reduce:[--media-error-dialog-transition-delay:0ms]',
'motion-reduce:[--media-error-dialog-transition-timing-function:ease-out]',
'[--media-tooltip-side-offset:0.75rem]',
'[--media-tooltip-boundary-offset:0.5rem]',
'[--media-popover-side-offset:0.5rem]',
'[--media-popover-boundary-offset:0.5rem]',
'motion-reduce:[--media-popup-transition-duration:0ms]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]',
'contrast-more:[--media-surface-background-color:oklch(0_0_0)]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-inner-border-color:oklch(1_0_0/0.25)]',
'contrast-more:[--media-surface-inner-border-color:oklch(1_0_0/0.25)]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-outer-border-color:transparent]',
'contrast-more:[--media-surface-outer-border-color:transparent]',
'pointer-fine:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]',
'pointer-coarse:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]',
'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:50ms]',
// Caption track CSS variables (consumed by the native caption bridge in light DOM)
'[--media-caption-track-y:-0.5rem]',
'[--media-caption-track-delay:25ms]',
'[--media-caption-track-duration:var(--media-controls-transition-duration)]',
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-5.5rem]',
'@2xl/media-root:has-[[data-controls][data-visible]]:*:[--media-caption-track-y:-3.5rem]',
// Native caption track container
'[&_video::-webkit-media-text-track-container]:transition-[translate]',
'[&_video::-webkit-media-text-track-container]:duration-(--media-caption-track-duration)',
'[&_video::-webkit-media-text-track-container]:ease-out',
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
'[&_video::-webkit-media-text-track-container]:scale-98',
'[&_video::-webkit-media-text-track-container]:z-1',
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
// Fullscreen
'[&:fullscreen]:[--media-border-radius:0]',
'[&:fullscreen_video]:object-contain'
);
/* ===========================================================================
Controls (hide/show behavior)
========================================================================== */
export const controls = cn(
baseControls,
surface,
// Position & wrapping layout (small)
'absolute bottom-2 inset-x-2 flex-wrap',
'[color:var(--media-color-primary,oklch(1_0_0))] z-10',
'peer-data-open/error:hidden',
'ease-(--media-controls-transition-timing-function) origin-bottom',
'duration-(--media-controls-transition-duration)',
'pointer-fine:will-change-[scale,filter,opacity]',
'pointer-fine:transition-[scale,filter,opacity]',
'pointer-coarse:will-change-[scale,opacity]',
'pointer-coarse:transition-[scale,opacity]',
// Hidden state
'not-data-visible:pointer-events-none not-data-visible:opacity-0',
'motion-safe:not-data-visible:scale-90',
'pointer-fine:motion-safe:not-data-visible:blur-sm',
// Single-row layout (large)
'@2xl/media-root:bottom-3 @2xl/media-root:inset-x-3 @2xl/media-root:flex-nowrap @2xl/media-root:gap-x-0.5 @2xl/media-root:p-1'
);
/* ===========================================================================
Button groups
========================================================================== */
export const buttonGroupStart = cn(baseButtonGroup, 'flex-1 @2xl/media-root:flex-none');
export const buttonGroupEnd = cn(baseButtonGroup, 'flex-1 justify-end @2xl/media-root:flex-none');
/* ===========================================================================
Time
========================================================================== */
export const time = {
...baseTime,
group: cn(
baseTime.group,
'grow-0 shrink-0 basis-full order-[-1] px-2.5',
'@2xl/media-root:grow @2xl/media-root:shrink @2xl/media-root:basis-0 @2xl/media-root:order-[unset]'
),
};
/* ===========================================================================
Thumbnail (with video surface)
========================================================================== */
export const thumbnail = {
...baseThumbnail,
root: cn(
baseThumbnail.root,
surface,
'[--media-slider-thumbnail-max-width:11rem] [--media-slider-thumbnail-padding:-1.125rem] [--media-slider-thumbnail-inset:calc((100cqi-100%)/2)]',
'absolute [left:clamp(calc(var(--media-slider-thumbnail-max-width)/2+var(--media-slider-thumbnail-padding)-var(--media-slider-thumbnail-inset)),var(--media-slider-pointer),calc(100%-var(--media-slider-thumbnail-max-width)/2-var(--media-slider-thumbnail-padding)+var(--media-slider-thumbnail-inset)))] bottom-[calc(100%+1.2rem)] -translate-x-1/2',
'opacity-0 scale-80 blur-sm origin-bottom',
'transition-[scale,opacity,filter] duration-150',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:opacity-100',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:scale-100',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none',
'has-[[role=img][data-loading]]:max-h-24'
),
image: cn(baseThumbnail.image, 'max-w-(--media-slider-thumbnail-max-width)'),
};
/* ===========================================================================
Sliders
========================================================================== */
export const slider = {
...baseSlider,
track: cn(baseSlider.track, 'bg-white/20 ring-1 ring-black/5'),
};
/* ===========================================================================
Popup (with video surface)
========================================================================== */
export const popup = {
...basePopup,
popover: cn(surface, basePopup.popover),
tooltip: cn(surface, basePopup.tooltip),
};
/* ===========================================================================
Menu
========================================================================== */
const menuOffsets = '[--media-popover-side-offset:0.5rem] [--media-popover-boundary-offset:0.5rem]';
export const menu = {
...baseMenu,
root: cn(baseMenu.root, menuOffsets),
settings: cn(baseMenu.settings, menuOffsets),
};
/* ===========================================================================
Buffering (with video surface)
========================================================================== */
export const bufferingIndicator = {
...baseBufferingIndicator,
container: cn(baseBufferingIndicator.container, surface),
};
/* ===========================================================================
Error (with video surface)
========================================================================== */
export const error = {
...baseError,
dialog: cn(baseError.dialog, surface, 'text-shadow-2xs text-shadow-black/25'),
content: cn(baseError.content, 'text-shadow-inherit'),
title: cn(baseError.title, 'text-base'),
};
/* ===========================================================================
Input Feedback (islands use video surface)
========================================================================== */
export const inputFeedback = {
...baseInputFeedback,
island: {
...baseInputFeedback.island,
base: cn(baseInputFeedback.island.base, surface),
},
};
/* ===========================================================================
Shared components (no overrides)
========================================================================== */
export {
airplayIcon,
captionsIcon,
castIcon,
fullscreenIcon,
muteIcon,
pipIcon,
playIcon,
} from '../../shared/tailwind/icon-state';
export { badge } from './components/badge';
export { button } from './components/button';
export { buttonGroup } from './components/button-group';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { overlay } from './components/overlay';
export { playbackRate } from './components/playback-rate';
export { poster } from './components/poster';
export { seek } from './components/seek';
@@ -0,0 +1,123 @@
import { cn } from '@videojs/utils/style';
import { container as baseContainer } from './components/container';
import { controls as baseControls } from './components/controls';
import { error as baseError } from './components/error';
import { popup as basePopup } from './components/popup';
import { slider as baseSlider } from './components/slider';
/* ===========================================================================
Container
========================================================================== */
export const container = cn(
baseContainer,
'[--media-controls-background-color:oklch(1_0_0)]',
'[--media-controls-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-controls-border-color:oklch(0_0_0/0.05)]',
'[--media-controls-text-color:var(--media-color-primary,oklch(0_0_0))]',
'[--media-error-dialog-transition-duration:250ms]',
'[--media-error-dialog-transition-delay:100ms]',
'[--media-popup-transition-duration:100ms]',
'[--media-popup-transition-timing-function:ease-out]',
'[--media-tooltip-background-color:oklch(1_0_0/0.1)]',
'[--media-tooltip-border-color:oklch(0_0_0/0.05)]',
'[--media-tooltip-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-tooltip-text-color:currentColor]',
'[--media-tooltip-side-offset:0.75rem]',
'[--media-tooltip-boundary-offset:0.75rem]',
'[--media-popover-background-color:oklch(1_0_0/0.1)]',
'[--media-popover-border-color:oklch(0_0_0/0.05)]',
'[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-popover-side-offset:0.75rem]',
'[--media-popover-boundary-offset:0.75rem]',
'motion-reduce:[--media-error-dialog-transition-duration:50ms]',
'motion-reduce:[--media-error-dialog-transition-delay:0ms]',
'motion-reduce:[--media-popup-transition-duration:0ms]',
'dark:[--media-controls-background-color:oklch(0_0_0)]',
'dark:[--media-controls-border-color:oklch(1_0_0/0.1)]',
'dark:[--media-controls-text-color:var(--media-color-primary,oklch(1_0_0))]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-tooltip-background-color:oklch(1_0_0)]',
'contrast-more:[--media-tooltip-background-color:oklch(1_0_0)]',
'dark:[@media(prefers-reduced-transparency:reduce)]:[--media-tooltip-background-color:oklch(0_0_0)]',
'dark:contrast-more:[--media-tooltip-background-color:oklch(0_0_0)]'
);
/* ===========================================================================
Controls
========================================================================== */
export const controls = cn(
baseControls,
// Layout
'p-1.5 gap-2',
'rounded-(--media-border-radius,1rem)',
'peer-data-open/error:**:invisible',
// Appearance
'text-(--media-controls-text-color)',
// Border
'ring-1 ring-(color:--media-controls-border-color)'
);
/* ===========================================================================
Popup
========================================================================== */
export const popup = {
...basePopup,
volume: cn(
basePopup.popover,
'p-0 pl-16',
'bg-transparent bg-gradient-to-l from-(--media-controls-background-color) from-80% to-transparent'
),
};
/* ===========================================================================
Sliders
========================================================================== */
export const slider = {
...baseSlider,
value: cn(baseSlider.value, 'bottom-10'),
};
/* ===========================================================================
Error
========================================================================== */
export const error = {
...baseError,
dialog: cn(
'absolute inset-0 z-20 flex items-center gap-4 rounded-full px-5 pr-2',
'bg-(--media-controls-background-color)',
'transition-[opacity,filter,scale] ease-out',
'duration-(--media-error-dialog-transition-duration)',
'delay-(--media-error-dialog-transition-delay)',
'group-data-starting-style/error:opacity-0 group-data-starting-style/error:blur-xs group-data-starting-style/error:scale-95',
'group-data-ending-style/error:opacity-0 group-data-ending-style/error:blur-xs group-data-ending-style/error:scale-95',
'group-data-ending-style/error:delay-0'
),
content: 'flex flex-1 items-center gap-2',
};
/* ===========================================================================
Shared components (no overrides)
========================================================================== */
export {
airplayIcon,
captionsIcon,
castIcon,
fullscreenIcon,
muteIcon,
pipIcon,
playIcon,
} from '../../shared/tailwind/icon-state';
export { badge } from './components/badge';
export { bufferingIndicator } from './components/buffering';
export { button } from './components/button';
export { buttonGroup } from './components/button-group';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { menu } from './components/menu';
export { playbackRate } from './components/playback-rate';
export { seek } from './components/seek';
export { time } from './components/time';
@@ -0,0 +1,2 @@
export const badge =
'rounded-full bg-current/10 px-1.5 py-0.5 text-[0.6875rem] font-medium leading-none text-current/70';
@@ -0,0 +1,2 @@
export const bufferingIndicator =
'absolute inset-0 hidden items-center justify-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:flex';
@@ -0,0 +1,3 @@
import { cn } from '@videojs/utils/style';
export const buttonGroup = cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5');
@@ -0,0 +1,38 @@
import { cn } from '@videojs/utils/style';
export const button = {
base: cn(
'flex items-center justify-center shrink-0 border-none cursor-pointer select-none text-center touch-manipulation min-h-0',
'py-2 px-4 rounded-lg',
'outline-2 outline-transparent -outline-offset-2',
'transition-[background-color,outline-offset,scale] will-change-[scale] duration-150 ease-out',
'focus-visible:outline-current focus-visible:outline-offset-2',
'active:scale-[0.98]',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
'supports-[corner-shape:squircle]:rounded-[1rem]',
'supports-[corner-shape:squircle]:[corner-shape:squircle]',
'data-[availability=unavailable]:hidden',
'data-[availability=unsupported]:hidden'
),
primary: 'bg-white text-black font-medium text-shadow-none',
subtle: cn(
'bg-transparent text-inherit text-shadow-inherit',
'hover:bg-current/10',
'focus-visible:bg-current/10',
'aria-expanded:bg-current/10'
),
icon: cn('grid w-[2.375rem] aspect-square p-0', 'active:scale-90'),
/**
* Live variant: wide pill button with a status dot rendered via `::before`
* (gray -> red at the live edge) and "LIVE" as the button's own text.
*/
live: cn(
'inline-flex items-center gap-1.5',
'aspect-auto w-auto px-3 py-2',
'text-xs font-semibold uppercase tracking-wider leading-none',
'before:inline-block before:size-2 before:shrink-0 before:rounded-full',
'before:bg-current/40 before:transition-colors before:duration-150 before:ease-out',
'before:content-[""]',
'data-[live-edge]:before:bg-red-500'
),
};
@@ -0,0 +1,20 @@
import { cn } from '@videojs/utils/style';
import { reset } from './reset';
export const container = cn(
reset,
// Layout & containment
'block relative isolate h-full w-full @container/media-root',
// Appearance
'rounded-(--media-border-radius,0.75rem)',
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Focus ring
'outline-2 outline-transparent -outline-offset-4',
'transition-[outline-offset,outline-color] duration-100 ease-out',
'focus-visible:outline-current focus-visible:outline-offset-2',
// Shadow color variables (derived from currentColor lightness)
'[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]',
'[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]',
// Icon sizing
'[--media-icon-size:18px]'
);
@@ -0,0 +1,14 @@
import { cn } from '@videojs/utils/style';
export const controls = cn(
// Peer marker for overlay/captions
'peer/controls',
// Layout
'@container/media-controls',
'flex items-center',
// Appearance (driven by CSS variables set on the container)
'bg-(--media-controls-background-color)',
'[backdrop-filter:var(--media-controls-backdrop-filter)]',
// Text shadow
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)'
);
@@ -0,0 +1,21 @@
import { cn } from '@videojs/utils/style';
export const error = {
root: 'peer/error group/error hidden data-[open]:flex absolute inset-0 z-20 items-center justify-center outline-none',
dialog: cn(
'flex flex-col gap-3 max-w-64 p-4 text-white',
'text-shadow-2xs text-shadow-black/50',
// Animation
'transition-[opacity,scale,transform]',
'duration-(--media-error-dialog-transition-duration)',
'delay-(--media-error-dialog-transition-delay)',
'ease-(--media-error-dialog-transition-timing-function)',
'group-data-starting-style/error:opacity-0 group-data-starting-style/error:scale-50',
'group-data-ending-style/error:opacity-0 group-data-ending-style/error:scale-50',
'group-data-ending-style/error:delay-0'
),
content: 'flex flex-col gap-2 py-1.5',
title: 'font-semibold leading-tight',
description: 'opacity-70 wrap-anywhere',
actions: 'flex gap-2 *:flex-1',
};
@@ -0,0 +1,11 @@
import { cn } from '@videojs/utils/style';
export const icon = cn(
'block [grid-area:1/1] size-(--media-icon-size)',
'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
export const iconHidden = 'hidden opacity-0';
export const iconFlipped = '[scale:-1_1]';
export const iconContainer = 'relative grid';
@@ -0,0 +1,151 @@
import { cn } from '@videojs/utils/style';
/**
* NOTE: tailwind.css is required to support the `@property --media-progress-fill` registration and animation keyframes. You should import from either:
* - "@videojs/html/tailwind.css" for HTML skins
* - "@videojs/react/tailwind.css" for React skins
*/
export const inputFeedback = {
root: cn(
// Layout
'absolute inset-x-0 top-0 bottom-14 pointer-events-none',
'grid grid-cols-3 items-center justify-items-center overflow-hidden',
'rounded-[inherit]',
// Shift to full extent in larger containers
'@2xl/media-root:bottom-0',
// Color
'[color:var(--media-color-primary,oklch(1_0_0))]'
),
island: {
// Minimal island is a top strip with a gradient backdrop (no surface treatment)
base: cn(
'group/input-indicator',
'absolute top-0 inset-x-0',
'pt-3 pb-32',
'flex justify-center',
'text-inherit font-medium',
'origin-top pointer-events-none',
// Transition
'duration-100 ease-out',
'data-starting-style:opacity-0',
'data-ending-style:opacity-0',
'data-starting-style:duration-400',
'data-starting-style:ease-in',
'data-ending-style:duration-400',
'data-ending-style:ease-in',
'[background-image:linear-gradient(to_bottom,oklch(0_0_0/0.35),oklch(0_0_0/0.2)_3rem,oklch(0_0_0/0))]',
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)',
// Pointer-dependent transition props
'pointer-fine:will-change-[translate,filter,opacity]',
'pointer-fine:transition-[translate,filter,opacity]',
'pointer-coarse:will-change-[translate,opacity]',
'pointer-coarse:transition-[translate,opacity]',
'pointer-fine:motion-safe:data-starting-style:blur-sm',
'pointer-fine:motion-safe:data-ending-style:blur-sm',
'motion-safe:data-ending-style:-translate-y-full'
),
content: cn(
'flex justify-between items-center gap-2 px-2.5 py-1',
// Keep the label pinned to the end even when the icon is hidden during dismissal
'*:last:ml-auto',
// Reduced transparency / high contrast: solid content background
'[@media(prefers-reduced-transparency:reduce)]:bg-(--media-controls-background-color)',
'[@media(prefers-reduced-transparency:reduce)]:rounded-lg',
'contrast-more:bg-(--media-controls-background-color) contrast-more:rounded-lg'
),
// Volume island sizing + progress-fill on nested __progress element
volume: cn('*:data-feedback-island-content:w-[min(80%,14rem)]'),
// Progress bar (nested inside content) - its own element in the minimal variant
volumeProgress: cn(
'[--media-progress-fill:var(--media-volume-fill)]',
'w-full h-0.75 rounded-full',
'[background-image:linear-gradient(to_right,currentColor_0%,currentColor_var(--media-progress-fill),oklch(from_currentColor_l_c_h/0.2)_var(--media-progress-fill),oklch(from_currentColor_l_c_h/0.2)_100%)]',
'shadow-[0_1px_0_var(--media-current-shadow-color-subtle)]'
),
// Shown state - applied on the active item itself
shownVolume: cn(
'data-open:duration-100',
// Boundary shake (keyframes must be registered and media-shake added to @theme - see note at top)
'data-min:*:data-feedback-island-content:animate-media-shake',
'data-max:*:data-feedback-island-content:animate-media-shake',
'motion-reduce:data-min:*:data-feedback-island-content:animate-none',
'motion-reduce:data-max:*:data-feedback-island-content:animate-none'
),
shownStatus: cn('data-open:duration-100'),
// Icon inside island - hidden by default; specific icons opt in via shown* below.
icon: cn('hidden shrink-0', 'drop-shadow-[0_1px_0_var(--media-current-shadow-color)]'),
shownVolumeHigh: 'group-data-[level=high]/input-indicator:block',
shownVolumeLow: 'group-data-[level=low]/input-indicator:block',
shownVolumeOff: 'group-data-[level=off]/input-indicator:block',
shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block',
shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block',
shownFullscreenEnter: cn(
'group-data-[status=fullscreen]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in'
),
shownFullscreenExit: cn(
'group-data-[status=exit-fullscreen]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in'
),
shownPipEnter: cn(
'group-data-[status=pip]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in'
),
shownPipExit: cn(
'group-data-[status=exit-pip]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in'
),
value: 'ml-auto',
},
bubble: {
base: cn(
'group/input-indicator',
// Default placement - center column for status bubbles and undirected seeks
'col-start-2 row-start-1',
'flex flex-col items-center justify-center p-4',
'transition-opacity duration-250 ease-out',
'data-starting-style:opacity-0',
'data-ending-style:opacity-0',
'data-starting-style:duration-200',
'data-starting-style:ease-in',
'data-ending-style:duration-200',
'data-ending-style:ease-in',
'@2xl/media-root:p-8',
'not-data-direction:[transition-property:opacity,scale]',
'not-data-direction:duration-600',
'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]',
'motion-reduce:not-data-direction:transition-opacity',
'motion-reduce:not-data-direction:duration-100',
'motion-reduce:not-data-direction:ease-out',
'not-data-direction:data-starting-style:scale-80',
'not-data-direction:data-ending-style:scale-80',
'not-data-direction:data-starting-style:duration-200',
'not-data-direction:data-starting-style:ease-in',
'not-data-direction:data-ending-style:duration-200',
'not-data-direction:data-ending-style:ease-in',
// Direction placement
'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start',
'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end'
),
icon: 'hidden w-9 h-9',
shownSeek: cn(
'group-data-direction/input-indicator:block',
'group-data-[direction=backward]/input-indicator:-scale-x-100',
// Slide animation (keyframes registered in companion CSS)
'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward',
'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward',
'motion-reduce:group-data-direction/input-indicator:animate-none'
),
shownPause: cn(
'group-data-[status=pause]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in'
),
shownPlay: cn(
'group-data-[status=play]/input-indicator:block',
'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in'
),
time: 'tabular-nums',
},
};
@@ -0,0 +1,80 @@
import { cn } from '@videojs/utils/style';
import { popup } from './popup';
const panelBase = cn(
'absolute inset-0 overflow-auto overscroll-none p-1 outline-none translate-none',
'data-starting-style:overflow-hidden data-ending-style:overflow-hidden',
'transition-[translate,filter] duration-(--menu-transition-duration) ease-out will-change-[translate,filter]'
);
const rootView = cn(
panelBase,
'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur'
);
const submenuPanel = cn(
panelBase,
'z-10',
'not-data-open:not-data-ending-style:-translate-x-full',
'not-data-open:not-data-ending-style:transition-none',
'data-starting-style:pointer-events-none data-ending-style:pointer-events-none',
'data-starting-style:blur data-ending-style:blur',
'data-starting-style:data-[direction=forward]:translate-x-full',
'data-ending-style:data-[direction=forward]:-translate-x-full',
'data-starting-style:data-[direction=back]:-translate-x-full',
'data-ending-style:data-[direction=back]:translate-x-full'
);
const itemBase = cn(
'flex cursor-pointer select-none items-center gap-2 rounded-lg py-1.5 px-2.5',
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)',
'outline-2 -outline-offset-2 outline-transparent',
'transition-colors duration-100 ease-out',
'hover:bg-current/10 data-highlighted:bg-current/10',
'focus-visible:outline-current focus-visible:outline-offset-2'
);
const menuTokens = cn(
'[--menu-transition-duration:250ms] [--menu-max-height:14rem]',
'[--popup-transition-property:,_width,_height] [--popup-transition-duration:,_var(--menu-transition-duration),_var(--menu-transition-duration)]',
'motion-reduce:[--menu-transition-duration:0ms]'
);
const menuHostShell = cn(
popup.popover,
menuTokens,
'max-w-(--media-popover-available-width,none) max-h-[min(var(--media-popover-available-height,var(--menu-max-height)),var(--menu-max-height))]',
'bg-(--media-popover-background-color) [backdrop-filter:var(--media-popover-backdrop-filter)]',
'shadow-[0_0_0_1px_var(--media-popover-border-color),0_4px_6px_-1px_oklch(0_0_0/0.1),0_2px_4px_-2px_oklch(0_0_0/0.1)]',
'box-border rounded-xl p-1 overscroll-none'
);
export const menu = {
/** Standalone menu popover host (audio playback rate, sandbox demos). */
root: cn(menuHostShell, 'min-w-24 !overflow-auto'),
/** Settings menu viewport host with nested submenu navigation. */
settings: cn(menuHostShell, 'min-w-44 w-(--media-menu-width) h-(--media-menu-height)', '!overflow-hidden'),
group: 'flex flex-col gap-0.5',
item: cn(
itemBase,
'group/menu-item justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50'
),
separator: 'my-1 -mx-1 border-b border-[oklch(0_0_0/0.075)] shadow-[0_1px_0_0_oklch(1_0_0/0.05)]',
tier: 'self-start -ml-1 pt-px text-[0.5625rem] font-semibold leading-none text-current/70',
badge: 'ml-auto',
indicator: '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100',
/** Root settings view - slides out when a submenu is active. */
rootView,
/** Submenu panel - slides in/out alongside the root view. */
submenuPanel,
back: cn(itemBase, 'mb-0.5 w-full font-medium'),
hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65',
hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap',
chevron: 'size-3.5 first:-ml-1 last:-mr-1',
settingsGroup: 'group/settings',
settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid',
settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0',
};
@@ -0,0 +1,23 @@
import { cn } from '@videojs/utils/style';
export const overlay = cn(
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Default: hidden
'opacity-0',
'bg-linear-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
'backdrop-blur-none backdrop-saturate-100',
// Transitions
'transition-[opacity,backdrop-filter]',
'duration-(--media-controls-transition-duration)',
'ease-out',
// Shown when controls visible
'peer-data-visible/controls:opacity-100',
// Shown when error visible (+ blur)
// Light DOM: peer/error is a direct sibling (React)
'peer-data-open/error:opacity-100',
'peer-data-open/error:duration-(--media-error-dialog-transition-duration)',
'peer-data-open/error:delay-(--media-error-dialog-transition-delay)',
'peer-data-open/error:backdrop-blur-lg peer-data-open/error:backdrop-saturate-120'
);
@@ -0,0 +1,3 @@
export const playbackRate = {
button: `tabular-nums after:w-[4ch] after:content-[attr(data-rate)_'×'] data-[inline-rate-label]:after:content-none`,
};
@@ -0,0 +1,43 @@
import { cn } from '@videojs/utils/style';
const base = cn(
// Reset default popover styles
'm-0 border-0 text-inherit overflow-visible',
// Animation
'transition-[scale,opacity,filter_var(--popup-transition-property,)]',
'duration-[var(--media-popup-transition-duration),var(--media-popup-transition-duration),var(--media-popup-transition-duration)_var(--popup-transition-duration,)]',
'ease-(--media-popup-transition-timing-function)',
'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm',
'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm',
'data-ending-style:duration-[max(0ms,calc(var(--media-popup-transition-duration)-50ms))]',
// Ensure we animate from the correct origin based on the side the popover is on
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left',
// Safe area between trigger and popup
'before:absolute before:pointer-events-[inherit]',
'data-[side=top]:before:left-0 data-[side=top]:before:right-0 data-[side=top]:before:top-full',
'data-[side=bottom]:before:left-0 data-[side=bottom]:before:right-0 data-[side=bottom]:before:bottom-full',
'data-[side=left]:before:top-0 data-[side=left]:before:bottom-0 data-[side=left]:before:left-full',
'data-[side=right]:before:top-0 data-[side=right]:before:bottom-0 data-[side=right]:before:right-full'
);
export const popup = {
base,
popover: cn(
base,
'data-[side=top]:before:h-(--media-popover-side-offset) data-[side=bottom]:before:h-(--media-popover-side-offset)',
'data-[side=left]:before:w-(--media-popover-side-offset) data-[side=right]:before:w-(--media-popover-side-offset)'
),
tooltip: cn(
base,
'px-2 py-1 rounded-lg text-[0.75rem] whitespace-nowrap',
'data-[open]:flex data-[open]:items-center data-[open]:gap-1',
'bg-(--media-tooltip-background-color) [backdrop-filter:var(--media-tooltip-backdrop-filter)]',
'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/10',
'text-(--media-tooltip-text-color)',
'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)',
'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)'
),
tooltipShortcut: cn(
'min-w-[1.5em] -mr-1 p-[0.1em] bg-current/15 text-[90%] font-semibold font-[inherit] leading-[1.25] text-center rounded'
),
};
@@ -0,0 +1,9 @@
import { cn } from '@videojs/utils/style';
export const poster = cn(
'absolute inset-0 w-full h-full pointer-events-none',
// Fade in/out with the `data-visible` attribute
'transition-opacity duration-250',
'not-data-visible:opacity-0',
'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
);
@@ -0,0 +1,9 @@
import { cn } from '@videojs/utils/style';
export const reset = cn(
'**:box-border',
// Keep authored templates hidden even when component classes set display.
'[&_[hidden][hidden]]:hidden',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]'
);
@@ -0,0 +1,5 @@
export const seek = {
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',
};
@@ -0,0 +1,79 @@
import { cn } from '@videojs/utils/style';
export const slider = {
root: cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none cursor-pointer',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-8',
// Vertical
'data-[orientation=vertical]:w-8 data-[orientation=vertical]:h-[4.5rem]'
),
track: cn(
'relative isolate overflow-hidden bg-current/20 rounded-[inherit] select-none',
// Horizontal
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-0.75',
// Vertical
'data-[orientation=vertical]:w-0.75 data-[orientation=vertical]:h-full'
),
fill: {
base: 'absolute rounded-[inherit] pointer-events-none',
fill: cn(
'bg-current',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
'data-[orientation=horizontal]:w-(--media-slider-fill,0)',
'group-data-dragging/slider:data-[orientation=horizontal]:w-(--media-slider-pointer)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
'data-[orientation=vertical]:h-(--media-slider-fill,0)',
'group-data-dragging/slider:data-[orientation=vertical]:h-(--media-slider-pointer)'
),
buffer: cn(
'bg-current/20 duration-250 ease-out',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer,0)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)'
),
},
thumb: {
base: cn(
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
'bg-current rounded-full',
'shadow-[0_0_0_1px_var(--media-current-shadow-color,oklch(0_0_0/0.15)),0_1px_3px_0_oklch(0_0_0/0.15),0_1px_2px_-1px_oklch(0_0_0/0.15)]',
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-current focus-visible:outline-offset-2',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill,0)',
'group-data-dragging/slider:data-[orientation=horizontal]:left-(--media-slider-pointer)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill,0))]',
'group-data-dragging/slider:data-[orientation=vertical]:top-[calc(100%-var(--media-slider-pointer))]'
),
interactive: cn(
'opacity-0 scale-70 origin-center',
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
),
},
preview: cn(
'group/preview',
'before:block before:bg-current/35 before:opacity-0 before:scale-50',
'before:transition-[opacity,scale] before:duration-200 before:ease-out',
'data-pointing:not-data-dragging:before:opacity-100 data-pointing:not-data-dragging:before:scale-100',
'data-[orientation=horizontal]:before:min-w-px data-[orientation=horizontal]:before:h-5',
'data-[orientation=vertical]:before:w-5 data-[orientation=vertical]:before:min-h-px',
'peer-has-[[role=img]:not([data-hidden])]/thumbnail:*:hidden'
),
value: cn(
'absolute bottom-6 tabular-nums',
'-translate-x-1/2 translate-y-2 scale-50 opacity-0 blur-lg',
'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)',
'transition-[filter,opacity,scale,translate] duration-200 ease-out',
'group-data-pointing/preview:translate-y-0 group-data-pointing/preview:scale-100',
'group-data-pointing/preview:opacity-100 group-data-pointing/preview:blur-none'
),
};
@@ -0,0 +1,13 @@
import { cn } from '@videojs/utils/style';
export const thumbnail = {
root: 'group/thumbnail peer/thumbnail pointer-events-none',
imageWrapper: 'relative rounded-lg bg-black/90',
image: cn('block rounded-[inherit] transition-opacity duration-150 ease-out', 'data-loading:opacity-0'),
time: 'mt-2 block text-center tabular-nums',
spinner: cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0',
'transition-opacity duration-150 ease-out',
'group-not-has-[[role=img][data-loading]]/thumbnail:[--media-spinner-animation:none] group-has-[[role=img][data-loading]]/thumbnail:opacity-100'
),
};
@@ -0,0 +1,9 @@
import { cn } from '@videojs/utils/style';
export const time = {
group: 'flex items-center gap-1',
current: cn('hidden tabular-nums', '@2xl/media-root:inline'),
separator: cn('hidden', '@2xl/media-root:inline @2xl/media-root:text-current/60'),
duration: cn('tabular-nums', '@2xl/media-root:text-current/60'),
controls: cn('@container flex flex-row-reverse items-center flex-1 gap-3', '@2xl/media-root:flex-row'),
};
@@ -0,0 +1,218 @@
import { cn } from '@videojs/utils/style';
import { buttonGroup as baseButtonGroup } from './components/button-group';
import { container as baseContainer } from './components/container';
import { controls as baseControls } from './components/controls';
import { error as baseError } from './components/error';
import { menu as baseMenu } from './components/menu';
import { popup as basePopup } from './components/popup';
import { slider as baseSlider } from './components/slider';
import { thumbnail as baseThumbnail } from './components/thumbnail';
import { time as baseTime } from './components/time';
/* ===========================================================================
Container
========================================================================== */
export const container = cn(
baseContainer,
'bg-black overflow-clip',
// 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/15 dark:after:ring-white/15',
// Video element
'[&_video]:block [&_video]:w-full [&_video]:h-full [&_video]:rounded-[inherit] [&_video]:[object-fit:var(--media-object-fit,contain)] [&_video]:[object-position:var(--media-object-position,center)]',
'[--media-video-border-radius:var(--media-border-radius,0.75rem)]',
'[--media-controls-background-color:transparent]',
'[--media-controls-transition-duration:100ms]',
'[--media-controls-transition-timing-function:ease-out]',
'[--media-error-dialog-transition-duration:150ms]',
'[--media-error-dialog-transition-delay:100ms]',
'[--media-error-dialog-transition-timing-function:ease-out]',
'[--media-popup-transition-duration:100ms]',
'[--media-popup-transition-timing-function:ease-out]',
'[--media-tooltip-background-color:oklch(1_0_0/0.1)]',
'[--media-tooltip-border-color:transparent]',
'[--media-tooltip-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-tooltip-text-color:currentColor]',
'[--media-tooltip-side-offset:0.5rem]',
'[--media-tooltip-boundary-offset:0.5rem]',
'[--media-popover-background-color:oklch(1_0_0/0.1)]',
'[--media-popover-border-color:transparent]',
'[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]',
'[--media-popover-side-offset:1.5rem]',
'[--media-popover-boundary-offset:0.5rem]',
'motion-reduce:[--media-error-dialog-transition-duration:50ms]',
'motion-reduce:[--media-error-dialog-transition-delay:0ms]',
'motion-reduce:[--media-popup-transition-duration:0ms]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-controls-background-color:oklch(0_0_0)]',
'contrast-more:[--media-controls-background-color:oklch(0_0_0)]',
'[@media(prefers-reduced-transparency:reduce)]:[--media-tooltip-background-color:oklch(0_0_0)]',
'contrast-more:[--media-tooltip-background-color:oklch(0_0_0)]',
'@2xl/media-root:*:[--media-popover-side-offset:0.5rem]',
'pointer-fine:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]',
'pointer-coarse:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]',
'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:50ms]',
// Caption track CSS variables (consumed by the native caption bridge in light DOM)
'[--media-caption-track-y:-0.5rem]',
'[--media-caption-track-delay:25ms]',
'[--media-caption-track-duration:var(--media-controls-transition-duration)]',
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-5rem]',
'@2xl/media-root:has-[[data-controls][data-visible]]:*:[--media-caption-track-y:-3rem]',
// Native caption track container
'[&_video::-webkit-media-text-track-container]:transition-[translate]',
'[&_video::-webkit-media-text-track-container]:duration-(--media-caption-track-duration)',
'[&_video::-webkit-media-text-track-container]:ease-out',
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
'[&_video::-webkit-media-text-track-container]:scale-98',
'[&_video::-webkit-media-text-track-container]:z-1',
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
// Fullscreen
'[&:fullscreen]:[--media-border-radius:0]',
'[&:fullscreen_video]:object-contain'
);
/* ===========================================================================
Controls (hide/show behavior)
========================================================================== */
export const controls = cn(
baseControls,
// Position & wrapping layout (small)
'absolute bottom-1 inset-x-1',
'p-1 gap-x-2 flex-wrap rounded-xl',
'text-white z-10',
'peer-data-open/error:hidden',
'ease-(--media-controls-transition-timing-function)',
'duration-(--media-controls-transition-duration)',
'pointer-fine:will-change-[translate,filter,opacity]',
'pointer-fine:transition-[translate,filter,opacity]',
'pointer-coarse:will-change-[translate,opacity]',
'pointer-coarse:transition-[translate,opacity]',
// Hidden state
'not-data-visible:opacity-0 not-data-visible:pointer-events-none',
'motion-safe:not-data-visible:translate-y-full',
'pointer-fine:motion-safe:not-data-visible:blur-sm',
// Single-row layout (large)
'@2xl/media-root:flex-nowrap @2xl/media-root:bottom-2 @2xl/media-root:inset-x-2',
'@2xl/media-root:*:[--media-popover-side-offset:0rem]'
);
/* ===========================================================================
Button groups
========================================================================== */
export const buttonGroupStart = cn(baseButtonGroup, 'flex-1 @2xl/media-root:flex-none');
export const buttonGroupEnd = cn(baseButtonGroup, 'flex-1 justify-end @2xl/media-root:flex-none');
/* ===========================================================================
Time
========================================================================== */
export const time = {
...baseTime,
controls: cn(
baseTime.controls,
'grow-0 shrink-0 basis-full order-[-1] px-2.5',
'@2xl/media-root:grow @2xl/media-root:shrink @2xl/media-root:basis-0 @2xl/media-root:order-[unset]'
),
};
/* ===========================================================================
Error
========================================================================== */
export const error = {
...baseError,
root: cn(baseError.root, 'pointer-events-none outline-none'),
dialog: cn(baseError.dialog, 'pointer-events-auto'),
title: cn(baseError.title, 'text-lg'),
};
/* ===========================================================================
Thumbnail
========================================================================== */
export const thumbnail = {
...baseThumbnail,
root: cn(
baseThumbnail.root,
'[--media-slider-thumbnail-max-width:11rem] [--media-slider-thumbnail-padding:-0.5rem] [--media-slider-thumbnail-inset:calc(100cqi-100%)]',
'absolute [left:clamp(calc(var(--media-slider-thumbnail-max-width)/2+var(--media-slider-thumbnail-padding)),var(--media-slider-pointer),calc(100%-var(--media-slider-thumbnail-max-width)/2-var(--media-slider-thumbnail-padding)+var(--media-slider-thumbnail-inset)))] bottom-full -translate-x-1/2',
'@2xl/media-root:[left:var(--media-slider-pointer)]',
'opacity-0 scale-80 blur-sm origin-bottom',
'transition-[scale,opacity,filter] duration-150',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:opacity-100',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:scale-100',
'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none',
'has-[[role=img][data-loading]]:max-h-24'
),
imageWrapper: cn(
baseThumbnail.imageWrapper,
'after:absolute after:inset-0 after:rounded-[inherit]',
'after:ring-1 after:ring-black/5 after:shadow-sm after:shadow-black/20'
),
image: cn(baseThumbnail.image, 'max-w-(--media-slider-thumbnail-max-width)'),
};
/* ===========================================================================
Sliders
========================================================================== */
export const slider = {
...baseSlider,
track: cn(baseSlider.track, 'ring-1 ring-black/5'),
};
/* ===========================================================================
Popup
========================================================================== */
export const popup = {
...basePopup,
volume: cn(
basePopup.popover,
'py-3 px-0 bg-transparent rounded-xl',
'[@media(prefers-reduced-transparency:reduce)]:bg-(--media-controls-background-color)',
'contrast-more:bg-(--media-controls-background-color)'
),
};
/* ===========================================================================
Menu
========================================================================== */
const menuOffsets = cn(
'[--media-popover-side-offset:1.5rem] [--media-popover-boundary-offset:0.5rem]',
'@2xl/media-root:[--media-popover-side-offset:0.5rem]'
);
export const menu = {
...baseMenu,
root: cn(baseMenu.root, menuOffsets),
settings: cn(baseMenu.settings, menuOffsets),
};
/* ===========================================================================
Shared components (no overrides)
========================================================================== */
export {
airplayIcon,
captionsIcon,
castIcon,
fullscreenIcon,
muteIcon,
pipIcon,
playIcon,
} from '../../shared/tailwind/icon-state';
export { badge } from './components/badge';
export { bufferingIndicator } from './components/buffering';
export { button } from './components/button';
export { buttonGroup } from './components/button-group';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { inputFeedback } from './components/input-feedback';
export { overlay } from './components/overlay';
export { playbackRate } from './components/playback-rate';
export { poster } from './components/poster';
export { seek } from './components/seek';
+56
View File
@@ -0,0 +1,56 @@
@keyframes media-shake {
0%,
100% {
translate: 0 0;
}
20% {
translate: -6px 0;
}
40% {
translate: 4px 0;
}
60% {
translate: -2px 0;
}
80% {
translate: 1px 0;
}
}
@keyframes media-slide-in-forward {
from {
translate: -60% 0;
opacity: 0;
}
}
@keyframes media-slide-in-backward {
from {
translate: 60% 0;
opacity: 0;
}
}
@keyframes media-pop-in {
from {
scale: 0.8;
opacity: 0;
}
}
@property --media-progress-fill {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--animate-media-shake: media-shake 300ms ease-in-out;
--animate-media-slide-in-forward: media-slide-in-forward 300ms ease-in-out;
--animate-media-slide-in-backward: media-slide-in-backward 300ms ease-in-out;
--animate-media-pop-in: media-pop-in 250ms ease-out;
}
@@ -0,0 +1,65 @@
/* ===========================================================================
Icon State
========================================================================== */
import { cn } from '@videojs/utils/style';
export const playIcon = {
button: 'group',
restart: 'hidden opacity-0 group-data-ended:block group-data-ended:opacity-100',
play: cn(
'hidden opacity-0',
'group-not-data-ended:group-data-paused:block',
'group-not-data-ended:group-data-paused:opacity-100',
'group-not-data-ended:group-not-data-started:block',
'group-not-data-ended:group-not-data-started:opacity-100'
),
pause:
'hidden opacity-0 group-data-started:group-not-data-paused:group-not-data-ended:block group-data-started:group-not-data-paused:group-not-data-ended:opacity-100',
};
export const muteIcon = {
button: 'group',
volumeOff: 'hidden opacity-0 group-data-muted:block group-data-muted:opacity-100',
volumeLow:
'hidden opacity-0 group-not-data-muted:group-data-[volume-level=low]:block group-not-data-muted:group-data-[volume-level=low]:opacity-100',
volumeHigh:
'hidden opacity-0 group-not-data-muted:group-not-data-[volume-level=low]:block group-not-data-muted:group-not-data-[volume-level=low]:opacity-100',
};
export const fullscreenIcon = {
button: 'group',
enter: 'hidden opacity-0 group-not-data-fullscreen:block group-not-data-fullscreen:opacity-100',
exit: 'hidden opacity-0 group-data-fullscreen:block group-data-fullscreen:opacity-100',
};
export const captionsIcon = {
button: 'group',
off: 'hidden opacity-0 group-not-data-active:block group-not-data-active:opacity-100',
on: 'hidden opacity-0 group-data-active:block group-data-active:opacity-100',
};
export const pipIcon = {
button: 'group',
off: 'hidden opacity-0 group-not-data-pip:block group-not-data-pip:opacity-100',
on: 'hidden opacity-0 group-data-pip:block group-data-pip:opacity-100',
};
export const castIcon = {
button: 'group',
enter:
'hidden opacity-0 group-not-data-[cast-state=connected]:block group-not-data-[cast-state=connected]:opacity-100',
exit: 'hidden opacity-0 group-data-[cast-state=connected]:block group-data-[cast-state=connected]:opacity-100',
};
export const airplayIcon = {
// The exit SVG remains mounted while inactive, so disable its keyframes until connected.
button: cn(
'group',
'not-data-[airplay-state=connected]:[--media-icon--airplay__fill-animation:none]',
'not-data-[airplay-state=connected]:[--media-icon--airplay__triangle-animation:none]'
),
enter:
'hidden opacity-0 group-not-data-[airplay-state=connected]:block group-not-data-[airplay-state=connected]:opacity-100',
exit: 'hidden opacity-0 group-data-[airplay-state=connected]:block group-data-[airplay-state=connected]:opacity-100',
};
+7 -18
View File
@@ -13,24 +13,13 @@ const entries = Object.fromEntries(
const createConfig = (mode: PackageBuildMode): UserConfig => ({
...packageBuildConfig(mode, 'browser'),
entry: entries,
copy: [
{
from: 'src/**/*.css',
to: `dist/${mode}`,
flatten: false,
},
],
plugins: [
{
name: 'watch-css',
buildStart() {
const cssFiles = globSync('src/**/*.css');
for (const file of cssFiles) {
this.addWatchFile(file);
}
},
},
],
// copy: [
// {
// from: 'src/**/*.css',
// to: `dist/${mode}`,
// flatten: false,
// },
// ],
});
export default defineConfig(packageBuildModes.map((mode) => createConfig(mode)));