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