mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): implement default and minimal video skins (#550)
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { CSSProperties, PropsWithChildren } from 'react';
|
||||
|
||||
export type BaseSkinProps = PropsWithChildren;
|
||||
export type BaseSkinProps<T = unknown> = PropsWithChildren<
|
||||
T & {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -1,3 +1,376 @@
|
||||
.media-minimal-skin * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.media-minimal-skin {
|
||||
color: var(--media-color, red);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 0.75rem);
|
||||
background: oklab(0 0 0);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
.media-minimal-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.15);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.media-minimal-skin::after {
|
||||
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Buffering indicator */
|
||||
.media-minimal-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Poster image */
|
||||
.media-minimal-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-minimal-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-minimal-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.media-minimal-skin > video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Media Container UI Overlay Styling */
|
||||
.media-minimal-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: start;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0));
|
||||
transition-property: opacity;
|
||||
transition-duration: 75ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 500ms;
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Media Control Bar UI/Styles */
|
||||
.media-minimal-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline: 0;
|
||||
padding: 2rem 0.375rem 0.375rem 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
will-change: transform, filter, opacity;
|
||||
transition-property: transform, filter, opacity;
|
||||
transition-delay: 0ms;
|
||||
transition-duration: 75ms;
|
||||
transition-timing-function: ease-out;
|
||||
color: oklab(1 0 0);
|
||||
z-index: 10;
|
||||
}
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
filter: blur(8px);
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 500ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
transition-duration: 100ms;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 640px) {
|
||||
.media-minimal-skin .media-controls {
|
||||
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Time Display */
|
||||
.media-minimal-skin .media-time-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.media-minimal-skin .media-duration-display {
|
||||
display: contents;
|
||||
color: oklab(1 0 0 / 0.5);
|
||||
}
|
||||
.media-minimal-skin .media-time-display {
|
||||
text-shadow: 0 1px 0 oklab(0 0 0 / 0.2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Button Groups (layout only) */
|
||||
.media-minimal-skin .media-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
}
|
||||
@container media-root (width > 640px) {
|
||||
.media-minimal-skin .media-button-group {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.media-minimal-skin .media-button {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
padding: 0.625rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: oklab(1 0 0);
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-button:hover,
|
||||
.media-minimal-skin .media-button:focus-visible,
|
||||
.media-minimal-skin .media-button[aria-expanded="true"] {
|
||||
color: oklab(1 0 0 / 0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-minimal-skin .media-button:focus-visible {
|
||||
outline-color: oklab(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-button[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.media-minimal-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-minimal-skin .media-icon {
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25));
|
||||
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;
|
||||
}
|
||||
.media-minimal-skin .media-icon--seek ~ .media-icon__label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
}
|
||||
.media-minimal-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
/* TimeSlider Component Styles */
|
||||
.media-minimal-skin .media-slider {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation styles */
|
||||
.media-minimal-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation styles */
|
||||
.media-minimal-skin .media-slider[data-orientation="vertical"] {
|
||||
height: 4.5rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background-color: oklab(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
transition: outline-offset 150ms ease-out;
|
||||
box-shadow: 0 0 0 1px oklab(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Horizontal track styles */
|
||||
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.1875rem;
|
||||
}
|
||||
|
||||
/* Vertical track styles */
|
||||
.media-minimal-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.1875rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider:focus-visible .media-slider__track {
|
||||
outline-color: oklab(1 0 0);
|
||||
outline-offset: 6px;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider__thumb {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background-color: oklab(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
box-shadow:
|
||||
0 0 0 1px oklab(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklab(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklab(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
scale: 0.7;
|
||||
transform-origin: center;
|
||||
transition-property: opacity, scale;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider:focus-within .media-slider__thumb {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] .media-slider__thumb {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="vertical"] .media-slider__thumb {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider__pointer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider__progress {
|
||||
background-color: oklab(1 0 0);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-popup-animation {
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-starting-style],
|
||||
.media-minimal-skin .media-popup-animation[data-ending-style] {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-popover-popup {
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Tooltip Component Styles */
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
color: oklab(1 0 0);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
background-color: oklab(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px oklab(0 0 0 / 0.1),
|
||||
0 2px 4px -2px oklab(0 0 0 / 0.1);
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklab(0 0 0 / 0.7);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklab(0 0 0 / 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,161 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PipIcon,
|
||||
PlayIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react/minimal';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { BaseSkinProps } from '../types';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
export type MinimalVideoSkinProps = BaseSkinProps;
|
||||
|
||||
export function MinimalVideoSkin(props: MinimalVideoSkinProps) {
|
||||
const { children } = props;
|
||||
return <div>{children}</div>;
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button(props, ref) {
|
||||
return <button type="button" className="media-button" ref={ref} {...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 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}>
|
||||
{children}
|
||||
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Controls.Root className="media-controls">
|
||||
<div className="media-button-group">
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<PlayButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<span className="media-icon__container">
|
||||
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
|
||||
<span className="media-icon__label">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<span className="media-icon__container">
|
||||
<SeekIcon className="media-icon media-icon--seek" />
|
||||
<span className="media-icon__label">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Time.Group className="media-time-group">
|
||||
<Time.Value type="current" className="media-time-display" />
|
||||
<span className="media-duration-display">
|
||||
<Time.Separator />
|
||||
<Time.Value type="duration" className="media-time-display" />
|
||||
</span>
|
||||
</Time.Group>
|
||||
|
||||
{/* Temporary spacer */}
|
||||
<div style={{ flex: '1', borderRadius: '100vh', height: '4px', background: 'oklab(1 0 0 / 0.2)' }} />
|
||||
|
||||
<div className="media-button-group">
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PiPButton
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<PipIcon className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<FullscreenButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Controls.Root>
|
||||
|
||||
<div className="media-overlay" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,379 @@
|
||||
.media-skin {
|
||||
color: var(--media-color, red);
|
||||
.media-default-skin * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.media-default-skin {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 2rem);
|
||||
background: oklab(0 0 0);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
.media-default-skin::before,
|
||||
.media-default-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 10;
|
||||
}
|
||||
.media-default-skin::before {
|
||||
inset: 1px;
|
||||
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
|
||||
}
|
||||
.media-default-skin::after {
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Buffering indicator */
|
||||
.media-default-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
color: #fff;
|
||||
}
|
||||
.media-default-skin .media-buffering-indicator .media-surface {
|
||||
padding: 0.25rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
/* Poster image */
|
||||
.media-default-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-default-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-default-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.media-default-skin > video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Media Container UI Overlay Styling */
|
||||
.media-default-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: start;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0));
|
||||
backdrop-filter: saturate(1.5) brightness(0.9);
|
||||
transition: opacity 0.15s ease-out;
|
||||
transition-duration: 100ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 300ms;
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Common Surface Styles - e.g. tooltips, popovers, controls */
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklab(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklab(1 0 0 / 0.05),
|
||||
0 0 0 1px oklab(0 0 0 / 0.15),
|
||||
oklab(0 0 0 / 0.15) 0px 1px 3px 0px,
|
||||
oklab(0 0 0 / 0.15) 0px 1px 2px -1px;
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklab(0 0 0 / 0.7);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklab(0 0 0),
|
||||
0 0 0 1px oklab(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklab(0 0 0 / 0.9);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklab(0 0 0),
|
||||
0 0 0 1px oklab(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Media Control Bar UI/Styles */
|
||||
.media-default-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
inset-inline: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: oklab(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
will-change: scale, transform, filter, opacity;
|
||||
transition-property: scale, transform, filter, opacity;
|
||||
transition-timing-function: ease-out;
|
||||
transition-delay: 0ms;
|
||||
transition-duration: 100ms;
|
||||
transform-origin: bottom;
|
||||
z-index: 10;
|
||||
}
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
pointer-events: none;
|
||||
filter: blur(8px);
|
||||
scale: 0.9;
|
||||
opacity: 0;
|
||||
transition-delay: 500ms;
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
transition-duration: 100ms;
|
||||
filter: blur(0);
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Time Display */
|
||||
.media-default-skin .media-time-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
.media-default-skin .media-time-display {
|
||||
text-shadow: 0 1px 0 oklab(0 0 0 / 0.25);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.media-default-skin .media-button {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(infinity * 1px);
|
||||
color: oklab(1 0 0 / 0.9);
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
text-shadow: 0 1px 0 oklab(0 0 0 / 0.25);
|
||||
}
|
||||
.media-default-skin .media-button:hover,
|
||||
.media-default-skin .media-button:focus-visible,
|
||||
.media-default-skin .media-button[aria-expanded="true"] {
|
||||
background-color: oklab(1 0 0 / 0.1);
|
||||
color: oklab(1 0 0);
|
||||
text-decoration: 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] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.media-default-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-default-skin .media-icon {
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25));
|
||||
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;
|
||||
}
|
||||
.media-default-skin .media-icon--seek ~ .media-icon__label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
}
|
||||
.media-default-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
/* TimeSlider Component Styles */
|
||||
.media-default-skin .media-slider {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation styles */
|
||||
.media-default-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation styles */
|
||||
.media-default-skin .media-slider[data-orientation="vertical"] {
|
||||
height: 5rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background-color: oklab(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
transition: outline-offset 150ms ease-out;
|
||||
box-shadow: 0 0 0 1px oklab(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Horizontal track styles */
|
||||
.media-default-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.25rem;
|
||||
}
|
||||
|
||||
/* Vertical track styles */
|
||||
.media-default-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider:focus-visible .media-slider__track {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 6px;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider__thumb {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background-color: oklab(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
box-shadow:
|
||||
0 0 0 1px oklab(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklab(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklab(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
transition-property: opacity, height, width;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-default-skin .media-slider__thumb:active {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.media-default-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-default-skin .media-slider:focus-within .media-slider__thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="horizontal"] .media-slider__thumb {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="vertical"] .media-slider__thumb {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider__pointer {
|
||||
background-color: oklab(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider__progress {
|
||||
background-color: oklab(1 0 0);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.media-default-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-default-skin .media-popup-animation {
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-starting-style],
|
||||
.media-default-skin .media-popup-animation[data-ending-style] {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-default-skin .media-popover-popup {
|
||||
padding: 0.75rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Tooltip Component Styles */
|
||||
.media-default-skin .media-tooltip-popup {
|
||||
color: oklab(1 0 0);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.media-default-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,155 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PipIcon,
|
||||
PlayIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { BaseSkinProps } from '../types';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
export type VideoSkinProps = BaseSkinProps;
|
||||
|
||||
export function VideoSkin(props: VideoSkinProps) {
|
||||
const { children } = props;
|
||||
return <div>{children}</div>;
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button(props, ref) {
|
||||
return <button ref={ref} type="button" className="media-button" {...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 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}>
|
||||
{children}
|
||||
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<div className="media-surface">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Controls.Root className="media-surface media-controls">
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<PlayButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<span className="media-icon__container">
|
||||
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
|
||||
<span className="media-icon__label">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<span className="media-icon__container">
|
||||
<SeekIcon className="media-icon media-icon--seek" />
|
||||
<span className="media-icon__label">{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Time.Group className="media-time-group">
|
||||
<Time.Value type="current" className="media-time-display" />
|
||||
{/* Temporary spacer */}
|
||||
<div style={{ flex: '1', borderRadius: '100vh', height: '4px', background: 'oklab(1 0 0 / 0.2)' }} />
|
||||
<Time.Value type="duration" className="media-time-display" />
|
||||
</Time.Group>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PiPButton
|
||||
render={(props) => (
|
||||
<Button {...props}>
|
||||
<PipIcon className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props}>
|
||||
<FullscreenButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Controls.Root>
|
||||
|
||||
<div className="media-overlay" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './buffering-indicator';
|
||||
@@ -0,0 +1 @@
|
||||
export { FullscreenButton, type FullscreenButtonProps } from './fullscreen-button';
|
||||
@@ -0,0 +1 @@
|
||||
export { MuteButton, type MuteButtonProps } from './mute-button';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './pip-button';
|
||||
@@ -0,0 +1 @@
|
||||
export { PlayButton, type PlayButtonProps } from './play-button';
|
||||
@@ -0,0 +1 @@
|
||||
export { Poster, type PosterProps } from './poster';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './seek-button';
|
||||
@@ -6,7 +6,7 @@ export type HTMLProps<T = any> = React.HTMLAttributes<T> & {
|
||||
};
|
||||
|
||||
/** Render function signature - receives props and state, returns element. */
|
||||
export type RenderFunction<Props, State> = (props: Props, state: State) => ReactElement;
|
||||
export type RenderFunction<Props, State> = (props: Props, state: State) => ReactElement | null;
|
||||
|
||||
/** Render prop - either a React element or a render function. */
|
||||
export type RenderProp<State> = ReactElement | RenderFunction<HTMLProps, State>;
|
||||
|
||||
@@ -37,16 +37,20 @@ export function composeRefs<T>(...refs: (OptionalRef<T> | OptionalRef<T>[])[]):
|
||||
return (node): (() => void) | void => {
|
||||
const cleanups = flatRefs.map((ref) => setRef(ref, node));
|
||||
|
||||
return () => {
|
||||
for (let i = 0; i < cleanups.length; i++) {
|
||||
const cleanup = cleanups[i];
|
||||
if (isFunction(cleanup)) {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(flatRefs[i], null);
|
||||
// Only return cleanup if any refs returned one (React 19+).
|
||||
// React 18 handles cleanup by calling the ref callback with null.
|
||||
if (cleanups.some(isFunction)) {
|
||||
return () => {
|
||||
for (let i = 0; i < cleanups.length; i++) {
|
||||
const cleanup = cleanups[i];
|
||||
if (isFunction(cleanup)) {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(flatRefs[i], null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user