fix(skins): slightly more idiomatic Tailwind, added custom properties (#175)

This commit is contained in:
Sam Potts
2025-11-10 22:52:16 +11:00
committed by GitHub
parent 4a2d580bb3
commit 694afb7de9
20 changed files with 134 additions and 360 deletions
-13
View File
@@ -4,19 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VJS HTML Demo</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<style>
body {
font-family:
InterVariable, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
margin: 0;
}
* {
box-sizing: border-box;
}
</style>
</head>
<body style="display: flex; flex-direction: column; min-height: 100vh; max-width: 960px; padding: 1rem">
<h1>Video.js 10 HTML Demos</h1>
+1 -1
View File
@@ -27,7 +27,7 @@
display: block;
width: 100%;
height: 100%;
border-radius: 2rem;
border-radius: 0.75rem;
max-width: 960px;
margin: 2rem auto;
aspect-ratio: 16/9;
+4 -3
View File
@@ -7,10 +7,11 @@ media-container {
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--vjs-border-radius, 2rem);
background: oklab(0 0 0);
font-family: var(--vjs-font-family, InterVariable, Inter, ui-sans-serif, system-ui, sans-serif);
font-size: 0.8125rem;
line-height: 1.5;
border-radius: inherit;
background: oklab(0 0 0);
}
media-container::before,
media-container::after {
@@ -204,8 +205,8 @@ media-mute-button .icon {
}
media-mute-button:not([data-volume-level]) .volume-low-icon,
media-mute-button[data-volume-level='high'] .volume-high-icon,
media-mute-button[data-volume-level='medium'] .volume-high-icon,
media-mute-button[data-volume-level='low'] .volume-low-icon,
media-mute-button[data-volume-level='medium'] .volume-low-icon,
media-mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}
+6 -5
View File
@@ -7,10 +7,11 @@ media-container {
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--vjs-border-radius, 2rem);
background: oklab(0 0 0);
font-family: var(--vjs-font-family, InterVariable, Inter, ui-sans-serif, system-ui, sans-serif);
font-size: 0.8125rem;
line-height: 1.5;
border-radius: inherit;
background: oklab(0 0 0);
}
media-container::after {
content: '';
@@ -19,11 +20,11 @@ media-container::after {
border-radius: inherit;
z-index: 10;
inset: 0;
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.2);
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.15);
}
@media (prefers-color-scheme: dark) {
media-container::after {
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.2);
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
}
}
@@ -183,8 +184,8 @@ media-mute-button .icon {
}
media-mute-button:not([data-volume-level]) .volume-low-icon,
media-mute-button[data-volume-level='high'] .volume-high-icon,
media-mute-button[data-volume-level='medium'] .volume-high-icon,
media-mute-button[data-volume-level='low'] .volume-low-icon,
media-mute-button[data-volume-level='medium'] .volume-low-icon,
media-mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}
-1
View File
@@ -14,7 +14,6 @@
"clsx": "^2.1.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"screenfull": "^6.0.2",
"tailwindcss": "^4.1.13"
},
"devDependencies": {
+9 -49
View File
@@ -1,10 +1,7 @@
import type { ChangeEventHandler } from 'react';
import { FrostedSkin, HlsVideo, MinimalSkin, VideoProvider } from '@videojs/react';
import { FullscreenEnterAltIcon, FullscreenExitAltIcon } from '@videojs/react/icons';
import clsx from 'clsx';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useFullscreen } from './hooks/useFullscreen';
import { useCallback, useMemo, useState } from 'react';
// NOTE: Commented out imports are for testing locally/externally defined skins.
// import { VideoProvider, Video } from '@videojs/react';
@@ -45,22 +42,22 @@ type SkinKey = (typeof skins)[number]['key'];
const mediaSources = [
{
key: '1',
name: 'Mux 1',
name: 'Mux 1 (HLS)',
value: 'https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ.m3u8',
},
{
key: '2',
name: 'Mux 2',
name: 'Mux 2 (HLS)',
value: 'https://stream.mux.com/a4nOgmxGWg6gULfcBbAa00gXyfcwPnAFldF8RdsNyk8M.m3u8',
},
{
key: '3',
name: 'Mux 3',
name: 'Mux 3 (MP4)',
value: 'https://stream.mux.com/A3VXy02VoUinw01pwyomEO3bHnG4P32xzV7u1j1FSzjNg/high.mp4',
},
{
key: '4',
name: 'Mux 4',
name: 'Mux 4 (HLS)',
value: 'https://stream.mux.com/lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML.m3u8',
},
] as const;
@@ -85,8 +82,6 @@ const DEFAULT_MEDIA_SOURCE: MediaSourceKey = '1';
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 containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(containerRef);
const mediaSource = useMemo(() => {
let match = mediaSources.find(m => m.key === mediaSourceKey);
@@ -120,37 +115,12 @@ export default function App(): JSX.Element {
// Force a re-render on changes.
const key = `${skinKey}-${mediaSourceKey}`;
const skinClassName = useMemo(() => {
switch (skinKey) {
case 'frosted':
case 'frosted-eject':
return 'aspect-video !rounded-4xl shadow shadow-lg shadow-black/15';
case 'minimal':
case 'minimal-eject':
return 'aspect-video !rounded-2xl shadow shadow-lg shadow-black/15';
default:
return '';
}
}, [skinKey]);
const playbackId = mediaSource.match(/stream\.mux\.com\/([^./]+)/)?.[1];
const poster = playbackId ? `https://image.mux.com/${playbackId}/thumbnail.webp` : undefined;
return (
<div
ref={containerRef}
className={clsx('', {
'bg-black text-stone-200 h-screen': isFullscreen,
'min-h-screen bg-white text-stone-700 dark:bg-black dark:text-stone-200': !isFullscreen,
})}
>
<header className={clsx(
'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',
{
'-translate-y-full': isFullscreen,
},
)}
>
<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>
@@ -180,24 +150,14 @@ export default function App(): JSX.Element {
</option>
))}
</select>
<button type="button" className="p-2" onClick={toggleFullscreen}>
{isFullscreen ? <FullscreenExitAltIcon /> : <FullscreenEnterAltIcon />}
<span className="sr-only">Toggle fullscreen</span>
</button>
</nav>
</div>
</header>
<main className={clsx(
'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': !isFullscreen,
},
)}
>
<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={skinClassName}>
<Skin className="aspect-video shadow-lg shadow-black/15">
{/* @ts-expect-error -- types are incorrect */}
<HlsVideo src={mediaSource} poster={poster} playsInline />
</Skin>
@@ -1,132 +0,0 @@
import type { RefObject } from 'react';
import { useCallback, useEffect, useState } from 'react';
import screenfull from 'screenfull';
export interface UseFullscreenOptions {
/**
* When true, automatically handles fullscreen change events
* to synchronize the state with browser fullscreen status
* @default true
*/
trackChanges?: boolean;
/**
* Callback fired when fullscreen state changes
*/
onFullscreenChange?: (isFullscreen: boolean) => void;
/**
* Callback fired when fullscreen encounters an error
*/
onFullscreenError?: (error: Error) => void;
}
export interface UseFullscreenResult {
/**
* Whether the element is currently in fullscreen mode
*/
isFullscreen: boolean;
/**
* Whether fullscreen is supported in the current environment
*/
isSupported: boolean;
/**
* Request fullscreen for the target element
* @returns Promise that resolves when fullscreen is entered
*/
enter: () => Promise<void>;
/**
* Exit fullscreen mode
* @returns Promise that resolves when fullscreen is exited
*/
exit: () => Promise<void>;
/**
* Toggle fullscreen mode for the target element
* @returns Promise that resolves when the toggle is complete
*/
toggle: () => Promise<void>;
}
/**
* Hook that provides fullscreen functionality for a DOM element
*
* @param elementRef - Ref to the element to make fullscreen
* @param options - Configuration options
* @returns Methods and state for managing fullscreen
*/
export function useFullscreen(
elementRef: RefObject<HTMLElement | null>,
options: UseFullscreenOptions = {},
): UseFullscreenResult {
const {
trackChanges = true,
onFullscreenChange,
onFullscreenError,
} = options;
const [isFullscreen, setIsFullscreen] = useState(
screenfull.isEnabled ? screenfull.isFullscreen : false,
);
// Handle fullscreen change events
useEffect(() => {
if (!screenfull.isEnabled || !trackChanges) return;
const handleChange = () => {
const newIsFullscreen = screenfull.isFullscreen;
setIsFullscreen(newIsFullscreen);
onFullscreenChange?.(newIsFullscreen);
};
const handleError = () => {
onFullscreenError?.(new Error('Fullscreen error occurred'));
};
screenfull.on('change', handleChange);
screenfull.on('error', handleError);
return () => {
screenfull.off('change', handleChange);
screenfull.off('error', handleError);
};
}, [trackChanges, onFullscreenChange, onFullscreenError]);
// Request fullscreen for the target element
const enter = useCallback(async (): Promise<void> => {
if (!screenfull.isEnabled || !elementRef.current) return;
try {
await screenfull.request(elementRef.current);
} catch (error) {
onFullscreenError?.(error instanceof Error ? error : new Error(String(error)));
}
}, [elementRef, onFullscreenError]);
// Exit fullscreen mode
const exit = useCallback(async (): Promise<void> => {
if (!screenfull.isEnabled) return;
try {
await screenfull.exit();
} catch (error) {
onFullscreenError?.(error instanceof Error ? error : new Error(String(error)));
}
}, [onFullscreenError]);
// Toggle fullscreen mode for the target element
const toggle = useCallback(async (): Promise<void> => {
if (!screenfull.isEnabled || !elementRef.current) return;
try {
await screenfull.toggle(elementRef.current);
} catch (error) {
onFullscreenError?.(error instanceof Error ? error : new Error(String(error)));
}
}, [elementRef, onFullscreenError]);
return {
isFullscreen,
isSupported: screenfull.isEnabled,
enter,
exit,
toggle,
};
}
@@ -7,10 +7,11 @@
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--vjs-border-radius, 2rem);
background: oklab(0 0 0);
font-family: var(--vjs-font-family, InterVariable, Inter, ui-sans-serif, system-ui, sans-serif);
font-size: 0.8125rem;
line-height: 1.5;
border-radius: inherit;
background: oklab(0 0 0);
}
.vjs-frosted-skin::before,
.vjs-frosted-skin::after {
@@ -201,8 +202,8 @@
}
.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='medium'] .volume-low-icon,
.vjs-frosted-skin .mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}
@@ -7,10 +7,11 @@
isolation: isolate;
container: root / inline-size;
overflow: clip;
border-radius: var(--vjs-border-radius, 2rem);
background: oklab(0 0 0);
font-family: var(--vjs-font-family, InterVariable, Inter, ui-sans-serif, system-ui, sans-serif);
font-size: 0.8125rem;
line-height: 1.5;
border-radius: inherit;
background: oklab(0 0 0);
}
.vjs-minimal-skin::after {
content: '';
@@ -19,11 +20,11 @@
border-radius: inherit;
z-index: 10;
inset: 0;
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.2);
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.2);
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
}
}
@@ -180,8 +181,8 @@
}
.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='medium'] .volume-low-icon,
.vjs-minimal-skin .mute-button[data-volume-level='off'] .volume-off-icon {
display: inline;
}