chore(root): archive examples into tech-preview (#315)

This commit is contained in:
rahim
2026-01-20 14:35:51 +11:00
committed by GitHub
parent 78695d4bb6
commit 35c6cb1e16
62 changed files with 33 additions and 1519 deletions
@@ -0,0 +1,146 @@
import type { PropsWithChildren } from 'react';
import { VideoProvider } from '@videojs/react-preview';
import clsx from 'clsx';
import { useEffect, useMemo, useRef } from 'react';
import { Link as LinkPrimitive, Redirect, Route, Switch, useLocation } from 'wouter';
import { VideoElement } from './components';
import { SkinLayout } from './components/SkinLayout';
import { useMediaQuery } from './hooks';
import CustomBaseUISkin from './skins/custom/base-ui/CustomBaseUISkin';
import customBaseUISkinSource from './skins/custom/base-ui/CustomBaseUISkin?raw';
import CustomNativeSkin from './skins/custom/native/CustomNativeSkin';
import customNativeSkinSource from './skins/custom/native/CustomNativeSkin?raw';
import FrostedSkinEjected from './skins/ejected/frosted/FrostedSkin';
import frostedSkinEjectedSource from './skins/ejected/frosted/FrostedSkin?raw';
import MinimalSkinEjected from './skins/ejected/minimal/MinimalSkin';
import minimalSkinEjectedSource from './skins/ejected/minimal/MinimalSkin?raw';
import FrostedSkin from './skins/imported/FrostedSkin';
import frostedSkinSource from './skins/imported/FrostedSkin?raw';
import MinimalSkin from './skins/imported/MinimalSkin';
import minimalSkinSource from './skins/imported/MinimalSkin?raw';
import './globals.css';
type NavigationSectionProps = PropsWithChildren<{
title: string;
}>;
function NavigationSection(props: NavigationSectionProps): JSX.Element {
return (
<div className="space-y-1.5">
<div className="text-xs text-zinc-400 px-3.5 dark:text-zinc-500">
{props.title}
</div>
<nav className="space-y-px">{props.children}</nav>
</div>
);
}
type LinkProps = PropsWithChildren<{
to: string;
}>;
function Link(props: LinkProps): JSX.Element {
return (
<LinkPrimitive
{...props}
className={isActive => clsx('flex px-3.5 py-2 rounded-lg transition-colors text-sm', {
'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700': !isActive,
'bg-zinc-200/60 text-zinc-900 dark:bg-zinc-200': isActive,
})}
/>
);
}
export default function App(): JSX.Element {
const skinProps = useMemo(() => ({
className: 'aspect-video shadow-lg shadow-black/15',
children: <VideoElement />,
}), []);
const isDesktop = useMediaQuery('(min-width: 768px)');
const navigationRef = useRef<HTMLElement>(null);
useEffect(() => {
if (isDesktop) {
navigationRef.current?.removeAttribute('popover');
} else {
navigationRef.current?.setAttribute('popover', 'auto');
}
}, [isDesktop]);
const [location] = useLocation();
useEffect(() => {
try {
navigationRef.current?.hidePopover();
} catch {}
}, [location]);
return (
<div className="min-h-screen text-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 flex">
<button type="button" className="p-3 absolute top-7 left-7 md:hidden cursor-pointer z-10" popoverTarget="navigation" popoverTargetAction="show">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-5">
<path fillRule="evenodd" d="M2 6.75A.75.75 0 0 1 2.75 6h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 6.75Zm0 6.5a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clipRule="evenodd" />
</svg>
<span className="sr-only">Open sidebar</span>
</button>
<header ref={navigationRef} className="p-3 w-72 bg-zinc-50 space-y-8 dark:bg-zinc-800 max-md:h-full popover-from-left max-md:shadow-xl max-md:shadow-black/15" popover="auto" id="navigation">
<h1 className="text-xl tracking-tight pt-6 px-3 flex items-center gap-2">
<span className="font-semibold text-zinc-600 dark:text-white">Video.js</span>
<span className="text-zinc-300 dark:text-zinc-700 font-extralight text-[80%]">/</span>
<span className="text-zinc-500 font-normal dark:text-zinc-100">React</span>
</h1>
<div className="space-y-6">
<NavigationSection title="Imported skins">
<Link to="/imported/frosted">Frosted</Link>
<Link to="/imported/minimal">Minimal</Link>
</NavigationSection>
<NavigationSection title="Ejected skins">
<Link to="/ejected/frosted">Frosted</Link>
<Link to="/ejected/minimal">Minimal</Link>
</NavigationSection>
<NavigationSection title="Build your own">
<Link to="/custom/native">Native</Link>
<Link to="/custom/base-ui">Base UI</Link>
</NavigationSection>
</div>
</header>
<main
className="flex-1 flex justify-center items-center px-6"
>
<VideoProvider>
<Switch>
<Route path="/imported/frosted">
<SkinLayout code={frostedSkinSource} preview={<FrostedSkin {...skinProps} />} />
</Route>
<Route path="/imported/minimal">
<SkinLayout code={minimalSkinSource} preview={<MinimalSkin {...skinProps} />} />
</Route>
<Route path="/ejected/frosted">
<SkinLayout code={frostedSkinEjectedSource} preview={<FrostedSkinEjected {...skinProps} />} />
</Route>
<Route path="/ejected/minimal">
<SkinLayout code={minimalSkinEjectedSource} preview={<MinimalSkinEjected {...skinProps} />} />
</Route>
<Route path="/custom/native">
<SkinLayout code={customNativeSkinSource} preview={<CustomNativeSkin {...skinProps} />} />
</Route>
<Route path="/custom/base-ui">
<SkinLayout code={customBaseUISkinSource} preview={<CustomBaseUISkin {...skinProps} />} />
</Route>
<Route>
<Redirect to="/imported/frosted" />
</Route>
</Switch>
</VideoProvider>
</main>
</div>
);
}
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { codeToHtml } from 'shiki';
import { useColorScheme } from '@/hooks';
export interface CodeBlockProps {
code: string;
}
export function CodeBlock(props: CodeBlockProps): JSX.Element {
const { code } = props;
const [output, setOutput] = useState<string>('');
const colorScheme = useColorScheme();
useEffect(() => {
codeToHtml(code, {
theme: colorScheme === 'light' ? 'vitesse-light' : 'vitesse-dark',
lang: 'javascript',
}).then((result) => {
setOutput(result);
});
}, [code, colorScheme]);
return (
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
<div dangerouslySetInnerHTML={{ __html: output }} className="h-full [&_pre]:rounded-lg [&_pre]:overflow-auto [&_pre]:h-full [&_pre]:w-0 [&_pre]:min-w-full font-mono text-sm *:p-3" />
);
}
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { CodeBlock } from './CodeBlock';
import { Tabs } from './Tabs';
export interface SkinLayoutProps {
code: string;
preview: ReactNode;
}
export function SkinLayout(props: SkinLayoutProps): JSX.Element {
const { code, preview } = props;
return (
<Tabs.Root defaultValue="preview" className="h-full flex flex-col flex-1 max-w-5xl mx-auto py-8 gap-6">
<Tabs.List className="justify-center">
<Tabs.Tab value="preview">Preview</Tabs.Tab>
<Tabs.Tab value="source">Source</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Panel value="preview" className="flex-1 flex flex-col justify-center">
{preview}
</Tabs.Panel>
<Tabs.Panel value="source" className="flex-1">
<CodeBlock code={code} />
</Tabs.Panel>
</Tabs.Root>
);
}
@@ -0,0 +1,18 @@
import type { ComponentProps } from 'react';
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import clsx from 'clsx';
export function Tab(props: ComponentProps<typeof TabsPrimitive.Tab>): JSX.Element {
const { className, ...rest } = props;
return (
<TabsPrimitive.Tab
className={clsx(
'flex h-8 items-center justify-center border-0 px-2 text-sm font-medium break-keep whitespace-nowrap text-zinc-600 outline-none select-none before:inset-x-0 before:inset-y-1 before:rounded-sm before:-outline-offset-1 before:outline-blue-800 hover:text-zinc-900 focus-visible:relative focus-visible:before:absolute focus-visible:before:outline-2 data-active:text-zinc-900 ',
// Dark mode styles
'dark:text-zinc-400 dark:hover:text-zinc-100 dark:data-active:text-zinc-900',
className,
)}
{...rest}
/>
);
}
@@ -0,0 +1,16 @@
import type { ComponentProps } from 'react';
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import clsx from 'clsx';
export function TabIndicator(props: ComponentProps<typeof TabsPrimitive.Indicator>): JSX.Element {
const { className, ...rest } = props;
return (
<TabsPrimitive.Indicator
className={clsx(
'absolute top-1/2 left-0 z-[-1] h-6 w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-1/2 rounded-sm bg-zinc-100 transition-all duration-200 ease-in-out',
className,
)}
{...rest}
/>
);
}
@@ -0,0 +1,13 @@
import type { ComponentProps } from 'react';
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import clsx from 'clsx';
export function TabList(props: ComponentProps<typeof TabsPrimitive.List>): JSX.Element {
const { className, ...rest } = props;
return (
<TabsPrimitive.List
className={clsx('flex gap-1 relative z-0', className)}
{...rest}
/>
);
}
@@ -0,0 +1,16 @@
import type { ComponentProps } from 'react';
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import clsx from 'clsx';
export function TabPanel(props: ComponentProps<typeof TabsPrimitive.Panel>): JSX.Element {
const { className, ...rest } = props;
return (
<TabsPrimitive.Panel
className={clsx(
'outline-blue-800 focus-visible:rounded-md focus-visible:outline-2',
className,
)}
{...rest}
/>
);
}
@@ -0,0 +1,14 @@
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import { Tab } from './Tab';
import { TabIndicator } from './TabIndicator';
import { TabList } from './TabList';
import { TabPanel } from './TabPanel';
export const Tabs = {
...TabsPrimitive,
Tab,
List: TabList,
Panel: TabPanel,
Indicator: TabIndicator,
} as typeof TabsPrimitive;
@@ -0,0 +1,12 @@
import { HlsVideo } from '@videojs/react-preview';
export function VideoElement(): JSX.Element {
return (
<HlsVideo
// @ts-expect-error -- types are incorrect
src="https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ.m3u8"
poster="https://image.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ/thumbnail.webp"
playsInline
/>
);
}
@@ -0,0 +1,4 @@
export * from './CodeBlock';
export * from './SkinLayout';
export * from './Tabs';
export * from './VideoElement';
@@ -0,0 +1,45 @@
@import 'tailwindcss';
@theme {
--font-sans:
InterVariable, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
/* Make reduced-transparency variant */
@custom-variant reduced-transparency @media (prefers-reduced-transparency: reduce);
.vjs {
font-family: var(--font-sans);
}
[popover].popover-from-left {
translate: -100% 0;
}
[popover] {
transition:
opacity 0.25s,
translate 0.25s,
overlay 0.25s allow-discrete,
display 0.25s allow-discrete;
opacity: 0;
&:popover-open {
opacity: 1;
translate: 0 0;
}
}
@starting-style {
[popover] {
&:popover-open {
opacity: 0;
}
}
[popover].popover-from-left {
&:popover-open {
translate: -100% 0;
}
}
}
@@ -0,0 +1,2 @@
export * from './useColorScheme';
export * from './useMediaQuery';
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
type ColorScheme = 'light' | 'dark';
export function useColorScheme(): ColorScheme {
const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
if (typeof window === 'undefined') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setColorScheme(event.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
return colorScheme;
}
@@ -0,0 +1,4 @@
import { useEffect, useLayoutEffect } from 'react';
export const useIsomorphicLayoutEffect: typeof useLayoutEffect | typeof useEffect
= typeof window !== 'undefined' ? useLayoutEffect : useEffect;
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
interface UseMediaQueryOptions {
defaultValue?: boolean;
initializeWithValue?: boolean;
}
const IS_SERVER = typeof window === 'undefined';
export function useMediaQuery(
query: string,
{
defaultValue = false,
initializeWithValue = true,
}: UseMediaQueryOptions = {},
): boolean {
const getMatches = (query: string): boolean => {
if (IS_SERVER) {
return defaultValue;
}
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState<boolean>(() => {
if (initializeWithValue) {
return getMatches(query);
}
return defaultValue;
});
// Handles the change event of the media query.
function handleChange() {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setMatches(getMatches(query));
}
useIsomorphicLayoutEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,366 @@
import type { ButtonProps as ButtonPrimitiveProps } from '@base-ui-components/react/button';
import type { SliderRootProps } from '@base-ui-components/react/slider';
import type { ComponentProps, PropsWithChildren } from 'react';
import { Button as ButtonPrimitive } from '@base-ui-components/react/button';
import { Popover as PopoverPrimitive } from '@base-ui-components/react/popover';
import { Slider as SliderPrimitive } from '@base-ui-components/react/slider';
import { Tooltip as TooltipPrimitive } from '@base-ui-components/react/tooltip';
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
} from '@heroicons/react/16/solid';
import {
PauseIcon,
PlayIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
XMarkIcon,
} from '@heroicons/react/20/solid';
import {
MediaContainer,
useCurrentTimeDisplayState,
useFullscreenButtonState,
useMuteButtonState,
usePlayButtonState,
useTimeSliderRootState,
useVolumeSliderRootState,
} from '@videojs/react-preview';
import { formatDisplayTime } from '@videojs/utils-preview';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
function Surface(props: ComponentProps<'div'>) {
const { className, ...rest } = props;
return (
<div
className={clsx(
// Background
'bg-linear-to-t to-zinc-50/80 from-zinc-100/80 backdrop-blur-md dark:from-zinc-950/80 dark:to-zinc-900/80',
// Border & shadow
'ring-1 ring-black/5 shadow-xs shadow-black/15 dark:ring-white/15',
// Text
'text-zinc-900 dark:text-zinc-50',
className,
)}
{...rest}
/>
);
}
function Popup(props: ComponentProps<typeof Surface>) {
const { className, ...rest } = props;
return (
<Surface
className={clsx(
'origin-(--transform-origin) transition data-instant:duration-0 translate-y-0',
'data-starting-style:scale-90 data-starting-style:translate-y-1 data-starting-style:opacity-0',
'data-ending-style:scale-90 data-ending-style:translate-y-1 data-ending-style:opacity-0',
className,
)}
{...rest}
/>
);
}
function TooltipPopup(props: ComponentProps<typeof TooltipPrimitive.Popup>) {
const { className, ...rest } = props;
return (
<TooltipPrimitive.Popup
render={props => <Popup {...props} className="rounded-md px-2 py-1 text-xs" />}
{...rest}
/>
);
}
const Tooltip = {
...TooltipPrimitive,
Popup: TooltipPopup,
};
function PopoverPopup(props: ComponentProps<typeof PopoverPrimitive.Popup>) {
const { className, ...rest } = props;
return (
<PopoverPrimitive.Popup
render={props => <Popup {...props} className="rounded-lg py-3 px-1.5" />}
{...rest}
/>
);
}
const Popover = {
...PopoverPrimitive,
Popup: PopoverPopup,
};
type ButtonProps = Omit<ButtonPrimitiveProps, 'className'>;
function Button(props: ButtonProps) {
return (
<ButtonPrimitive
className={clsx(
'flex p-1.75 hover:bg-black/5 text-zinc-700 hover:text-zinc-800 transition-colors rounded-md outline-2 outline-transparent',
// Focus styles
'focus-visible:outline-zinc-700',
// Dark styles
'dark:text-zinc-100 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-zinc-300',
)}
{...props}
/>
);
}
function PlayButton() {
const { paused, requestPause, requestPlay } = usePlayButtonState();
const onClick = useCallback(() => {
if (paused) {
requestPlay();
} else {
requestPause();
}
}, [paused, requestPlay, requestPause]);
const label = paused ? 'Play' : 'Pause';
return (
<Tooltip.Root>
<Tooltip.Trigger render={(
<Button onClick={onClick} data-paused={paused ? '' : undefined}>
{paused ? <PlayIcon className="size-5" /> : <PauseIcon className="size-5" />}
<span className="sr-only">{label}</span>
</Button>
)}
/>
<Tooltip.Portal>
<Tooltip.Positioner sideOffset={12} align="start">
<Tooltip.Popup>
{label}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
);
}
function MuteButton() {
const { muted, requestMute, requestUnmute } = useMuteButtonState();
const [open, setOpen] = useState(false);
const onClick = useCallback(() => {
if (muted) {
requestUnmute();
} else {
requestMute();
}
}, [muted, requestMute, requestUnmute]);
const handleOpenChange = useCallback((isOpen: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => {
// Don't close when clicking the trigger button - only allow hover to control it
if (!isOpen && eventDetails?.reason === 'trigger-press') return;
setOpen(isOpen);
}, []);
const label = muted ? 'Unmute' : 'Mute';
return (
<Popover.Root open={open} onOpenChange={handleOpenChange}>
<Popover.Trigger
openOnHover
delay={20}
closeDelay={200}
render={(
<Button onClick={onClick}>
{muted ? <SpeakerXMarkIcon className="size-5" /> : <SpeakerWaveIcon className="size-5" />}
<span className="sr-only">{label}</span>
</Button>
)}
/>
<Popover.Portal>
<Popover.Backdrop />
<Popover.Positioner sideOffset={12} side="top">
<Popover.Popup className="flex items-center justify-center">
<VolumeSlider />
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
function FullscreenButton() {
const { fullscreen, requestEnterFullscreen, requestExitFullscreen } = useFullscreenButtonState();
const onClick = useCallback(() => {
if (fullscreen) {
requestExitFullscreen();
} else {
requestEnterFullscreen();
}
}, [fullscreen, requestEnterFullscreen, requestExitFullscreen]);
const label = fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
return (
<Tooltip.Root>
<Tooltip.Trigger render={(
<Button onClick={onClick}>
{fullscreen
? <XMarkIcon className="size-5" />
: (
<div className="grid [&_svg]:[grid-area:1/1] size-5 place-content-center">
<ArrowDownLeftIcon className="size-4 -translate-x-0.5 translate-y-0.5" />
<ArrowUpRightIcon className="size-4 translate-x-0.5 -translate-y-0.5" />
</div>
)}
<span className="sr-only">{label}</span>
</Button>
)}
/>
<Tooltip.Portal>
<Tooltip.Positioner sideOffset={12} align="end">
<Tooltip.Popup>
{label}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
);
}
type SliderProps = Pick<SliderRootProps<number>, 'min' | 'max' | 'step' | 'value' | 'onValueChange' | 'orientation'>;
function Slider(props: SliderProps) {
return (
<SliderPrimitive.Root
{...props}
thumbAlignment="edge"
className="flex-1 group/slider"
>
<SliderPrimitive.Control className={clsx(
'flex items-center',
'data-[orientation="horizontal"]:h-5 data-[orientation="horizontal"]:min-w-20',
'data-[orientation="vertical"]:w-5 data-[orientation="vertical"]:h-20 data-[orientation="vertical"]:justify-center',
)}
>
<SliderPrimitive.Track className={clsx(
'relative select-none rounded-full bg-black/10 dark:bg-white/10',
'data-[orientation="horizontal"]:w-full data-[orientation="horizontal"]:h-1',
'data-[orientation="vertical"]:w-1 data-[orientation="vertical"]:h-full',
)}
>
<SliderPrimitive.Indicator className="bg-zinc-700 dark:bg-white rounded-[inherit]" />
<SliderPrimitive.Thumb className={clsx(
'z-10 bg-white size-3 select-none ring ring-black/10 rounded-full shadow-xs shadow-black/15 transition-opacity ease-in-out',
// Focus styles (the hidden input inside the thumb gets focus)
'-outline-offset-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-500',
)}
/>
</SliderPrimitive.Track>
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
function TimeSlider() {
// TODO: Expose something like _fillWidth to facilitate debouncing etc.
const { currentTime, duration, requestSeek } = useTimeSliderRootState();
return (
<Slider
// FIXME: If seems that currentTime can be undefined initially
value={currentTime ?? 0}
// FIXME: If seems that duration can be undefined initially
// Base UI will throw an error if max is less than min (which is 0)
max={!duration ? 1 : duration}
step={0.1}
onValueChange={requestSeek}
/>
);
}
function VolumeSlider() {
const { volume, muted, requestVolumeChange } = useVolumeSliderRootState();
// FIXME: If seems that volume can be undefined initially, despite the types.
const value = muted ? 0 : volume ?? 0;
return (
<Slider
value={value}
max={1}
step={0.1}
onValueChange={requestVolumeChange}
orientation="vertical"
/>
);
}
interface TimeDisplayProps {
type: 'current' | 'duration';
className?: string;
}
function TimeDisplay(props: TimeDisplayProps) {
const { type, className } = props;
const { duration, currentTime } = useCurrentTimeDisplayState();
const value = formatDisplayTime(type === 'current' ? currentTime : duration);
return <span className={clsx('tabular-nums', className)}>{value}</span>;
}
export default function CustomBaseUISkin({ children, className }: SkinProps): JSX.Element {
return (
<MediaContainer className={clsx(
'relative isolate @container/root group/root overflow-clip bg-black rounded-xl',
// Base typography
'font-sans text-[0.8125rem] subpixel-antialiased',
// Fancy borders
'after:absolute after:inset-0 after:ring-black/10 after:ring-1 dark:after:ring-white/10 after:ring-inset after:z-10 after:pointer-events-none after:rounded-[inherit]',
// Prevent rounded corners in fullscreen
'[&:fullscreen]:rounded-none',
// Ensure the nested video inherits the radius
'[&_video]:w-full [&_video]:h-full',
className,
)}
>
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<Surface className={clsx(
'@container/controls absolute inset-x-2 bottom-2 justify-end z-20 p-1 rounded-lg flex items-center gap-0.5',
// Animation
'transition ease-in-out',
// FIXME: Temporary hide/show logic
'opacity-0 scale-[0.98] blur-sm delay-500 pointer-events-none origin-bottom',
'has-data-paused:opacity-100 has-data-paused:scale-100 has-data-paused:blur-none has-data-paused:delay-0 has-data-paused:pointer-events-auto',
'group-hover/root:opacity-100 group-hover/root:scale-100 group-hover/root:blur-none group-hover/root:delay-0 group-hover/root:pointer-events-auto',
)}
>
<Tooltip.Provider>
<PlayButton />
<div className="flex items-center gap-2.5 px-2 flex-1">
<div className="flex items-center gap-1">
<TimeDisplay type="current" />
<span className="opacity-50">/</span>
<TimeDisplay type="duration" className="opacity-50" />
</div>
<TimeSlider />
</div>
<MuteButton />
<FullscreenButton />
</Tooltip.Provider>
</Surface>
</MediaContainer>
);
};
@@ -0,0 +1,90 @@
import type { PropsWithChildren } from 'react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
TimeSlider,
VolumeSlider,
} from '@videojs/react-preview';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react-preview/icons';
import styles from './styles';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
export default function CustomNativeSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<div className={styles.Controls}>
<div className={styles.ControlsRow}>
<TimeSlider.Root className={styles.SliderRoot}>
<TimeSlider.Track className={styles.SliderTrack}>
<TimeSlider.Progress className={styles.SliderProgress} />
<TimeSlider.Pointer className={styles.SliderPointer} />
</TimeSlider.Track>
<TimeSlider.Thumb className={`${styles.SliderThumb} ${styles.TimeSliderThumb}`} />
</TimeSlider.Root>
</div>
<div className={styles.ControlsRow}>
<div className="flex items-center gap-3">
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={styles.PlayIcon}></PlayIcon>
<PauseIcon className={styles.PauseIcon}></PauseIcon>
</PlayButton>
<div className="flex items-center gap-1">
<CurrentTimeDisplay
// Use showRemaining to show count down/remaining time
// showRemaining
className={styles.TimeDisplay}
/>
<span className="opacity-50">/</span>
<DurationDisplay className={`${styles.TimeDisplay} opacity-50`} />
</div>
</div>
<div className="flex items-center gap-0.5">
<div className={styles.VolumeControls}>
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.VolumeButton}`}>
<VolumeHighIcon className={styles.VolumeHighIcon} />
<VolumeLowIcon className={styles.VolumeLowIcon} />
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
<div className={styles.VolumeSlider}>
<VolumeSlider.Root className={styles.SliderRoot}>
<VolumeSlider.Track className={styles.SliderTrack}>
<VolumeSlider.Progress className={styles.SliderProgress} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={styles.SliderThumb} />
</VolumeSlider.Root>
</div>
</div>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
<FullscreenExitIcon className={styles.FullScreenExitIcon} />
</FullscreenButton>
</div>
</div>
</div>
</MediaContainer>
);
};
@@ -0,0 +1,122 @@
import type { CustomNativeSkinStyles } from './types';
// NOTE: Removing import to sidestep for compiler complexity (CJP)
// import { cn } from '../../utils/cn';
// A (very crude) utility to merge class names
// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case.
// It just makes the billions of Tailwind classes a little easier to read.
function cn(...classes: (string | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
const styles: CustomNativeSkinStyles = {
MediaContainer: cn(
'relative isolate @container/root group/root overflow-clip bg-black rounded-xl',
// Base typography
'font-sans text-[0.8125rem] subpixel-antialiased',
// Fancy borders
'after:absolute after:inset-0 after:ring-black/10 after:ring-1 dark:after:ring-white/10 after:ring-inset after:z-10 after:pointer-events-none after:rounded-[inherit]',
// Prevent rounded corners in fullscreen
'[&:fullscreen]:rounded-none',
// Ensure the nested video inherits the radius
'[&_video]:w-full [&_video]:h-full',
),
Controls: cn(
'@container/controls absolute inset-x-0 bottom-0 top-1/3 flex flex-col justify-end z-20 px-2.5 pb-2.5 text-white text-shadow',
'shadow-sm shadow-black/15',
// Background
'bg-linear-to-t from-stone-950/70 via-stone-950/60 via-35% to-transparent',
// Animation
'transition ease-in-out',
// FIXME: Temporary hide/show logic
'translate-y-full opacity-0 delay-500 pointer-events-none',
'has-data-paused:translate-y-0 has-data-paused:opacity-100 has-data-paused:delay-0 has-data-paused:pointer-events-auto',
'group-hover/root:translate-y-0 group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:pointer-events-auto',
),
ControlsRow: cn('flex items-center justify-between'),
Button: cn(
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-md',
// Background/foreground
'bg-transparent text-white/90',
// Hover and focus states
'hover:no-underline hover:bg-stone-100/10 hover:backdrop-blur-md hover:text-white focus-visible:no-underline focus-visible:bg-stone-100/10 focus-visible:text-white',
// Focus state
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500',
// Disabled state
'aria-disabled:grayscale aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
// Loading state
'aria-busy:pointer-events-none aria-busy:cursor-not-allowed',
// Expanded state
'aria-expanded:bg-stone-100/10 aria-expanded:text-white',
// Pressed state
'active:scale-95',
),
IconButton: cn(
'grid [&_svg]:[grid-area:1/1]',
'[&_svg]:shrink-0 [&_svg]:transition [&_svg]:duration-300 [&_svg]:ease-out [&_svg]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_svg]:shadow-black/20',
),
PlayButton: cn(
'[&_.pause-icon]:opacity-100 [&[data-paused]_.pause-icon]:opacity-0',
'[&_.play-icon]:opacity-0 [&[data-paused]_.play-icon]:opacity-100',
),
PlayIcon: cn('play-icon'),
PauseIcon: cn('pause-icon'),
VolumeControls: cn('flex items-center flex-row-reverse group/volume'),
VolumeSlider: cn(
'w-0 px-3 overflow-hidden pointer-events-none transition-[opacity,width] opacity-0 ease-out delay-500',
'group-hover/volume:w-28 group-hover/volume:pointer-events-auto group-hover/volume:opacity-100 group-hover/volume:delay-0',
'group-focus-within/volume:w-28 group-focus-within/volume:pointer-events-auto group-focus-within/volume:opacity-100 group-focus-within/volume:delay-0',
),
VolumeButton: cn(
'[&_svg]:hidden',
'[&[data-volume-level="high"]_.volume-high-icon]:inline',
'[&[data-volume-level="medium"]_.volume-low-icon]:inline',
'[&[data-volume-level="low"]_.volume-low-icon]:inline',
'[&[data-volume-level="off"]_.volume-off-icon]:inline',
),
VolumeHighIcon: cn('volume-high-icon'),
VolumeLowIcon: cn('volume-low-icon'),
VolumeOffIcon: cn('volume-off-icon'),
FullScreenButton: cn(
'[&_.fullscreen-enter-icon]:opacity-100 [&[data-fullscreen]_.fullscreen-enter-icon]:opacity-0',
'[&_.fullscreen-exit-icon]:opacity-0 [&[data-fullscreen]_.fullscreen-exit-icon]:opacity-100',
'[&_path]:transition-transform ease-out',
),
FullScreenEnterIcon: cn(
'fullscreen-enter-icon',
'group-hover/button:[&_.arrow-1]:-translate-x-px group-hover/button:[&_.arrow-1]:-translate-y-px',
'group-hover/button:[&_.arrow-2]:translate-x-px group-hover/button:[&_.arrow-2]:translate-y-px',
),
FullScreenExitIcon: cn(
'fullscreen-exit-icon',
'[&_.arrow-1]:-translate-x-px [&_.arrow-1]:-translate-y-px',
'[&_.arrow-2]:translate-x-px [&_.arrow-2]:translate-y-px',
'group-hover/button:[&_.arrow-1]:translate-0',
'group-hover/button:[&_.arrow-2]:translate-0',
),
TimeSliderThumb: cn(
'opacity-0',
'group-hover/slider:opacity-100 group-focus-within/slider:opacity-100',
),
TimeDisplay: cn('tabular-nums text-shadow-2xs shadow-black/50'),
SliderRoot: cn(
'flex items-center justify-center flex-1 group/slider relative',
'[&[data-orientation="horizontal"]]:h-5 [&[data-orientation="horizontal"]]:min-w-20',
'[&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20',
),
SliderTrack: cn(
'relative select-none rounded-full bg-white/25 backdrop-blur-sm backdrop-brightness-90 backdrop-saturate-150 shadow-sm shadow-black/10',
'[&[data-orientation="horizontal"]]:w-full [&[data-orientation="horizontal"]]:h-1',
'[&[data-orientation="vertical"]]:w-1',
),
SliderProgress: cn('bg-amber-500 rounded-[inherit]'),
// TODO: Work out what we want to do here.
SliderPointer: cn('rounded-[inherit]'),
SliderThumb: cn(
'bg-white z-10 select-none ring ring-black/10 rounded-full shadow-sm shadow-black/15 transition-[opacity,height,width] ease-in-out',
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500',
'size-3 active:size-3.5 group-active/slider:size-3.5 hover:cursor-ew-resize',
),
};
export default styles;
@@ -0,0 +1,26 @@
export interface CustomNativeSkinStyles {
readonly MediaContainer: string;
readonly Controls: string;
readonly ControlsRow: string;
readonly Button: string;
readonly IconButton: string;
readonly PlayButton: string;
readonly PlayIcon: string;
readonly PauseIcon: string;
readonly VolumeControls: string;
readonly VolumeSlider: string;
readonly VolumeButton: string;
readonly VolumeHighIcon: string;
readonly VolumeLowIcon: string;
readonly VolumeOffIcon: string;
readonly FullScreenButton: string;
readonly FullScreenEnterIcon: string;
readonly FullScreenExitIcon: string;
readonly TimeSliderThumb: string;
readonly TimeDisplay: string;
readonly SliderRoot: string;
readonly SliderTrack: string;
readonly SliderProgress: string;
readonly SliderPointer: string;
readonly SliderThumb: string;
}
@@ -0,0 +1,120 @@
import type { PropsWithChildren } from 'react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
Popover,
PreviewTimeDisplay,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/react-preview';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react-preview/icons';
import './frosted.css';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
export default function FrostedSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`vjs-frosted-skin ${className}`}>
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<div className="overlay" />
<div className="surface control-bar" data-testid="media-controls">
<Tooltip.Root delay={500}>
<Tooltip.Trigger>
<PlayButton className="button play-button">
<PlayIcon className="icon play-icon" />
<PauseIcon className="icon pause-icon" />
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className="tooltip-popup surface popup-animation">
<span className="tooltip play-tooltip">Play</span>
<span className="tooltip pause-tooltip">Pause</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
<div className="time-controls">
<CurrentTimeDisplay
// Use showRemaining to show count down/remaining time
// showRemaining
className="time-display"
/>
<Tooltip.Root trackCursorAxis="x">
<Tooltip.Trigger>
<TimeSlider.Root className="slider">
<TimeSlider.Track className="slider-track">
<TimeSlider.Progress className="slider-progress" />
<TimeSlider.Pointer className="slider-pointer" />
</TimeSlider.Track>
<TimeSlider.Thumb className="slider-thumb" />
</TimeSlider.Root>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={18} collisionPadding={12}>
<Tooltip.Popup className="surface popup-animation tooltip-popup">
<PreviewTimeDisplay className="time-display media-preview-time-display" />
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
<DurationDisplay className="time-display" />
</div>
<Popover.Root openOnHover delay={200} closeDelay={100}>
<Popover.Trigger>
<MuteButton className="button mute-button">
<VolumeHighIcon className="icon volume-high-icon" />
<VolumeLowIcon className="icon volume-low-icon" />
<VolumeOffIcon className="icon volume-off-icon" />
</MuteButton>
</Popover.Trigger>
<Popover.Positioner side="top" sideOffset={12}>
<Popover.Popup className="surface popup-animation popover-popup">
<VolumeSlider.Root className="slider" orientation="vertical">
<VolumeSlider.Track className="slider-track">
<VolumeSlider.Progress className="slider-progress" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="slider-thumb" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>
<Tooltip.Root delay={500}>
<Tooltip.Trigger>
<FullscreenButton className="button fullscreen-button">
<FullscreenEnterIcon className="icon fullscreen-enter-icon" />
<FullscreenExitIcon className="icon fullscreen-exit-icon" />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className="surface popup-animation tooltip-popup">
<span className="tooltip fullscreen-enter-tooltip">Enter Fullscreen</span>
<span className="tooltip fullscreen-exit-tooltip">Exit Fullscreen</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
</div>
</MediaContainer>
);
}
@@ -0,0 +1,352 @@
.vjs-frosted-skin * {
box-sizing: border-box;
}
.vjs-frosted-skin {
position: relative;
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--frosted-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;
}
.vjs-frosted-skin::before,
.vjs-frosted-skin::after {
content: '';
position: absolute;
pointer-events: none;
border-radius: inherit;
z-index: 10;
}
.vjs-frosted-skin::before {
inset: 1px;
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
}
.vjs-frosted-skin::after {
inset: 0;
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.1);
}
/* Fullscreen */
.vjs-frosted-skin:fullscreen {
border-radius: 0;
}
.vjs-frosted-skin > ::slotted([slot='media']) {
display: block;
width: 100%;
height: 100%;
}
/* Media Container UI Overlay Styling */
.vjs-frosted-skin > .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;
}
.vjs-frosted-skin[data-controls='hidden'] > .overlay {
transition-delay: 500ms;
transition-duration: 300ms;
opacity: 0;
}
/* Common Surface Styles - e.g. tooltips, popovers, controls */
.vjs-frosted-skin .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) {
.vjs-frosted-skin .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) {
.vjs-frosted-skin .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 */
.vjs-frosted-skin .control-bar {
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;
}
.vjs-frosted-skin[data-controls='hidden'] .control-bar {
pointer-events: none;
filter: blur(8px);
scale: 0.9;
opacity: 0;
transition-delay: 500ms;
transition-duration: 300ms;
}
/* Time Display Styling */
.vjs-frosted-skin .time-controls {
display: flex;
align-items: center;
flex: 1;
gap: 0.75rem;
padding-inline: 0.375rem;
}
.vjs-frosted-skin .time-display {
text-shadow: 0 1px 0 oklab(0 0 0 / 0.25);
font-variant-numeric: tabular-nums;
}
/* Generic Media Button Styling */
.vjs-frosted-skin .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;
}
.vjs-frosted-skin .button:hover,
.vjs-frosted-skin .button:focus-visible,
.vjs-frosted-skin .button[aria-expanded='true'] {
background-color: oklab(1 0 0 / 0.1);
color: oklab(1 0 0);
text-decoration: none;
}
.vjs-frosted-skin .button:focus-visible {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 2px;
}
.vjs-frosted-skin .button[disabled] {
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(1);
}
.vjs-frosted-skin .button .icon {
grid-area: 1 / 1;
width: 18px;
height: 18px;
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25));
}
/* Media Play Button UI/Styles */
.vjs-frosted-skin .play-button .icon {
opacity: 0;
transition: opacity 150ms linear;
}
.vjs-frosted-skin .play-button:not([data-paused]) .pause-icon,
.vjs-frosted-skin .play-button[data-paused] .play-icon {
opacity: 1;
}
/* Media Fullscreen Button UI/Styles */
.vjs-frosted-skin .fullscreen-button .icon {
display: none;
}
.vjs-frosted-skin .fullscreen-button:not([data-fullscreen]) .fullscreen-enter-icon,
.vjs-frosted-skin .fullscreen-button[data-fullscreen] .fullscreen-exit-icon {
display: inline;
}
/* One way to define the "default visible" icon (CJP) */
.vjs-frosted-skin .mute-button .icon {
display: none;
}
.vjs-frosted-skin .mute-button:not([data-volume-level]) .volume-low-icon,
.vjs-frosted-skin .mute-button[data-volume-level='high'] .volume-high-icon,
.vjs-frosted-skin .mute-button[data-volume-level='medium'] .volume-high-icon,
.vjs-frosted-skin .mute-button[data-volume-level='low'] .volume-low-icon,
.vjs-frosted-skin .mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}
/* TimeSlider Component Styles */
.vjs-frosted-skin .slider {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation styles */
.vjs-frosted-skin .slider[data-orientation='horizontal'] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation styles */
.vjs-frosted-skin .slider[data-orientation='vertical'] {
height: 5rem;
width: 1.25rem;
}
.vjs-frosted-skin .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 */
.vjs-frosted-skin .slider-track[data-orientation='horizontal'] {
width: 100%;
height: 0.25rem;
}
/* Vertical track styles */
.vjs-frosted-skin .slider-track[data-orientation='vertical'] {
width: 0.25rem;
height: 100%;
}
.vjs-frosted-skin .slider:focus-visible .slider-track {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 6px;
}
.vjs-frosted-skin .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;
}
.vjs-frosted-skin .slider-thumb:active {
width: 0.75rem;
height: 0.75rem;
}
.vjs-frosted-skin .slider:hover .slider-thumb,
.vjs-frosted-skin .slider:focus-within .slider-thumb {
opacity: 1;
}
.vjs-frosted-skin .slider-track[data-orientation='horizontal'] .slider-thumb {
cursor: ew-resize;
}
.vjs-frosted-skin .slider-track[data-orientation='vertical'] .slider-thumb {
cursor: ns-resize;
}
.vjs-frosted-skin .slider-pointer {
background-color: oklab(1 0 0 / 0.2);
border-radius: inherit;
}
.vjs-frosted-skin .slider-progress {
background-color: oklab(1 0 0);
border-radius: inherit;
}
.vjs-frosted-skin .media-preview-time-display {
font-variant-numeric: tabular-nums;
}
.vjs-frosted-skin .popup-animation {
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
transform: scale(1);
transform-origin: bottom;
opacity: 1;
filter: blur(0px);
}
.vjs-frosted-skin .popup-animation[data-starting-style],
.vjs-frosted-skin .popup-animation[data-ending-style] {
transform: scale(0);
opacity: 0;
filter: blur(8px);
}
.vjs-frosted-skin .popup-animation[data-instant] {
transition-duration: 0ms;
}
.vjs-frosted-skin .popover-popup {
padding: 0.75rem 0.25rem;
border-radius: calc(infinity * 1px);
margin: 0;
border: 0;
}
/* Tooltip Component Styles */
.vjs-frosted-skin .tooltip-popup {
color: oklab(1 0 0);
padding: 0.25rem 0.625rem;
border-radius: calc(infinity * 1px);
font-size: 0.75rem;
}
.vjs-frosted-skin .tooltip {
display: none;
white-space: nowrap;
}
.vjs-frosted-skin .tooltip-popup[data-paused] .play-tooltip,
.vjs-frosted-skin .tooltip-popup:not([data-paused]) .pause-tooltip {
display: block;
}
.vjs-frosted-skin .tooltip-popup[data-fullscreen] .fullscreen-exit-tooltip,
.vjs-frosted-skin .tooltip-popup:not([data-fullscreen]) .fullscreen-enter-tooltip {
display: block;
}
@@ -0,0 +1,125 @@
import type { PropsWithChildren } from 'react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
Popover,
PreviewTimeDisplay,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/react-preview';
import {
FullscreenEnterAltIcon,
FullscreenExitAltIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react-preview/icons';
import './minimal.css';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
export default function MinimalSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`vjs-minimal-skin ${className}`}>
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<div className="overlay" />
<div className="control-bar">
<Tooltip.Root delay={500} closeDelay={0}>
<Tooltip.Trigger>
<PlayButton className="button play-button">
<PlayIcon className="icon play-icon" />
<PauseIcon className="icon pause-icon" />
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top-start" sideOffset={6} collisionPadding={12}>
<Tooltip.Popup className="popup-animation tooltip-popup">
<span className="tooltip play-tooltip">Play</span>
<span className="tooltip pause-tooltip">Pause</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
<div className="time-display-group">
<CurrentTimeDisplay
// Use showRemaining to show count down/remaining time
// showRemaining
className="time-display"
/>
<span className="duration-display">
/
<DurationDisplay className="time-display" />
</span>
</div>
<Tooltip.Root trackCursorAxis="x">
<Tooltip.Trigger>
<TimeSlider.Root className="slider">
<TimeSlider.Track className="slider-track">
<TimeSlider.Progress className="slider-progress" />
<TimeSlider.Pointer className="slider-pointer" />
</TimeSlider.Track>
<TimeSlider.Thumb className="slider-thumb" />
</TimeSlider.Root>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className="popup-animation tooltip-popup">
<PreviewTimeDisplay className="time-display media-preview-time-display" />
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
<div className="button-group">
<Popover.Root openOnHover delay={200} closeDelay={300}>
<Popover.Trigger>
<MuteButton className="button mute-button">
<VolumeHighIcon className="icon volume-high-icon" />
<VolumeLowIcon className="icon volume-low-icon" />
<VolumeOffIcon className="icon volume-off-icon" />
</MuteButton>
</Popover.Trigger>
<Popover.Positioner side="top" sideOffset={2}>
<Popover.Popup className="popup-animation popover-popup">
<VolumeSlider.Root className="slider" orientation="vertical">
<VolumeSlider.Track className="slider-track">
<VolumeSlider.Progress className="slider-progress" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="slider-thumb" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>
<Tooltip.Root delay={500} closeDelay={0}>
<Tooltip.Trigger>
<FullscreenButton className="button fullscreen-button">
<FullscreenEnterAltIcon className="icon fullscreen-enter-icon" />
<FullscreenExitAltIcon className="icon fullscreen-exit-icon" />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top-end" sideOffset={6} collisionPadding={12}>
<Tooltip.Popup className="popup-animation tooltip-popup">
<span className="tooltip fullscreen-enter-tooltip">Enter Fullscreen</span>
<span className="tooltip fullscreen-exit-tooltip">Exit Fullscreen</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
</div>
</div>
</MediaContainer>
);
}
@@ -0,0 +1,347 @@
.vjs-minimal-skin * {
box-sizing: border-box;
}
.vjs-minimal-skin {
position: relative;
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--minimal-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;
}
.vjs-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) {
.vjs-minimal-skin::after {
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
}
}
/* Fullscreen */
.vjs-minimal-skin:fullscreen {
border-radius: 0;
}
.vjs-minimal-skin > ::slotted([slot='media']) {
display: block;
width: 100%;
height: 100%;
}
/* Media Container UI Overlay Styling */
.vjs-minimal-skin > .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;
}
.vjs-minimal-skin[data-controls='hidden'] > .overlay {
opacity: 0;
transition-duration: 500ms;
transition-delay: 500ms;
}
/* Media Control Bar UI/Styles */
.vjs-minimal-skin .control-bar {
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);
}
.vjs-minimal-skin[data-controls='hidden'] > .control-bar {
opacity: 0;
transform: translateY(100%);
filter: blur(8px);
transition-delay: 500ms;
transition-duration: 500ms;
pointer-events: none;
}
@container root (width > 640px) {
.vjs-minimal-skin .control-bar {
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
gap: 0.875rem;
}
}
/* Time Display Styling */
.vjs-minimal-skin .time-display-group {
display: flex;
align-items: center;
gap: 0.25rem;
}
.vjs-minimal-skin .duration-display {
display: contents;
color: oklab(1 0 0 / 0.5);
}
.vjs-minimal-skin .time-display {
text-shadow: 0 1px 0 oklab(0 0 0 / 0.2);
font-variant-numeric: tabular-nums;
}
.vjs-minimal-skin .button-group {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Generic Media Button Styling */
.vjs-minimal-skin .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;
}
.vjs-minimal-skin .button:hover,
.vjs-minimal-skin .button:focus-visible,
.vjs-minimal-skin .button[aria-expanded='true'] {
color: oklab(1 0 0 / 0.8);
text-decoration: none;
}
.vjs-minimal-skin .button:focus-visible {
outline-color: oklab(1 0 0);
outline-offset: 2px;
}
.vjs-minimal-skin .button[disabled] {
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(1);
}
.vjs-minimal-skin .button .icon {
grid-area: 1 / 1;
width: 18px;
height: 18px;
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.4));
}
/* Media Play Button UI/Styles */
.vjs-minimal-skin .play-button .icon {
opacity: 0;
transition: opacity 150ms linear;
}
.vjs-minimal-skin .play-button:not([data-paused]) .pause-icon,
.vjs-minimal-skin .play-button[data-paused] .play-icon {
opacity: 1;
}
/* Media Fullscreen Button UI/Styles */
.vjs-minimal-skin .fullscreen-button .icon {
display: none;
}
.vjs-minimal-skin .fullscreen-button:not([data-fullscreen]) .fullscreen-enter-icon,
.vjs-minimal-skin .fullscreen-button[data-fullscreen] .fullscreen-exit-icon {
display: inline;
}
/* One way to define the "default visible" icon (CJP) */
.vjs-minimal-skin .mute-button .icon {
display: none;
}
.vjs-minimal-skin .mute-button:not([data-volume-level]) .volume-low-icon,
.vjs-minimal-skin .mute-button[data-volume-level='high'] .volume-high-icon,
.vjs-minimal-skin .mute-button[data-volume-level='medium'] .volume-high-icon,
.vjs-minimal-skin .mute-button[data-volume-level='low'] .volume-low-icon,
.vjs-minimal-skin .mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}
/* TimeSlider Component Styles */
.vjs-minimal-skin .slider {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation styles */
.vjs-minimal-skin .slider[data-orientation='horizontal'] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation styles */
.vjs-minimal-skin .slider[data-orientation='vertical'] {
height: 4.5rem;
width: 1.25rem;
}
.vjs-minimal-skin .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 */
.vjs-minimal-skin .slider-track[data-orientation='horizontal'] {
width: 100%;
height: 0.1875rem;
}
/* Vertical track styles */
.vjs-minimal-skin .slider-track[data-orientation='vertical'] {
width: 0.1875rem;
height: 100%;
}
.vjs-minimal-skin .slider:focus-visible .slider-track {
outline-color: oklab(1 0 0);
outline-offset: 6px;
}
.vjs-minimal-skin .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;
}
.vjs-minimal-skin .slider:hover .slider-thumb,
.vjs-minimal-skin .slider:focus-within .slider-thumb {
opacity: 1;
scale: 1;
}
.vjs-minimal-skin .slider-track[data-orientation='horizontal'] .slider-thumb {
cursor: ew-resize;
}
.vjs-minimal-skin .slider-track[data-orientation='vertical'] .slider-thumb {
cursor: ns-resize;
}
.vjs-minimal-skin .slider-pointer {
display: none;
}
.vjs-minimal-skin .slider-progress {
background-color: oklab(1 0 0);
border-radius: inherit;
}
.vjs-minimal-skin .media-preview-time-display {
font-variant-numeric: tabular-nums;
}
.vjs-minimal-skin .popup-animation {
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
transform: scale(1);
transform-origin: bottom;
opacity: 1;
filter: blur(0px);
}
.vjs-minimal-skin .popup-animation[data-starting-style],
.vjs-minimal-skin .popup-animation[data-ending-style] {
transform: scale(0);
opacity: 0;
filter: blur(8px);
}
.vjs-minimal-skin .popup-animation[data-instant] {
transition-duration: 0ms;
}
.vjs-minimal-skin .popover-popup {
padding: 0.5rem;
margin: 0;
border: 0;
background: transparent;
}
/* Tooltip Component Styles */
.vjs-minimal-skin .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) {
.vjs-minimal-skin .tooltip-popup {
background-color: oklab(0 0 0 / 0.7);
}
}
@media (prefers-contrast: more) {
.vjs-minimal-skin .tooltip-popup {
background-color: oklab(0 0 0 / 0.9);
}
}
.vjs-minimal-skin .tooltip {
display: none;
white-space: nowrap;
}
.vjs-minimal-skin .tooltip-popup[data-paused] .play-tooltip,
.vjs-minimal-skin .tooltip-popup:not([data-paused]) .pause-tooltip {
display: block;
}
.vjs-minimal-skin .tooltip-popup[data-fullscreen] .fullscreen-exit-tooltip,
.vjs-minimal-skin .tooltip-popup:not([data-fullscreen]) .fullscreen-enter-tooltip {
display: block;
}
@@ -0,0 +1,3 @@
import '@videojs/react-preview/skins/frosted.css';
export { FrostedSkin as default } from '@videojs/react-preview';
@@ -0,0 +1,3 @@
import '@videojs/react-preview/skins/minimal.css';
export { MinimalSkin as default } from '@videojs/react-preview';