feat(example/react): improvements to react examples (#210)

This commit is contained in:
Sam Potts
2025-12-10 09:27:38 +11:00
committed by GitHub
parent 546989c2be
commit c35b012250
57 changed files with 2886 additions and 2138 deletions
+123 -146
View File
@@ -1,168 +1,145 @@
import type { ChangeEventHandler } from 'react';
import type { PropsWithChildren } from 'react';
import { VideoProvider } from '@videojs/react';
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 { FrostedSkin, HlsVideo, MinimalSkin, VideoProvider } from '@videojs/react';
import { useCallback, useMemo, useState } from 'react';
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';
// NOTE: Commented out imports are for testing locally/externally defined skins.
// import { VideoProvider, Video } from '@videojs/react';
// import FrostedSkin from './skins/frosted/FrostedSkin';
// import MinimalSkin from './skins/toasted/MinimalSkin';
import FrostedEjectedSkin from './skins/frosted-eject/FrostedSkin';
import MinimalEjectedSkin from './skins/minimal-eject/MinimalSkin';
import '@videojs/react/skins/frosted.css';
import '@videojs/react/skins/minimal.css';
import './globals.css';
const skins = [
{
key: 'frosted',
name: 'Frosted (imported)',
component: FrostedSkin,
},
{
key: 'frosted-eject',
name: 'Frosted (ejected)',
component: FrostedEjectedSkin,
},
{
key: 'minimal',
name: 'Minimal (imported)',
component: MinimalSkin,
},
{
key: 'minimal-eject',
name: 'Minimal (ejected)',
component: MinimalEjectedSkin,
},
] as const;
type NavigationSectionProps = PropsWithChildren<{
title: string;
}>;
type SkinKey = (typeof skins)[number]['key'];
const mediaSources = [
{
key: '1',
name: 'Mux 1 (HLS)',
value: 'https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ.m3u8',
},
{
key: '2',
name: 'Mux 2 (HLS)',
value: 'https://stream.mux.com/a4nOgmxGWg6gULfcBbAa00gXyfcwPnAFldF8RdsNyk8M.m3u8',
},
{
key: '3',
name: 'Mux 3 (MP4)',
value: 'https://stream.mux.com/A3VXy02VoUinw01pwyomEO3bHnG4P32xzV7u1j1FSzjNg/high.mp4',
},
{
key: '4',
name: 'Mux 4 (HLS)',
value: 'https://stream.mux.com/lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML.m3u8',
},
] as const;
type MediaSourceKey = (typeof mediaSources)[number]['key'];
function getParam<T>(key: string, defaultValue: T): T {
const params = new URLSearchParams(window.location.search);
return (params.get(key) as T) || defaultValue;
}
function setParam(key: string, value: string) {
const params = new URLSearchParams(window.location.search);
params.set(key, value);
const search = params.toString();
const url = window.location.pathname + (search ? `?${search}` : '');
window.history.replaceState(null, '', url);
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>
);
}
const DEFAULT_SKIN: SkinKey = 'frosted';
const DEFAULT_MEDIA_SOURCE: MediaSourceKey = '1';
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 [skinKey, setSkinKey] = useState<SkinKey>(() => getParam('skin', DEFAULT_SKIN));
const [mediaSourceKey, setMediaSourceKey] = useState<MediaSourceKey>(() => getParam('source', DEFAULT_MEDIA_SOURCE));
const skinProps = useMemo(() => ({
className: 'aspect-video shadow-lg shadow-black/15',
children: <VideoElement />,
}), []);
const mediaSource = useMemo(() => {
let match = mediaSources.find(m => m.key === mediaSourceKey);
if (!match) {
match = mediaSources.find(m => m.key === DEFAULT_MEDIA_SOURCE)!;
setMediaSourceKey(match.key);
const isDesktop = useMediaQuery('(min-width: 768px)');
const navigationRef = useRef<HTMLElement>(null);
useEffect(() => {
if (isDesktop) {
navigationRef.current?.removeAttribute('popover');
} else {
navigationRef.current?.setAttribute('popover', 'auto');
}
return match.value;
}, [mediaSourceKey]);
}, [isDesktop]);
const Skin = useMemo(() => {
let match = skins.find(s => s.key === skinKey);
if (!match) {
match = skins.find(s => s.key === DEFAULT_SKIN)!;
setSkinKey(match.key);
}
return match.component;
}, [skinKey]);
const onChangeSkin: ChangeEventHandler<HTMLSelectElement> = useCallback((event) => {
const value = event.target.value as SkinKey;
setSkinKey(value);
setParam('skin', value);
}, []);
const onChangeMediaSource: ChangeEventHandler<HTMLSelectElement> = useCallback((event) => {
const value = event.target.value as MediaSourceKey;
setMediaSourceKey(value);
setParam('source', value);
}, []);
// Force a re-render on changes.
const key = `${skinKey}-${mediaSourceKey}`;
const playbackId = mediaSource.match(/stream\.mux\.com\/([^./]+)/)?.[1];
const poster = playbackId ? `https://image.mux.com/${playbackId}/thumbnail.webp` : undefined;
const [location] = useLocation();
useEffect(() => {
try {
navigationRef.current?.hidePopover();
} catch {}
}, [location]);
return (
<div className="min-h-screen bg-white text-stone-700 dark:bg-stone-900 dark:text-stone-200">
<header className="fixed top-0 z-10 inset-x-0 bg-white dark:bg-stone-800 shadow shadow-black/10 after:h-px after:absolute after:inset-x-0 after:top-full after:bg-black/5 transition-transform">
<div className="grid grid-cols-5 h-2" aria-hidden="true">
<div className="bg-yellow-500"></div>
<div className="bg-orange-500"></div>
<div className="bg-red-500"></div>
<div className="bg-purple-500"></div>
<div className="bg-blue-500"></div>
</div>
<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>
<div className="py-3 px-6 flex items-center justify-between">
<div className="space-y-1">
<h1 className="font-medium text-lg tracking-tight leading-tight dark:text-white">Playground</h1>
<small className="block text-stone-400 text-sm">Test out the various skins for Video.js.</small>
</div>
<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>
<nav className="flex items-center gap-3">
<select value={mediaSourceKey} onChange={onChangeMediaSource}>
{mediaSources.map(({ key, name }) => (
<option key={key} value={key}>
{name}
</option>
))}
</select>
<select value={skinKey} onChange={onChangeSkin}>
{skins.map(({ key, name }) => (
<option key={key} value={key}>
{name}
</option>
))}
</select>
</nav>
<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="min-h-screen flex justify-center items-center bg-radial bg-size-[16px_16px] from-stone-300 dark:from-stone-700 via-10% via-transparent to-transparent">
<div className="w-full max-w-5xl mx-auto p-6">
<VideoProvider key={key}>
<Skin className="aspect-video shadow-lg shadow-black/15">
{/* @ts-expect-error -- types are incorrect */}
<HlsVideo src={mediaSource} poster={poster} playsInline />
</Skin>
</VideoProvider>
</div>
<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';
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
/>
);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './CodeBlock';
export * from './SkinLayout';
export * from './Tabs';
export * from './VideoElement';
+35
View File
@@ -8,3 +8,38 @@
/* 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;
}
}
}
+2
View File
@@ -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;
+53
View File
@@ -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,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';
import { formatDisplayTime } from '@videojs/utils';
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';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react/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>
);
};
@@ -1,4 +1,4 @@
import type { MinimalSkinStyles } from './types';
import type { CustomNativeSkinStyles } from './types';
// NOTE: Removing import to sidestep for compiler complexity (CJP)
// import { cn } from '../../utils/cn';
@@ -9,59 +9,51 @@ function cn(...classes: (string | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
const styles: MinimalSkinStyles = {
const styles: CustomNativeSkinStyles = {
MediaContainer: cn(
'relative @container/root group/root overflow-clip bg-black',
'relative isolate @container/root group/root overflow-clip bg-black rounded-xl',
// Base typography
'text-[0.8125rem]', // 13px
// 'ring-1 ring-inset ring-black/10 dark:ring-white/10',
'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 [&:fullscreen]:[&_video]:h-full [&:fullscreen]:[&_video]:w-full',
// Ensure the nested video inherits the radius.
'[&_video]:rounded-[inherit] [&_video]:w-full [&_video]:h-auto',
),
Overlay: cn(
'absolute inset-0 rounded-[inherit] bg-black/30',
'bg-gradient-to-t from-black/30 to-transparent to-[120px]',
'opacity-0 delay-500 duration-300',
// FIXME: Temporary hide/show logic
'has-[+.controls_[data-paused]]:opacity-100 has-[+.controls_[data-paused]]:delay-0 has-[+.controls_[data-paused]]:duration-75',
'group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:duration-75',
// Prevent rounded corners in fullscreen
'[&:fullscreen]:rounded-none',
// Ensure the nested video inherits the radius
'[&_video]:w-full [&_video]:h-full',
),
Controls: cn(
'controls', // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility.
'@container/controls absolute inset-x-0 bottom-0 flex items-center gap-3.5 z-20 px-6 pb-6 pt-10 text-white text-shadow',
'@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 duration-300',
'has-[[data-paused]]:translate-y-0 has-[[data-paused]]:opacity-100 has-[[data-paused]]:delay-0 has-[[data-paused]]:duration-75',
'group-hover/root:translate-y-0 group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:duration-75',
'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',
),
Icon: cn('icon'),
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',
'bg-transparent text-white/90',
// Hover and focus states
'hover:text-white/70 focus-visible:text-white/70',
'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-white',
'-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:text-white/70',
'aria-expanded:bg-stone-100/10 aria-expanded:text-white',
// Pressed state
'active:scale-95',
),
ButtonGroup: cn('flex items-center gap-1.5'),
IconButton: cn(
'grid [&_.icon]:[grid-area:1/1]',
'[&_.icon]:shrink-0 [&_.icon]:transition [&_.icon]:duration-300 [&_.icon]:ease-out [&_.icon]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_.icon]:shadow-black/20',
'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',
@@ -69,8 +61,14 @@ const styles: MinimalSkinStyles = {
),
PlayIcon: cn('play-icon'),
PauseIcon: cn('pause-icon'),
MuteButton: cn(
'[&_.icon]:hidden',
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',
@@ -79,25 +77,27 @@ const styles: MinimalSkinStyles = {
VolumeHighIcon: cn('volume-high-icon'),
VolumeLowIcon: cn('volume-low-icon'),
VolumeOffIcon: cn('volume-off-icon'),
FullscreenButton: cn(
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(
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(
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',
),
TimeSliderRoot: cn('mx-2'),
TimeSliderThumb: cn('opacity-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',
@@ -105,12 +105,13 @@ const styles: MinimalSkinStyles = {
'[&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20',
),
SliderTrack: cn(
'relative select-none rounded-full bg-white/10',
'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-white rounded-[inherit]'),
SliderPointer: cn('hidden'),
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',
@@ -1,22 +1,21 @@
export interface MinimalSkinStyles {
export interface CustomNativeSkinStyles {
readonly MediaContainer: string;
readonly Overlay: string;
readonly Controls: string;
readonly Icon: string;
readonly ControlsRow: string;
readonly Button: string;
readonly ButtonGroup: string;
readonly IconButton: string;
readonly PlayButton: string;
readonly PlayIcon: string;
readonly PauseIcon: string;
readonly MuteButton: 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 TimeSliderRoot: string;
readonly FullScreenButton: string;
readonly FullScreenEnterIcon: string;
readonly FullScreenExitIcon: string;
readonly TimeSliderThumb: string;
readonly TimeDisplay: string;
readonly SliderRoot: string;
@@ -1,6 +1,18 @@
import type { PropsWithChildren } from 'react';
import { CurrentTimeDisplay, DurationDisplay, FullscreenButton, MediaContainer, MuteButton, PlayButton, Popover, PreviewTimeDisplay, TimeSlider, Tooltip, VolumeSlider } from '@videojs/react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
Popover,
PreviewTimeDisplay,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/react';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
@@ -20,7 +32,8 @@ type SkinProps = PropsWithChildren<{
export default function FrostedSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`vjs-frosted-skin ${className}`}>
{children}
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<div className="overlay" />
@@ -1,6 +1,18 @@
import type { PropsWithChildren } from 'react';
import { CurrentTimeDisplay, DurationDisplay, FullscreenButton, MediaContainer, MuteButton, PlayButton, Popover, PreviewTimeDisplay, TimeSlider, Tooltip, VolumeSlider } from '@videojs/react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
Popover,
PreviewTimeDisplay,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/react';
import {
FullscreenEnterAltIcon,
FullscreenExitAltIcon,
@@ -20,7 +32,8 @@ type SkinProps = PropsWithChildren<{
export default function MinimalSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`vjs-minimal-skin ${className}`}>
{children}
{/* FIXME: Mismatch between React 18 & 19 types */}
{children as any}
<div className="overlay" />
@@ -7,7 +7,7 @@
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--minimal-border-radius, 2rem);
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;
@@ -1,130 +0,0 @@
import type { PropsWithChildren } from 'react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
Popover,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@videojs/react';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react/icons';
import styles from './styles';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
export default function FrostedSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
{children}
{/* Background gradient to help with controls contrast. */}
<div className={styles.Overlay} aria-hidden="true" />
<div className={styles.Controls} data-testid="media-controls">
<Tooltip.Root delay={600} closeDelay={0}>
<Tooltip.Trigger>
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={styles.PlayIcon}></PlayIcon>
<PauseIcon className={styles.PauseIcon}></PauseIcon>
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className={`${styles.TooltipPopup} ${styles.PlayTooltipPopup}`}>
<span className={styles.PlayTooltip}>Play</span>
<span className={styles.PauseTooltip}>Pause</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
<div className={styles.TimeControls}>
<CurrentTimeDisplay
// Use showRemaining to show count down/remaining time
// showRemaining
className={styles.TimeDisplay}
/>
<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} />
</TimeSlider.Root>
<DurationDisplay className={styles.TimeDisplay} />
</div>
<Popover.Root openOnHover delay={200} closeDelay={100}>
<Popover.Trigger>
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
<VolumeHighIcon className={styles.VolumeHighIcon} />
<VolumeLowIcon className={styles.VolumeLowIcon} />
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
</Popover.Trigger>
<Popover.Positioner side="top" sideOffset={12}>
<Popover.Popup className={styles.PopoverPopup}>
<VolumeSlider.Root className={styles.SliderRoot} orientation="vertical">
<VolumeSlider.Track className={styles.SliderTrack}>
<VolumeSlider.Progress className={styles.SliderProgress} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={styles.SliderThumb} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>
<Tooltip.Root delay={600} closeDelay={0}>
<Tooltip.Trigger>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
<FullscreenExitIcon className={styles.FullScreenExitIcon} />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className={`${styles.TooltipPopup} ${styles.FullScreenTooltipPopup}`}>
<span className={styles.FullScreenEnterTooltip}>Enter Fullscreen</span>
<span className={styles.FullScreenExitTooltip}>Exit Fullscreen</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Root>
</div>
</MediaContainer>
);
}
// function ArrowSvg(props: React.ComponentProps<'svg'>) {
// return (
// <svg width="20" height="10" viewBox="0 0 20 10" fill="none" {...props}>
// <path
// d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
// className={styles.ArrowFill}
// />
// <path
// d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
// className={styles.ArrowOuterStroke}
// />
// <path
// d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
// className={styles.ArrowInnerStroke}
// />
// </svg>
// );
// }
-171
View File
@@ -1,171 +0,0 @@
import type { FrostedSkinStyles } 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: FrostedSkinStyles = {
MediaContainer: cn(
'relative @container/root group/root overflow-clip',
// Base typography
'text-sm',
// Prevent rounded corners in fullscreen.
'[&:fullscreen]:rounded-none [&:fullscreen]:[&_video]:h-full [&:fullscreen]:[&_video]:w-full',
// Fancy borders.
'after:absolute after:inset-0 after:ring-black/10 after:ring-1 dark:after:ring-black/40 after:ring-inset after:z-10 after:pointer-events-none after:rounded-[inherit]',
'before:absolute before:inset-px before:rounded-[inherit] before:ring-white/15 before:ring-1 before:ring-inset before:z-10 before:pointer-events-none',
// Ensure the nested video inherits the radius.
'[&_video]:rounded-[inherit] [&_video]:w-full [&_video]:h-auto',
),
Overlay: cn(
'opacity-0 delay-500 rounded-[inherit] absolute inset-0 pointer-events-none bg-gradient-to-t from-black/50 via-black/20 to-transparent transition-opacity backdrop-saturate-150 backdrop-brightness-90',
// Hide when playing (for now).
// FIXME: This is crude temporary logic, well improve it later I guess with a [data-show-controls] attribute or something?
'has-[+.controls_[data-paused]]:opacity-100 has-[+.controls_[data-paused]]:delay-0',
'group-hover/root:opacity-100 group-hover/root:delay-0',
),
Controls: cn(
'controls', // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility.
'@container/controls absolute inset-x-3 bottom-3 rounded-full flex items-center p-1 ring ring-white/10 ring-inset gap-0.5 text-white text-shadow',
'shadow-sm shadow-black/15',
// Background
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
// Animation
'transition will-change-transform origin-bottom ease-out',
// FIXME: Temporary hide/show logic
'scale-90 opacity-0 delay-500',
'has-[[data-paused]]:scale-100 has-[[data-paused]]:opacity-100 has-[[data-paused]]:delay-0',
'group-hover/root:scale-100 group-hover/root:opacity-100 group-hover/root:delay-0',
// Border to enhance contrast on lighter videos
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
// Reduced transparency for users with preference
// XXX: This requires a Tailwind custom variant (see 1 below)
'reduced-transparency:bg-black/70 reduced-transparency:ring-black reduced-transparency:after:ring-white/20',
// High contrast mode
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20',
),
Button: cn(
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-full',
// Background/foreground
'bg-transparent text-white/90',
// Hover and focus states
'hover:no-underline hover:bg-white/10 hover:text-white focus-visible:no-underline focus-visible:bg-white/10 focus-visible:text-white',
// Focus state
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-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-white/10 aria-expanded:text-white',
// Pressed state
'active:scale-95',
),
IconButton: cn(
'grid [&_svg]:[grid-area:1/1]',
'[&_svg]:shrink-0 [&_svg]:transition-opacity [&_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'),
TooltipPopup: cn(
'whitespace-nowrap flex origin-[var(--transform-origin)] flex-col rounded-md text-white text-xs @7xl/root:text-sm px-2 py-1',
// Background
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
// Animation
'transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[instant]:duration-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0',
// Ring
'ring-1 ring-white/10 ring-inset',
// Text shadow
'text-shadow shadow-black/10',
// Border to enhance contrast on lighter videos
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none',
),
PlayTooltipPopup: cn(
'[&_.pause-tooltip]:inline [&[data-paused]_.pause-tooltip]:hidden',
'[&_.play-tooltip]:hidden [&[data-paused]_.play-tooltip]:inline',
),
PlayTooltip: cn('play-tooltip'),
PauseTooltip: cn('pause-tooltip'),
MuteButton: cn(
'[&_svg]:opacity-0',
'[&[data-volume-level="high"]_.volume-high-icon]:opacity-100',
'[&[data-volume-level="medium"]_.volume-low-icon]:opacity-100',
'[&[data-volume-level="low"]_.volume-low-icon]:opacity-100',
'[&[data-volume-level="off"]_.volume-off-icon]:opacity-100',
),
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',
),
FullScreenTooltipPopup: cn(
'[&_.fullscreen-enter-tooltip]:inline [&[data-fullscreen]_.fullscreen-enter-tooltip]:hidden',
'[&_.fullscreen-exit-tooltip]:hidden [&[data-fullscreen]_.fullscreen-exit-tooltip]:inline',
),
FullScreenEnterTooltip: cn('fullscreen-enter-tooltip'),
FullScreenExitTooltip: cn('fullscreen-exit-tooltip'),
TimeControls: cn('flex-1 flex items-center gap-3 px-1.5'),
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(
'w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5',
'[&[data-orientation="horizontal"]]:h-1',
'[&[data-orientation="vertical"]]:w-1',
),
SliderProgress: cn('bg-white 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',
'opacity-0 transition-[opacity,height,width] ease-in-out',
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500',
'group-hover/slider:opacity-100 group-focus-within/slider:opacity-100',
'size-2.5 active:size-3 group-active/slider:size-3 hover:cursor-ew-resize',
),
PopoverPopup: cn(
'relative px-2 py-4 rounded-2xl',
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
'ring ring-white/10 ring-inset shadow-sm shadow-black/15',
// Border to enhance contrast on lighter videos
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
// Reduced transparency for users with preference
// XXX: This requires a Tailwind custom variant (see 1 below)
'reduced-transparency:bg-black/70 reduced-transparency:ring-black reduced-transparency:after:ring-white/20',
// High contrast mode
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20',
),
};
/*
[1] @custom-variant reduced-transparency @media (prefers-reduced-transparency: reduce);
*/
export default styles;
-32
View File
@@ -1,32 +0,0 @@
export interface FrostedSkinStyles {
readonly MediaContainer: string;
readonly Overlay: string;
readonly Controls: string;
readonly Button: string;
readonly IconButton: string;
readonly PlayButton: string;
readonly PlayIcon: string;
readonly PauseIcon: string;
readonly PlayTooltipPopup: string;
readonly PlayTooltip: string;
readonly PauseTooltip: string;
readonly TooltipPopup: string;
readonly MuteButton: string;
readonly VolumeHighIcon: string;
readonly VolumeLowIcon: string;
readonly VolumeOffIcon: string;
readonly FullScreenButton: string;
readonly FullScreenEnterIcon: string;
readonly FullScreenExitIcon: string;
readonly FullScreenTooltipPopup: string;
readonly FullScreenEnterTooltip: string;
readonly FullScreenExitTooltip: string;
readonly SliderRoot: string;
readonly SliderTrack: string;
readonly SliderProgress: string;
readonly SliderPointer: string;
readonly SliderThumb: string;
readonly PopoverPopup: string;
readonly TimeControls: string;
readonly TimeDisplay: string;
}
@@ -0,0 +1,3 @@
import '@videojs/react/skins/frosted.css';
export { FrostedSkin as default } from '@videojs/react';
@@ -0,0 +1,3 @@
import '@videojs/react/skins/minimal.css';
export { MinimalSkin as default } from '@videojs/react';
@@ -1,75 +0,0 @@
import type { PropsWithChildren } from 'react';
import {
CurrentTimeDisplay,
DurationDisplay,
FullscreenButton,
MediaContainer,
MuteButton,
PlayButton,
TimeSlider,
} from '@videojs/react';
import {
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PlayIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/react/icons';
import styles from './styles';
type SkinProps = PropsWithChildren<{
className?: string;
}>;
export default function MinimalSkin({ children, className = '' }: SkinProps): JSX.Element {
return (
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
{children}
<div className={styles.Overlay} aria-hidden="true" />
<div className={styles.Controls}>
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={`${styles.PlayIcon} ${styles.Icon}`} />
<PauseIcon className={`${styles.PauseIcon} ${styles.Icon}`} />
</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>
<TimeSlider.Root className={`${styles.SliderRoot} ${styles.TimeSliderRoot}`}>
<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 className={styles.ButtonGroup}>
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
<VolumeHighIcon className={`${styles.VolumeHighIcon} ${styles.Icon}`} />
<VolumeLowIcon className={`${styles.VolumeLowIcon} ${styles.Icon}`} />
<VolumeOffIcon className={`${styles.VolumeOffIcon} ${styles.Icon}`} />
</MuteButton>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullscreenButton}`}>
<FullscreenEnterIcon className={`${styles.FullscreenEnterIcon} ${styles.Icon}`} />
<FullscreenExitIcon className={`${styles.FullscreenExitIcon} ${styles.Icon}`} />
</FullscreenButton>
</div>
</div>
</MediaContainer>
);
}