mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(example/react): improvements to react examples (#210)
This commit is contained in:
+123
-146
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './CodeBlock';
|
||||
export * from './SkinLayout';
|
||||
export * from './Tabs';
|
||||
export * from './VideoElement';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
};
|
||||
+41
-40
@@ -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',
|
||||
+8
-9
@@ -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;
|
||||
+15
-2
@@ -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" />
|
||||
|
||||
+15
-2
@@ -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" />
|
||||
|
||||
+1
-1
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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, we’ll 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user