feat(skin): implement default and minimal skins for HTML player (#698)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-03-04 22:35:45 +11:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9a5dfab4e8
commit c5cafae57f
51 changed files with 3513 additions and 2334 deletions
+1 -3
View File
@@ -1,3 +1 @@
.media-skin {
color: var(--media-color, red);
}
@import "@videojs/skins/audio/default.css";
+1 -21
View File
@@ -1,21 +1 @@
.media-background-skin {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
}
.media-background-skin > video,
.media-background-skin > .media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: inherit;
}
.media-background-skin > :where(img, picture) {
width: 100%;
height: 100%;
object-fit: inherit;
}
@import "@videojs/skins/background/default.css";
@@ -1,707 +1 @@
/* ==========================================================================
Reset
========================================================================== */
.media-minimal-skin *,
.media-minimal-skin *::before,
.media-minimal-skin *::after {
box-sizing: border-box;
margin: 0;
}
.media-minimal-skin img,
.media-minimal-skin video,
.media-minimal-skin svg {
display: block;
max-width: 100%;
}
.media-minimal-skin button {
font: inherit;
}
@media (prefers-reduced-motion: no-preference) {
.media-minimal-skin {
interpolate-size: allow-keywords;
}
}
/* ==========================================================================
Root Container
========================================================================== */
.media-minimal-skin {
position: relative;
isolation: isolate;
container: media-root / inline-size;
overflow: clip;
border-radius: var(--media-border-radius, 0.75rem);
background: oklch(0 0 0);
font-family:
Inter Variable,
Inter,
ui-sans-serif,
system-ui,
sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
letter-spacing: normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
}
/* Border ring */
.media-minimal-skin::after {
content: "";
position: absolute;
inset: 0;
z-index: 10;
border-radius: inherit;
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.15);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.media-minimal-skin::after {
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
}
}
/* Fullscreen */
.media-minimal-skin:fullscreen {
border-radius: 0;
}
/* ==========================================================================
Media Element
========================================================================== */
.media-minimal-skin > video {
width: 100%;
height: 100%;
}
/* ==========================================================================
Poster Image
========================================================================== */
.media-minimal-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.25s;
pointer-events: none;
}
.media-minimal-skin > img:not([data-visible]) {
opacity: 0;
}
/* ==========================================================================
Overlay / Scrim
========================================================================== */
.media-minimal-skin .media-overlay {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.7), oklch(0 0 0 / 0.5) 7.5rem, oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
opacity: 0;
transition-property: opacity, backdrop-filter;
transition-duration: 500ms;
transition-delay: 500ms;
transition-timing-function: ease-out;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-overlay {
transition-duration: 100ms;
}
}
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay,
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
opacity: 1;
transition-duration: 150ms;
transition-delay: 0ms;
}
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
}
/* ==========================================================================
Buffering Indicator
========================================================================== */
.media-minimal-skin .media-buffering-indicator {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: oklch(1 0 0);
pointer-events: none;
}
/* ==========================================================================
Error Dialog
========================================================================== */
.media-minimal-skin .media-error {
display: none;
}
.media-minimal-skin .media-error[data-visible] {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.media-minimal-skin .media-error__dialog {
display: none;
transition-property: display, opacity, transform;
transition-duration: 500ms;
transition-delay: 100ms;
transition-behavior: allow-discrete;
transition-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-minimal-skin .media-error[data-visible] .media-error__dialog {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 16rem;
padding: 1rem;
color: oklch(1 0 0);
font-size: 0.875rem;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.5);
pointer-events: auto;
transform: scale(1);
opacity: 1;
color: oklch(1 0 0);
@starting-style {
transform: scale(0.5);
opacity: 0;
}
}
.media-minimal-skin .media-error__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.375rem 0;
}
.media-minimal-skin .media-error__title {
font-weight: 600;
line-height: 1.25;
}
.media-minimal-skin .media-error__actions {
display: flex;
gap: 0.5rem;
}
.media-minimal-skin .media-error__actions > * {
flex: 1;
}
/* ==========================================================================
Controls
========================================================================== */
.media-minimal-skin .media-controls {
position: absolute;
bottom: 0;
inset-inline: 0;
z-index: 10;
container: media-controls / inline-size;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 2rem 0.375rem 0.375rem 0.375rem;
color: oklch(1 0 0);
will-change: transform, filter, opacity;
transition-property: transform, filter, opacity;
transition-duration: 75ms;
transition-delay: 0ms;
transition-timing-function: ease-out;
}
.media-minimal-skin .media-controls:not([data-visible]) {
opacity: 0;
transform: translateY(100%);
filter: blur(8px);
transition-duration: 500ms;
transition-delay: 500ms;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-controls:not([data-visible]) {
scale: 1;
transform: translateY(0);
filter: blur(0);
transition-duration: 100ms;
}
}
@container media-root (width > 40rem) {
.media-minimal-skin .media-controls {
gap: 0.875rem;
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
}
}
/* ==========================================================================
Time Controls & Display
========================================================================== */
.media-minimal-skin .media-time-controls {
display: flex;
flex-direction: row-reverse;
align-items: center;
flex: 1;
gap: 0.75rem;
}
.media-minimal-skin .media-time {
display: flex;
align-items: center;
gap: 0.25rem;
}
.media-minimal-skin .media-time__value {
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.2);
}
.media-minimal-skin .media-time__value--current,
.media-minimal-skin .media-time__separator {
display: none;
}
@container media-controls (width > 28rem) {
.media-minimal-skin .media-time-controls {
flex-direction: row;
}
.media-minimal-skin .media-time__value--duration,
.media-minimal-skin .media-time__separator {
color: oklch(1 0 0 / 0.5);
}
.media-minimal-skin .media-time__value--current,
.media-minimal-skin .media-time__separator {
display: inline;
}
}
/* ==========================================================================
Button Groups
========================================================================== */
.media-minimal-skin .media-button-group {
display: flex;
align-items: center;
gap: 0.075rem;
}
@container media-root (width > 40rem) {
.media-minimal-skin .media-button-group {
gap: 0.125rem;
}
}
/* ==========================================================================
Buttons
========================================================================== */
/* Base button */
.media-minimal-skin .media-button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.5rem 1rem;
background: oklch(1 0 0);
border: none;
border-radius: 0.5rem;
outline: 2px solid transparent;
outline-offset: -2px;
color: oklch(0 0 0);
font-weight: 500;
transition-property: background-color, color, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: pointer;
user-select: none;
}
.media-minimal-skin .media-button:focus-visible {
outline-color: oklch(1 0 0);
outline-offset: 2px;
}
.media-minimal-skin .media-button[disabled] {
opacity: 0.5;
filter: grayscale(1);
cursor: not-allowed;
}
/* Icon button variant */
.media-minimal-skin .media-button--icon {
display: grid;
width: 2.375rem;
padding: 0;
aspect-ratio: 1;
background: transparent;
color: oklch(1 0 0);
}
.media-minimal-skin .media-button--icon:hover,
.media-minimal-skin .media-button--icon:focus-visible,
.media-minimal-skin .media-button--icon[aria-expanded="true"] {
color: oklch(1 0 0 / 0.8);
text-decoration: none;
}
.media-minimal-skin .media-button--icon .media-icon {
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
}
/* Seek button variant — hidden at small sizes */
@container media-controls (width < 28rem) {
.media-minimal-skin .media-button--seek {
display: none;
}
}
/* Playback rate button */
.media-minimal-skin .media-button--playback-rate {
padding: 0;
}
.media-minimal-skin .media-button--playback-rate span {
width: 4ch;
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Icons
========================================================================== */
.media-minimal-skin .media-icon__container {
position: relative;
}
.media-minimal-skin .media-icon {
flex-shrink: 0;
grid-area: 1 / 1;
width: 18px;
height: 18px;
transition-behavior: allow-discrete;
transition-property: display, opacity;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.media-minimal-skin .media-icon--hidden {
display: none;
opacity: 0;
}
.media-minimal-skin .media-icon--flipped {
scale: -1 1;
}
/* Seek icon label positioning */
.media-minimal-skin .media-icon--seek ~ .media-icon__label {
position: absolute;
right: 0;
bottom: -3px;
font-size: 0.75em;
font-weight: 480;
font-variant-numeric: tabular-nums;
}
.media-minimal-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
right: unset;
left: 0;
}
/* ==========================================================================
Slider
========================================================================== */
.media-minimal-skin .media-slider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation */
.media-minimal-skin .media-slider[data-orientation="horizontal"] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation */
.media-minimal-skin .media-slider[data-orientation="vertical"] {
width: 1.25rem;
height: 4.5rem;
}
/* Track */
.media-minimal-skin .media-slider__track {
position: relative;
isolation: isolate;
overflow: hidden;
background-color: oklch(1 0 0 / 0.2);
border-radius: inherit;
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
user-select: none;
}
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] {
width: 100%;
height: 0.1875rem;
}
.media-minimal-skin .media-slider__track[data-orientation="vertical"] {
width: 0.1875rem;
height: 100%;
}
/* Thumb */
.media-minimal-skin .media-slider__thumb {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
width: 0.75rem;
height: 0.75rem;
background-color: oklch(1 0 0);
border-radius: calc(infinity * 1px);
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.1),
0 1px 3px 0 oklch(0 0 0 / 0.15),
0 1px 2px -1px oklch(0 0 0 / 0.15);
opacity: 0;
scale: 0.7;
transform-origin: center;
transition-property: opacity, scale, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
user-select: none;
outline: 2px solid transparent;
outline-offset: -2px;
}
.media-minimal-skin .media-slider__thumb[data-orientation="horizontal"] {
top: 50%;
left: var(--media-slider-fill);
}
.media-minimal-skin .media-slider__thumb[data-orientation="vertical"] {
left: 50%;
top: calc(100% - var(--media-slider-fill));
}
.media-minimal-skin .media-slider__thumb:focus-visible {
outline-color: oklch(1 0 0);
outline-offset: 2px;
}
.media-minimal-skin .media-slider:hover .media-slider__thumb,
.media-minimal-skin .media-slider:focus-within .media-slider__thumb,
.media-minimal-skin .media-slider__thumb--persistent {
opacity: 1;
scale: 1;
}
/* Shared track fills */
.media-minimal-skin .media-slider__buffer,
.media-minimal-skin .media-slider__fill {
position: absolute;
border-radius: inherit;
pointer-events: none;
}
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"],
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
inset-block: 0;
left: 0;
}
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"],
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
inset-inline: 0;
bottom: 0;
}
/* Buffer */
.media-minimal-skin .media-slider__buffer {
background-color: oklch(1 0 0 / 0.2);
transition-duration: 0.25s;
transition-timing-function: ease-out;
}
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"] {
width: var(--media-slider-buffer);
transition-property: width;
}
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"] {
height: var(--media-slider-buffer);
transition-property: height;
}
/* Fill */
.media-minimal-skin .media-slider__fill {
background-color: oklch(1 0 0);
}
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
width: var(--media-slider-fill);
}
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
height: var(--media-slider-fill);
}
/* Time display within slider */
.media-minimal-skin .media-slider__time-display {
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Popups & Animations
========================================================================== */
.media-minimal-skin .media-popup-animation {
opacity: 1;
transform: scale(1);
transform-origin: bottom;
filter: blur(0px);
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
}
.media-minimal-skin .media-popup-animation[data-starting-style],
.media-minimal-skin .media-popup-animation[data-ending-style] {
opacity: 0;
transform: scale(0);
filter: blur(8px);
}
.media-minimal-skin .media-popup-animation[data-instant] {
transition-duration: 0ms;
}
.media-minimal-skin .media-popup {
margin: 0;
border: 0;
background: transparent;
--media-popover-side-offset: 0.5rem;
}
.media-minimal-skin .media-popup--volume {
padding: 0.25rem;
}
/* ==========================================================================
Tooltips
========================================================================== */
.media-minimal-skin .media-tooltip {
white-space: nowrap;
}
.media-minimal-skin .media-tooltip-popup {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: oklch(1 0 0 / 0.1);
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.1),
0 2px 4px -2px oklch(0 0 0 / 0.1);
color: oklch(1 0 0);
font-size: 0.75rem;
}
@media (prefers-reduced-transparency: reduce) {
.media-minimal-skin .media-tooltip-popup {
background-color: oklch(0 0 0 / 0.7);
}
}
@media (prefers-contrast: more) {
.media-minimal-skin .media-tooltip-popup {
background-color: oklch(0 0 0 / 0.9);
}
}
/* ==========================================================================
Captions
========================================================================== */
.media-minimal-skin .media-captions {
position: absolute;
inset: auto 1rem 1.5rem 1rem;
z-index: 20;
font-size: 1rem;
text-wrap: balance;
pointer-events: none;
}
.media-minimal-skin .media-captions__container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 42ch;
margin: 0 auto;
text-align: center;
}
.media-minimal-skin .media-captions__text {
display: block;
padding: 0.125rem 0.5rem;
color: oklch(1 0 0);
text-shadow:
0 0 1px oklch(0 0 0 / 0.7),
0 0 8px oklch(0 0 0 / 0.7);
text-align: center;
white-space: pre-wrap;
line-height: 1.2;
}
@media (prefers-contrast: more) {
.media-minimal-skin .media-captions__text {
background: oklch(0 0 0 / 0.7);
text-shadow: none;
box-decoration-break: clone;
}
}
.media-minimal-skin .media-captions__text > * {
display: inline;
}
/* Responsive caption sizing */
@container media-root (width > 20rem) {
.media-minimal-skin .media-captions {
font-size: 1.5rem;
}
}
@container media-root (width > 48rem) {
.media-minimal-skin .media-captions {
font-size: 1.875rem;
}
}
@container media-root (width > 80rem) {
.media-minimal-skin .media-captions {
font-size: 2.25rem;
}
}
/* Caption shifting styles (custom and native) */
.media-minimal-skin {
--media-caption-track-delay: 600ms;
--media-caption-track-y: -0.5rem;
}
.media-minimal-skin:has(.media-controls[data-visible]) {
--media-caption-track-delay: 25ms;
--media-caption-track-y: -3rem;
}
.media-minimal-skin .media-captions,
.media-minimal-skin video::-webkit-media-text-track-container {
/* NOTE: The delay must account for the controls delay/duration */
transition: transform 150ms ease-out;
transition-delay: var(--media-caption-track-delay);
}
.media-minimal-skin video::-webkit-media-text-track-container {
transform: translateY(var(--media-caption-track-y)) scale(0.98);
z-index: 1;
font-family: inherit;
}
/* When controls are visible, shift captions up to avoid overlap */
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-captions,
.media-minimal-skin video::-webkit-media-text-track-container {
transition-duration: 50ms;
}
}
@import "@videojs/skins/video/minimal.css";
@@ -1,4 +1,3 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -14,11 +13,29 @@ import {
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { playbackRate } from '@videojs/skins/video/default.tailwind';
import {
bufferingIndicator,
button,
buttonGroup,
controls,
error,
icon,
iconContainer,
iconFlipped,
iconState,
overlay,
popup,
root,
seek,
slider,
time,
} from '@videojs/skins/video/minimal.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { CaptionsButton } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -35,16 +52,6 @@ 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-drop-shadow-color)] drop-shadow-black/25',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
const iconHidden = 'hidden opacity-0';
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
@@ -55,112 +62,21 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
<button
ref={ref}
type="button"
className={cn(
// Shared
'items-center justify-center shrink-0 border-none cursor-pointer select-none',
'outline-2 outline-transparent -outline-offset-2',
'transition-[background-color,color,outline-offset] duration-150 ease-out',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
// Variant
variant === 'icon'
? cn(
'grid w-[2.375rem] aspect-square bg-transparent rounded-md',
'text-white',
'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'
)
: cn(
'flex py-2 px-4 bg-white rounded-lg',
'text-black font-medium',
'focus-visible:outline-white focus-visible:outline-offset-2'
),
className
)}
className={cn(button.base, variant === 'icon' ? button.icon : button.default, 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 CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
<>
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
</>
);
}
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
// Vertical
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-[4.5rem]',
className
)}
{...props}
/>
);
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] 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',
className
)}
{...props}
/>
);
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
@@ -170,28 +86,7 @@ const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: '
return (
<div
ref={ref}
className={cn(
'absolute rounded-[inherit] pointer-events-none',
{
'bg-white': type === 'fill',
'bg-white/20 duration-250 ease-out': type === 'buffer',
},
// Horizontal
`data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0`,
{
'data-[orientation=horizontal]:w-(--media-slider-fill)': type === 'fill',
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)':
type === 'buffer',
},
// Vertical
`data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0`,
{
'data-[orientation=vertical]:h-(--media-slider-fill)': type === 'fill',
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)':
type === 'buffer',
},
className
)}
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
{...props}
/>
);
@@ -204,27 +99,7 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
return (
<div
ref={ref}
className={cn(
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
'bg-white rounded-full',
'ring ring-black/10 shadow-sm shadow-black/15',
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-white focus-visible:outline-offset-2',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
// Visibility
persistent
? undefined
: 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'
),
className
)}
className={cn(slider.thumb.base, persistent ? undefined : slider.thumb.interactive, className)}
{...props}
/>
);
@@ -236,87 +111,28 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
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-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Resets
'**:box-border **:m-0',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]',
// 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',
// Caption track CSS variables (consumed by native track container below)
'[--media-caption-track-delay:600ms]',
'[--media-caption-track-y:-0.5rem]',
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
// Native caption track container
'[&_video::-webkit-media-text-track-container]:transition-transform',
'[&_video::-webkit-media-text-track-container]:duration-150',
'[&_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]',
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
// Fullscreen
'[&:fullscreen]:rounded-none',
className
)}
{...rest}
>
<Container className={cn(root, className)} {...rest}>
<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
}
render={(props) => (
<div {...props} className={bufferingIndicator}>
<SpinnerIcon className={icon} />
</div>
)}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div
{...props}
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
>
<div
className={cn(
'hidden flex-col gap-3 max-w-64 p-4 text-white text-sm pointer-events-auto',
'group-data-[visible]/error:flex',
'text-shadow-2xs text-shadow-black/50',
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
'starting:opacity-0 starting:scale-50',
'ease-[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)]'
)}
>
<div className="flex flex-col gap-2 py-1.5">
<p id="media-error-title" className="font-semibold leading-tight">
<div {...props} className={error.root}>
<div className={error.dialog}>
<div className={error.content}>
<p id="media-error-title" className={error.title}>
Something went wrong.
</p>
<p id="media-error-description">An error occurred while trying to play the video. Please try again.</p>
</div>
<div className="flex gap-2 *:flex-1">
<div className={error.actions}>
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
@@ -326,35 +142,15 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<Controls.Root
data-controls="" // Used as a hook for Tailwind has-[] styles
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'
)}
className={controls}
>
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
<span className={buttonGroup}>
<PlayButton
render={(props, state) => (
<Button variant="icon" {...props}>
<PlayButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.play.button}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</Button>
)}
/>
@@ -362,12 +158,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button variant="icon" {...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] tabular-nums">
{SEEK_TIME}
</span>
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
@@ -376,29 +170,21 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={icon} />
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">
{SEEK_TIME}
</span>
<span className={cn(seek.label, seek.labelForward)}>{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 text-shadow-2xs text-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 text-shadow-2xs text-shadow-black/25', '@md/media-controls:text-white/50')}
/>
<span className={time.controls}>
<Time.Group className={time.group}>
<Time.Value type="current" className={time.current} />
<Time.Separator className={time.separator} />
<Time.Value type="duration" className={time.duration} />
</Time.Group>
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
@@ -410,41 +196,26 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</TimeSlider.Root>
</span>
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
<span className={buttonGroup}>
<PlaybackRateButton
render={(props, state) => (
<Button variant="icon" {...props} className="p-0">
<span className="w-[4ch] tabular-nums">{state.rate}&times;</span>
</Button>
)}
render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.mute.button}>
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
</Button>
)}
/>
}
/>
<Popover.Popup
className={cn(
// Reset & offset
'm-0 border-0 bg-transparent [--media-popover-side-offset:0.5rem]',
// Volume popup
'p-1',
// Animation
'opacity-100 scale-100 origin-bottom blur-none',
'transition-[transform,scale,opacity,filter] duration-200',
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
'data-[instant]:duration-0'
)}
>
<Popover.Popup className={cn(popup.base, popup.volume)}>
<VolumeSlider.Root
orientation="vertical"
thumbAlignment="edge"
@@ -459,9 +230,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</Popover.Root>
<CaptionsButton
render={(props, state) => (
<Button variant="icon" {...props}>
<CaptionsButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.captions.button}>
<CaptionsOffIcon className={cn(icon, iconState.captions.off)} />
<CaptionsOnIcon className={cn(icon, iconState.captions.on)} />
</Button>
)}
/>
@@ -475,66 +247,25 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
/>
<FullscreenButton
render={(props, state) => (
<Button variant="icon" {...props}>
<FullscreenButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.fullscreen.button}>
<FullscreenEnterIcon className={cn(icon, iconState.fullscreen.enter)} />
<FullscreenExitIcon className={cn(icon, iconState.fullscreen.exit)} />
</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',
)}>
{/* <div className={captions.root}>
<div className={captions.container}>
<span className={captions.cue}>
An example cue
</span>
</div>
</div> */}
<div
className={cn(
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
// Transitions
'transition-[opacity,backdrop-filter] ease-out',
'duration-500 delay-500',
// Shown when controls visible
'peer-data-[visible]/controls:opacity-100',
'peer-data-[visible]/controls:duration-150',
'peer-data-[visible]/controls:delay-0',
// Shown when error visible (+ blur)
'peer-data-[visible]/error:opacity-100',
'peer-data-[visible]/error:duration-150',
'peer-data-[visible]/error:delay-0',
'peer-data-[visible]/error:backdrop-blur-[8px]',
// Reduced motion
'motion-reduce:duration-100'
)}
/>
<div className={overlay} />
{children}
</Container>
@@ -1,4 +1,3 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -18,7 +17,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { CaptionsButton } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -41,61 +40,17 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
});
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
const { ended, paused } = state;
return (
<>
<RestartIcon {...rest} className={cn(className, { 'media-icon--hidden': !ended })} />
<PlayIcon {...rest} className={cn(className, { 'media-icon--hidden': ended || !paused })} />
<PauseIcon {...rest} className={cn(className, { 'media-icon--hidden': paused })} />
</>
);
}
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
const { muted, volumeLevel } = state;
return (
<>
<VolumeOffIcon {...rest} className={cn(className, { 'media-icon--hidden': !muted })} />
<VolumeLowIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel !== 'low' })} />
<VolumeHighIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel === 'low' })} />
</>
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
<>
<FullscreenExitIcon {...rest} className={cn(className, { 'media-icon--hidden': !fullscreen })} />
<FullscreenEnterIcon {...rest} className={cn(className, { 'media-icon--hidden': fullscreen })} />
</>
);
}
export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-minimal-skin', className)} {...rest}>
<BufferingIndicator
render={(props, state) =>
state.visible ? (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
) : null
}
render={(props) => (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
)}
/>
<ErrorDialog
@@ -123,9 +78,11 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<Controls.Root className="media-controls">
<span className="media-button-group">
<PlayButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<PlayButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--play">
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</Button>
)}
/>
@@ -173,20 +130,18 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<span className="media-button-group">
<PlaybackRateButton
render={(props, state) => (
<Button {...props} className="media-button--icon media-button--playback-rate">
<span>{state.rate}&times;</span>
</Button>
)}
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--mute">
<VolumeOffIcon className="media-icon media-icon--volume-off" />
<VolumeLowIcon className="media-icon media-icon--volume-low" />
<VolumeHighIcon className="media-icon media-icon--volume-high" />
</Button>
)}
/>
@@ -203,9 +158,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</Popover.Root>
<CaptionsButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<CaptionsButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--captions">
<CaptionsOffIcon className="media-icon media-icon--captions-off" />
<CaptionsOnIcon className="media-icon media-icon--captions-on" />
</Button>
)}
/>
@@ -219,9 +175,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
/>
<FullscreenButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<FullscreenButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--fullscreen">
<FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
<FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
</Button>
)}
/>
+1 -708
View File
@@ -1,708 +1 @@
/* ==========================================================================
Reset
========================================================================== */
.media-default-skin *,
.media-default-skin *::before,
.media-default-skin *::after {
box-sizing: border-box;
margin: 0;
}
.media-default-skin img,
.media-default-skin video,
.media-default-skin svg {
display: block;
max-width: 100%;
}
.media-default-skin button {
font: inherit;
}
@media (prefers-reduced-motion: no-preference) {
.media-default-skin {
interpolate-size: allow-keywords;
}
}
/* ==========================================================================
Root Container
========================================================================== */
.media-default-skin {
position: relative;
isolation: isolate;
container: media-root / inline-size;
overflow: clip;
border-radius: var(--media-border-radius, 2rem);
background: oklch(0 0 0);
font-family:
Inter Variable,
Inter,
ui-sans-serif,
system-ui,
sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
letter-spacing: normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
}
/* Inner border rings */
.media-default-skin::before,
.media-default-skin::after {
content: "";
position: absolute;
z-index: 10;
border-radius: inherit;
pointer-events: none;
}
.media-default-skin::before {
inset: 1px;
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
}
.media-default-skin::after {
inset: 0;
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.1);
}
/* Fullscreen */
.media-default-skin:fullscreen {
border-radius: 0;
}
/* ==========================================================================
Media Element
========================================================================== */
.media-default-skin > video {
display: block;
width: 100%;
height: 100%;
}
/* ==========================================================================
Poster Image
========================================================================== */
.media-default-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.25s;
pointer-events: none;
}
.media-default-skin > img:not([data-visible]) {
opacity: 0;
}
/* ==========================================================================
Overlay / Scrim
========================================================================== */
.media-default-skin .media-overlay {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.5), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
opacity: 0;
transition-property: opacity, backdrop-filter;
transition-duration: 300ms;
transition-delay: 500ms;
transition-timing-function: ease-out;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-overlay {
transition-duration: 100ms;
}
}
.media-default-skin .media-controls[data-visible] ~ .media-overlay,
.media-default-skin .media-error[data-visible] ~ .media-overlay {
opacity: 1;
transition-duration: 150ms;
transition-delay: 0ms;
}
.media-default-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
}
/* ==========================================================================
Buffering Indicator
========================================================================== */
.media-default-skin .media-buffering-indicator {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: oklch(1 0 0);
pointer-events: none;
}
.media-default-skin .media-buffering-indicator .media-surface {
padding: 0.25rem;
border-radius: 100%;
}
/* ==========================================================================
Error Dialog
========================================================================== */
.media-default-skin .media-error {
display: none;
}
.media-default-skin .media-error[data-visible] {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.media-default-skin .media-error__dialog {
display: none;
transition-property: display, opacity, transform;
transition-duration: 500ms;
transition-delay: 100ms;
transition-behavior: allow-discrete;
transition-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-default-skin .media-error[data-visible] .media-error__dialog {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 18rem;
padding: 0.75rem;
border-radius: 1.75rem;
color: oklch(1 0 0);
font-size: 0.875rem;
pointer-events: auto;
transform: scale(1);
opacity: 1;
@starting-style {
transform: scale(0.5);
opacity: 0;
}
}
.media-default-skin .media-error__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.375rem;
}
.media-default-skin .media-error__title {
font-weight: 600;
line-height: 1.25;
}
.media-default-skin .media-error__description {
opacity: 0.7;
}
.media-default-skin .media-error__actions {
display: flex;
gap: 0.5rem;
}
.media-default-skin .media-error__actions > * {
flex: 1;
}
/* ==========================================================================
Surface (shared glass effect for tooltips, popovers, controls)
========================================================================== */
.media-default-skin .media-surface {
background-color: oklch(1 0 0 / 0.1);
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.05),
0 0 0 1px oklch(0 0 0 / 0.15),
oklch(0 0 0 / 0.15) 0px 1px 3px 0px,
oklch(0 0 0 / 0.15) 0px 1px 2px -1px;
}
@media (prefers-reduced-transparency: reduce) {
.media-default-skin .media-surface {
background-color: oklch(0 0 0 / 0.7);
box-shadow:
inset 0 0 0 1px oklch(0 0 0),
0 0 0 1px oklch(1 0 0 / 0.2);
}
}
@media (prefers-contrast: more) {
.media-default-skin .media-surface {
background-color: oklch(0 0 0 / 0.9);
box-shadow:
inset 0 0 0 1px oklch(0 0 0),
0 0 0 1px oklch(1 0 0 / 0.2);
}
}
/* ==========================================================================
Controls
========================================================================== */
.media-default-skin .media-controls {
position: absolute;
bottom: 0.75rem;
inset-inline: 0.75rem;
z-index: 10;
container: media-controls / inline-size;
display: flex;
align-items: center;
gap: 0.075rem;
padding: 0.175rem;
border-radius: calc(infinity * 1px);
color: oklch(1 0 0);
will-change: scale, transform, filter, opacity;
transition-property: scale, transform, filter, opacity;
transition-duration: 100ms;
transition-delay: 0ms;
transition-timing-function: ease-out;
transform-origin: bottom;
}
.media-default-skin .media-controls:not([data-visible]) {
opacity: 0;
scale: 0.9;
filter: blur(8px);
transition-duration: 300ms;
transition-delay: 500ms;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-controls:not([data-visible]) {
scale: 1;
filter: blur(0);
transition-duration: 100ms;
}
}
@container media-root (width > 40rem) {
.media-default-skin .media-controls {
gap: 0.125rem;
padding: 0.25rem;
}
}
/* ==========================================================================
Time Display
========================================================================== */
.media-default-skin .media-time {
container: media-time / inline-size;
display: flex;
align-items: center;
flex: 1;
gap: 0.75rem;
padding-inline: 0.5rem;
}
.media-default-skin .media-time__value {
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
}
.media-default-skin .media-time .media-time__value:first-child {
display: none;
}
@container media-time (width > 18rem) {
.media-default-skin .media-time .media-time__value:first-child {
display: block;
}
}
/* ==========================================================================
Buttons
========================================================================== */
/* Base button */
.media-default-skin .media-button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.5rem 1rem;
background: oklch(1 0 0);
border: none;
border-radius: calc(infinity * 1px);
outline: 2px solid transparent;
outline-offset: -2px;
color: oklch(0 0 0);
font-weight: 500;
transition-property: background-color, color, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: pointer;
user-select: none;
}
.media-default-skin .media-button:focus-visible {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 2px;
}
.media-default-skin .media-button[disabled] {
opacity: 0.5;
filter: grayscale(1);
cursor: not-allowed;
}
/* Icon button variant */
.media-default-skin .media-button--icon {
display: grid;
width: 2.125rem;
padding: 0;
aspect-ratio: 1;
background: transparent;
color: oklch(1 0 0 / 0.9);
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
}
.media-default-skin .media-button--icon:hover,
.media-default-skin .media-button--icon:focus-visible,
.media-default-skin .media-button--icon[aria-expanded="true"] {
background-color: oklch(1 0 0 / 0.1);
color: oklch(1 0 0);
text-decoration: none;
}
.media-default-skin .media-button--icon .media-icon {
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
}
/* Seek button variant — hidden at small sizes */
@container media-controls (width < 28rem) {
.media-default-skin .media-button--seek {
display: none;
}
}
/* Playback rate button */
.media-default-skin .media-button--playback-rate {
padding: 0;
}
.media-default-skin .media-button--playback-rate span {
width: 4ch;
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Icons
========================================================================== */
.media-default-skin .media-icon__container {
position: relative;
}
.media-default-skin .media-icon {
flex-shrink: 0;
grid-area: 1 / 1;
width: 18px;
height: 18px;
transition-behavior: allow-discrete;
transition-property: display, opacity;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.media-default-skin .media-icon--hidden {
display: none;
opacity: 0;
}
.media-default-skin .media-icon--flipped {
scale: -1 1;
}
/* Seek icon label positioning */
.media-default-skin .media-icon--seek ~ .media-icon__label {
position: absolute;
right: 0;
bottom: -3px;
font-size: 0.75em;
font-weight: 480;
font-variant-numeric: tabular-nums;
}
.media-default-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
right: unset;
left: 0;
}
/* ==========================================================================
Slider
========================================================================== */
.media-default-skin .media-slider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation */
.media-default-skin .media-slider[data-orientation="horizontal"] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation */
.media-default-skin .media-slider[data-orientation="vertical"] {
width: 1.25rem;
height: 5rem;
}
/* Track */
.media-default-skin .media-slider__track {
position: relative;
isolation: isolate;
overflow: hidden;
background-color: oklch(1 0 0 / 0.2);
border-radius: inherit;
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
user-select: none;
}
.media-default-skin .media-slider__track[data-orientation="horizontal"] {
width: 100%;
height: 0.25rem;
}
.media-default-skin .media-slider__track[data-orientation="vertical"] {
width: 0.25rem;
height: 100%;
}
/* Thumb */
.media-default-skin .media-slider__thumb {
z-index: 10;
position: absolute;
transform: translate(-50%, -50%);
width: 0.625rem;
height: 0.625rem;
background-color: oklch(1 0 0);
border-radius: calc(infinity * 1px);
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.1),
0 1px 3px 0 oklch(0 0 0 / 0.15),
0 1px 2px -1px oklch(0 0 0 / 0.15);
opacity: 0;
transition-property: opacity, height, width, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
user-select: none;
outline: 2px solid transparent;
outline-offset: -2px;
}
.media-default-skin .media-slider__thumb[data-orientation="horizontal"] {
top: 50%;
left: var(--media-slider-fill);
}
.media-default-skin .media-slider__thumb[data-orientation="vertical"] {
left: 50%;
top: calc(100% - var(--media-slider-fill));
}
.media-default-skin .media-slider__thumb:focus-visible {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 2px;
}
.media-default-skin .media-slider:active .media-slider__thumb,
.media-default-skin .media-slider__thumb--persistent {
width: 0.75rem;
height: 0.75rem;
}
.media-default-skin .media-slider:hover .media-slider__thumb,
.media-default-skin .media-slider__thumb:focus-visible,
.media-default-skin .media-slider__thumb--persistent {
opacity: 1;
}
/* Shared track fills */
.media-default-skin .media-slider__buffer,
.media-default-skin .media-slider__fill {
position: absolute;
border-radius: inherit;
pointer-events: none;
}
.media-default-skin .media-slider__buffer[data-orientation="horizontal"],
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
inset-block: 0;
left: 0;
}
.media-default-skin .media-slider__buffer[data-orientation="vertical"],
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
inset-inline: 0;
bottom: 0;
}
/* Buffer */
.media-default-skin .media-slider__buffer {
background-color: oklch(1 0 0 / 0.2);
transition-duration: 0.25s;
transition-timing-function: ease-out;
}
.media-default-skin .media-slider__buffer[data-orientation="horizontal"] {
width: var(--media-slider-buffer);
transition-property: width;
}
.media-default-skin .media-slider__buffer[data-orientation="vertical"] {
height: var(--media-slider-buffer);
transition-property: height;
}
/* Fill */
.media-default-skin .media-slider__fill {
background-color: oklch(1 0 0);
}
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
width: var(--media-slider-fill);
}
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
height: var(--media-slider-fill);
}
/* Time display within slider */
.media-default-skin .media-slider__time-display {
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Popups & Animations
========================================================================== */
.media-default-skin .media-popup-animation {
opacity: 1;
transform: scale(1);
transform-origin: bottom;
filter: blur(0px);
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
}
.media-default-skin .media-popup-animation[data-starting-style],
.media-default-skin .media-popup-animation[data-ending-style] {
opacity: 0;
transform: scale(0);
filter: blur(8px);
}
.media-default-skin .media-popup-animation[data-instant] {
transition-duration: 0ms;
}
.media-default-skin .media-popup {
margin: 0;
border: 0;
--media-popover-side-offset: 0.5rem;
}
.media-default-skin .media-popup--volume {
padding: 0.625rem 0.25rem;
border-radius: calc(infinity * 1px);
}
/* ==========================================================================
Tooltips
========================================================================== */
.media-default-skin .media-tooltip {
white-space: nowrap;
}
.media-default-skin .media-tooltip-popup {
padding: 0.25rem 0.625rem;
border-radius: calc(infinity * 1px);
color: oklch(1 0 0);
font-size: 0.75rem;
}
/* ==========================================================================
Captions
========================================================================== */
.media-default-skin .media-captions {
position: absolute;
inset: auto 1rem 1.5rem 1rem;
z-index: 20;
font-size: 1rem;
text-wrap: balance;
pointer-events: none;
}
.media-default-skin .media-captions__container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 42ch;
margin: 0 auto;
text-align: center;
}
.media-default-skin .media-captions__text {
display: block;
padding: 0.125rem 0.5rem;
color: oklch(1 0 0);
text-shadow:
0 0 1px oklch(0 0 0 / 0.7),
0 0 8px oklch(0 0 0 / 0.7);
text-align: center;
white-space: pre-wrap;
line-height: 1.2;
}
@media (prefers-contrast: more) {
.media-default-skin .media-captions__text {
background: oklch(0 0 0 / 0.7);
text-shadow: none;
box-decoration-break: clone;
}
}
.media-default-skin .media-captions__text > * {
display: inline;
}
/* Responsive caption sizing */
@container media-root (width > 20rem) {
.media-default-skin .media-captions {
font-size: 1.5rem;
}
}
@container media-root (width > 48rem) {
.media-default-skin .media-captions {
font-size: 1.875rem;
}
}
@container media-root (width > 80rem) {
.media-default-skin .media-captions {
font-size: 2.25rem;
}
}
/* Caption shifting styles (custom and native) */
.media-default-skin {
--media-caption-track-delay: 600ms;
--media-caption-track-y: -0.5rem;
}
.media-default-skin:has(.media-controls[data-visible]) {
--media-caption-track-delay: 25ms;
--media-caption-track-y: -3.5rem;
}
.media-default-skin .media-captions,
.media-default-skin video::-webkit-media-text-track-container {
/* NOTE: The delay must account for the controls delay/duration */
transition: transform 150ms ease-out;
transition-delay: var(--media-caption-track-delay);
}
.media-default-skin video::-webkit-media-text-track-container {
transform: translateY(var(--media-caption-track-y)) scale(0.98);
z-index: 1;
font-family: inherit;
}
/* When controls are visible, shift captions up to avoid overlap */
.media-default-skin .media-controls[data-visible] ~ .media-captions {
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-captions,
.media-default-skin video::-webkit-media-text-track-container {
transition-duration: 50ms;
}
}
@import "@videojs/skins/video/default.css";
@@ -1,4 +1,3 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -14,11 +13,28 @@ import {
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import {
bufferingIndicator,
button,
controls,
error,
icon,
iconContainer,
iconFlipped,
iconState,
overlay,
playbackRate,
popup,
root,
seek,
slider,
time,
} from '@videojs/skins/video/default.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { CaptionsButton } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -35,29 +51,6 @@ 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)]: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-drop-shadow-color)] drop-shadow-black/25',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
const iconHidden = 'hidden opacity-0';
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
@@ -68,113 +61,21 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
<button
ref={ref}
type="button"
className={cn(
// Shared
'items-center justify-center shrink-0 border-none cursor-pointer select-none',
'outline-2 outline-transparent -outline-offset-2',
'transition-[background-color,color,outline-offset] duration-150 ease-out',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
// Variant
variant === 'icon'
? cn(
'grid w-[2.125rem] aspect-square bg-transparent rounded-full',
'text-white/90',
'text-shadow-2xs text-shadow-black/25',
'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'
)
: cn(
'flex py-2 px-4 bg-white rounded-full',
'text-black font-medium',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2'
),
className
)}
className={cn(button.base, variant === 'icon' ? button.icon : button.default, 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 CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
<>
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
</>
);
}
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
// Vertical
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-20',
className
)}
{...props}
/>
);
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
// Horizontal
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
// Vertical
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full',
className
)}
{...props}
/>
);
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
@@ -184,28 +85,7 @@ const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: '
return (
<div
ref={ref}
className={cn(
'absolute rounded-[inherit] pointer-events-none',
{
'bg-white': type === 'fill',
'bg-white/20 duration-250 ease-out': type === 'buffer',
},
// Horizontal
`data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0`,
{
'data-[orientation=horizontal]:w-(--media-slider-fill)': type === 'fill',
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)':
type === 'buffer',
},
// Vertical
`data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0`,
{
'data-[orientation=vertical]:h-(--media-slider-fill)': type === 'fill',
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)':
type === 'buffer',
},
className
)}
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
{...props}
/>
);
@@ -218,27 +98,7 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
return (
<div
ref={ref}
className={cn(
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
'bg-white rounded-full',
'ring ring-black/10 shadow-sm shadow-black/15',
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
// Visibility
persistent
? 'size-3'
: cn(
'size-2.5',
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
'group-active/slider:size-3'
),
className
)}
className={cn(slider.thumb.base, persistent ? slider.thumb.persistent : slider.thumb.interactive, className)}
{...props}
/>
);
@@ -250,93 +110,32 @@ 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-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Resets
'**:box-border **:m-0',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]',
// 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',
// Caption track CSS variables (consumed by native track container below)
'[--media-caption-track-delay:600ms]',
'[--media-caption-track-y:-0.5rem]',
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
// Native caption track container
'[&_video::-webkit-media-text-track-container]:transition-transform',
'[&_video::-webkit-media-text-track-container]:duration-150',
'[&_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]',
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
// Fullscreen
'[&:fullscreen]:rounded-none',
className
)}
{...rest}
>
<Container className={cn(root, className)} {...rest}>
<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>
render={(props) => (
<div {...props} className={bufferingIndicator.root}>
<div className={bufferingIndicator.container}>
<SpinnerIcon className={icon} />
</div>
) : null
}
</div>
)}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div
{...props}
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
>
<div
className={cn(
'hidden flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white text-sm pointer-events-auto',
'group-data-[visible]/error:flex',
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
'starting:opacity-0 starting:scale-50',
'ease-[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)]',
surface
)}
>
<div className="flex flex-col gap-2 px-2 pt-2 pb-1.5">
<p id="media-error-title" className="font-semibold leading-tight">
<div {...props} className={error.root}>
<div className={error.dialog}>
<div className={error.content}>
<p id="media-error-title" className={error.title}>
Something went wrong.
</p>
<p id="media-error-description" className="opacity-70">
<p id="media-error-description" className={error.description}>
An error occurred while trying to play the video. Please try again.
</p>
</div>
<div className="flex gap-2 *:flex-1">
<div className={error.actions}>
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
@@ -346,35 +145,14 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<Controls.Root
data-controls="" // Used as a hook for Tailwind has-[] styles
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'
)}
className={controls}
>
<PlayButton
render={(props, state) => (
<Button variant="icon" {...props}>
<PlayButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.play.button}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</Button>
)}
/>
@@ -382,10 +160,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button variant="icon" {...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] tabular-nums">{SEEK_TIME}</span>
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
@@ -394,20 +172,17 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={icon} />
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">{SEEK_TIME}</span>
<span className={cn(seek.label, seek.labelForward)}>{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 text-shadow-2xs text-shadow-black/25 tabular-nums"
/>
<Time.Group className={time.group}>
<Time.Value type="current" className={time.current} />
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
@@ -415,45 +190,26 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</TimeSlider.Track>
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
</TimeSlider.Root>
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
<Time.Value type="duration" className={time.duration} />
</Time.Group>
<PlaybackRateButton
render={(props, state) => (
<Button variant="icon" {...props}>
<span className="w-[4ch] tabular-nums">{state.rate}&times;</span>
</Button>
)}
/>
<PlaybackRateButton render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />} />
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.mute.button}>
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
</Button>
)}
/>
}
/>
<Popover.Popup
className={cn(
// Reset & offset
'm-0 border-0 [--media-popover-side-offset:0.5rem]',
// Volume popup
'py-2.5 px-1 rounded-full',
// Surface
surface,
// Animation
'opacity-100 scale-100 origin-bottom blur-none',
'transition-[transform,scale,opacity,filter] duration-200',
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
'data-[instant]:duration-0'
)}
>
<Popover.Popup className={cn(popup.base, popup.volume)}>
<VolumeSlider.Root
orientation="vertical"
thumbAlignment="edge"
@@ -468,9 +224,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</Popover.Root>
<CaptionsButton
render={(props, state) => (
<Button variant="icon" {...props}>
<CaptionsButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.captions.button}>
<CaptionsOffIcon className={cn(icon, iconState.captions.off)} />
<CaptionsOnIcon className={cn(icon, iconState.captions.on)} />
</Button>
)}
/>
@@ -484,65 +241,24 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
/>
<FullscreenButton
render={(props, state) => (
<Button variant="icon" {...props}>
<FullscreenButtonIcon state={state} className={icon} />
render={(props) => (
<Button variant="icon" {...props} className={iconState.fullscreen.button}>
<FullscreenEnterIcon className={cn(icon, iconState.fullscreen.enter)} />
<FullscreenExitIcon className={cn(icon, iconState.fullscreen.exit)} />
</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',
)}>
{/* <div className={captions.root}>
<div className={captions.container}>
<span className={captions.cue}>
An example cue
</span>
</div>
</div> */}
<div
className={cn(
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/50 via-black/30 to-transparent',
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
// Transitions
'transition-[opacity,backdrop-filter] ease-out',
'duration-300 delay-500',
// Shown when controls visible
'peer-data-[visible]/controls:opacity-100',
'peer-data-[visible]/controls:duration-150',
'peer-data-[visible]/controls:delay-0',
// Shown when error visible (+ blur)
'peer-data-[visible]/error:opacity-100',
'peer-data-[visible]/error:duration-150',
'peer-data-[visible]/error:delay-0',
'peer-data-[visible]/error:backdrop-blur-[8px]',
// Reduced motion
'motion-reduce:duration-100'
)}
/>
<div className={overlay} />
{children}
</Container>
+26 -69
View File
@@ -1,4 +1,3 @@
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
import {
CaptionsOffIcon,
CaptionsOnIcon,
@@ -18,7 +17,7 @@ import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
import { CaptionsButton } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
@@ -41,63 +40,19 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
});
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
const { ended, paused } = state;
return (
<>
<RestartIcon {...rest} className={cn(className, { 'media-icon--hidden': !ended })} />
<PlayIcon {...rest} className={cn(className, { 'media-icon--hidden': ended || !paused })} />
<PauseIcon {...rest} className={cn(className, { 'media-icon--hidden': paused })} />
</>
);
}
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
const { muted, volumeLevel } = state;
return (
<>
<VolumeOffIcon {...rest} className={cn(className, { 'media-icon--hidden': !muted })} />
<VolumeLowIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel !== 'low' })} />
<VolumeHighIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel === 'low' })} />
</>
);
}
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
const { active } = state;
return (
<>
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
</>
);
}
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
const { fullscreen } = state;
return (
<>
<FullscreenExitIcon {...rest} className={cn(className, { 'media-icon--hidden': !fullscreen })} />
<FullscreenEnterIcon {...rest} className={cn(className, { 'media-icon--hidden': fullscreen })} />
</>
);
}
export function VideoSkin(props: VideoSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-default-skin', className)} {...rest}>
<BufferingIndicator
render={(props, state) =>
state.visible ? (
<div {...props} className="media-buffering-indicator">
<div className="media-surface">
<SpinnerIcon className="media-icon" />
</div>
render={(props) => (
<div {...props} className="media-buffering-indicator">
<div className="media-surface">
<SpinnerIcon className="media-icon" />
</div>
) : null
}
</div>
)}
/>
<ErrorDialog
@@ -124,9 +79,11 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<Controls.Root className="media-surface media-controls">
<PlayButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<PlayButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--play">
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</Button>
)}
/>
@@ -168,20 +125,18 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</Time.Group>
<PlaybackRateButton
render={(props, state) => (
<Button {...props} className="media-button--icon media-button--playback-rate">
<span>{state.rate}&times;</span>
</Button>
)}
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--mute">
<VolumeOffIcon className="media-icon media-icon--volume-off" />
<VolumeLowIcon className="media-icon media-icon--volume-low" />
<VolumeHighIcon className="media-icon media-icon--volume-high" />
</Button>
)}
/>
@@ -198,9 +153,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</Popover.Root>
<CaptionsButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<CaptionsButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--captions">
<CaptionsOffIcon className="media-icon media-icon--captions-off" />
<CaptionsOnIcon className="media-icon media-icon--captions-on" />
</Button>
)}
/>
@@ -214,9 +170,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
/>
<FullscreenButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<FullscreenButtonIcon state={state} className="media-icon" />
render={(props) => (
<Button {...props} className="media-button--icon media-button--fullscreen">
<FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
<FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
</Button>
)}
/>