From 7d3be367f5b31b8a6d5b9a9e3c87245f95b8e22a Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Thu, 19 Feb 2026 07:43:44 +1100 Subject: [PATCH] feat(react): implement default and minimal video skins (#550) --- .../src/core/ui/pip-button/pip-button-core.ts | 2 +- packages/icons/package.json | 2 + packages/icons/scripts/build.ts | 73 +++- .../src/assets/default/fullscreen-enter.svg | 12 +- .../src/assets/default/fullscreen-exit.svg | 12 +- packages/icons/src/assets/default/pause.svg | 6 +- packages/icons/src/assets/default/pip.svg | 4 + packages/icons/src/assets/default/play.svg | 6 +- packages/icons/src/assets/default/restart.svg | 4 + packages/icons/src/assets/default/seek.svg | 3 + .../icons/src/assets/default/volume-high.svg | 10 +- .../icons/src/assets/default/volume-low.svg | 6 +- .../icons/src/assets/default/volume-off.svg | 6 +- .../src/assets/minimal/fullscreen-enter.svg | 14 +- .../src/assets/minimal/fullscreen-exit.svg | 14 +- packages/icons/src/assets/minimal/pause.svg | 6 +- packages/icons/src/assets/minimal/pip.svg | 4 + packages/icons/src/assets/minimal/play.svg | 6 +- packages/icons/src/assets/minimal/restart.svg | 4 + packages/icons/src/assets/minimal/seek.svg | 3 + .../icons/src/assets/minimal/volume-high.svg | 10 +- .../icons/src/assets/minimal/volume-low.svg | 6 +- .../icons/src/assets/minimal/volume-off.svg | 6 +- packages/react/package.json | 1 + packages/react/src/presets/types.ts | 9 +- .../react/src/presets/video/minimal-skin.css | 375 ++++++++++++++++- .../react/src/presets/video/minimal-skin.tsx | 159 +++++++- packages/react/src/presets/video/skin.css | 380 +++++++++++++++++- packages/react/src/presets/video/skin.tsx | 153 ++++++- .../react/src/ui/buffering-indicator/index.ts | 1 + .../react/src/ui/fullscreen-button/index.ts | 1 + packages/react/src/ui/mute-button/index.ts | 1 + packages/react/src/ui/pip-button/index.ts | 1 + packages/react/src/ui/play-button/index.ts | 1 + packages/react/src/ui/poster/index.ts | 1 + packages/react/src/ui/seek-button/index.ts | 1 + packages/react/src/utils/types.ts | 2 +- packages/react/src/utils/use-composed-refs.ts | 22 +- packages/utils/src/style/cn.ts | 30 +- packages/utils/src/style/tests/cn.test.ts | 28 ++ pnpm-lock.yaml | 3 + site/src/content/docs/concepts/skins.mdx | 55 ++- .../react/FrostedSkin/FrostedSkinDemo.tsx | 25 +- .../react/MinimalSkin/MinimalSkinDemo.tsx | 24 +- 44 files changed, 1332 insertions(+), 160 deletions(-) create mode 100644 packages/icons/src/assets/default/pip.svg create mode 100644 packages/icons/src/assets/default/restart.svg create mode 100644 packages/icons/src/assets/default/seek.svg mode change 100755 => 100644 packages/icons/src/assets/default/volume-high.svg create mode 100644 packages/icons/src/assets/minimal/pip.svg create mode 100644 packages/icons/src/assets/minimal/restart.svg create mode 100644 packages/icons/src/assets/minimal/seek.svg mode change 100755 => 100644 packages/icons/src/assets/minimal/volume-high.svg create mode 100644 packages/react/src/ui/buffering-indicator/index.ts create mode 100644 packages/react/src/ui/fullscreen-button/index.ts create mode 100644 packages/react/src/ui/mute-button/index.ts create mode 100644 packages/react/src/ui/pip-button/index.ts create mode 100644 packages/react/src/ui/play-button/index.ts create mode 100644 packages/react/src/ui/poster/index.ts create mode 100644 packages/react/src/ui/seek-button/index.ts diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts index 857d3887..dabc24b1 100644 --- a/packages/core/src/core/ui/pip-button/pip-button-core.ts +++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -42,7 +42,7 @@ export class PiPButtonCore { return label; } - return state.pip ? 'Exit PiP' : 'Enter PiP'; + return state.pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture'; } getAttrs(state: PiPButtonState) { diff --git a/packages/icons/package.json b/packages/icons/package.json index 26a6ec18..c69802b0 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -29,6 +29,8 @@ ], "scripts": { "build": "node --import tsx scripts/build.ts", + "build:watch": "pnpm run build -- --watch", + "dev": "pnpm run build:watch", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/icons/scripts/build.ts b/packages/icons/scripts/build.ts index d296f872..f016c445 100644 --- a/packages/icons/scripts/build.ts +++ b/packages/icons/scripts/build.ts @@ -1,10 +1,13 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; + +const isWatch = process.argv.includes('--watch'); + import { transform } from '@svgr/core'; import { camelCase, pascalCase } from '@videojs/utils/string'; import { transform as esbuildTransform } from 'esbuild'; -import { optimize } from 'svgo'; +import { type Config, optimize } from 'svgo'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -14,9 +17,30 @@ const DIST_DIR = join(ROOT, 'dist'); const FRAMEWORKS = ['react', 'html'] as const; type Framework = (typeof FRAMEWORKS)[number]; -const SVGO_CONFIG = { +const SVGO_CONFIG: Config = { multipass: true, - plugins: [], + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, + }, + }, + }, + { + name: 'removeAttrs', + params: { + attrs: ['^fill$', '^stroke$', '^clip-rule$', '^fill-rule$'], + }, + }, + { + name: 'addAttributesToSVGElement', + params: { + attributes: [{ fill: 'currentColor' }], + }, + }, + ], }; function ensureDir(path: string): void { @@ -44,8 +68,9 @@ function optimizeSvg(svgContent: string): string { } async function buildReactComponent(svgContent: string, componentName: string): Promise<{ js: string; tsx: string }> { - const transformOpts = { + const transformOpts: Parameters[1] = { plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + jsxRuntime: 'automatic', svgoConfig: SVGO_CONFIG, }; @@ -83,7 +108,7 @@ function buildIndexTypes(icons: { name: string; varName: string }[], framework: async function buildIconSet(setName: string): Promise { const svgFiles = getSvgFiles(setName); - console.log(` Building set: ${setName} (${svgFiles.length} icons)`); + console.log(`Building set: ${setName} (${svgFiles.length} icons)`); const icons = svgFiles.map((file) => ({ name: file.replace('.svg', ''), @@ -119,18 +144,46 @@ async function buildIconSet(setName: string): Promise { } } -async function main(): Promise { - console.log('Building icons...\n'); - cleanDist(); - +async function build(): Promise { const sets = getIconSets(); console.log(`Found ${sets.length} icon sets: ${sets.join(', ')}\n`); for (const set of sets) { await buildIconSet(set); } +} +function debounce(fn: () => void, ms: number): () => void { + let timer: ReturnType; + return () => { + clearTimeout(timer); + timer = setTimeout(fn, ms); + }; +} + +async function main(): Promise { + console.log('Building icons...\n'); + cleanDist(); + await build(); console.log('\nBuild complete!'); + + if (isWatch) { + const rebuild = debounce(() => { + console.log('\nRebuilding icons...\n'); + build() + .then(() => console.log('\nRebuild complete!')) + .catch(console.error); + }, 200); + + watch(ASSETS_DIR, { recursive: true }, (_event, filename) => { + if (filename?.endsWith('.svg')) { + console.log(`Changed: ${filename}`); + rebuild(); + } + }); + + console.log('\nWatching for changes...'); + } } main().catch(console.error); diff --git a/packages/icons/src/assets/default/fullscreen-enter.svg b/packages/icons/src/assets/default/fullscreen-enter.svg index fb646cd4..5abf4719 100644 --- a/packages/icons/src/assets/default/fullscreen-enter.svg +++ b/packages/icons/src/assets/default/fullscreen-enter.svg @@ -1,10 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/fullscreen-exit.svg b/packages/icons/src/assets/default/fullscreen-exit.svg index 586fc235..66d75161 100644 --- a/packages/icons/src/assets/default/fullscreen-exit.svg +++ b/packages/icons/src/assets/default/fullscreen-exit.svg @@ -1,10 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/pause.svg b/packages/icons/src/assets/default/pause.svg index 94b7dc64..9c9a987e 100644 --- a/packages/icons/src/assets/default/pause.svg +++ b/packages/icons/src/assets/default/pause.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/pip.svg b/packages/icons/src/assets/default/pip.svg new file mode 100644 index 00000000..24a1290a --- /dev/null +++ b/packages/icons/src/assets/default/pip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/default/play.svg b/packages/icons/src/assets/default/play.svg index 9646cc51..61cc47ac 100644 --- a/packages/icons/src/assets/default/play.svg +++ b/packages/icons/src/assets/default/play.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/restart.svg b/packages/icons/src/assets/default/restart.svg new file mode 100644 index 00000000..f33253f8 --- /dev/null +++ b/packages/icons/src/assets/default/restart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/default/seek.svg b/packages/icons/src/assets/default/seek.svg new file mode 100644 index 00000000..a785e41b --- /dev/null +++ b/packages/icons/src/assets/default/seek.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/default/volume-high.svg b/packages/icons/src/assets/default/volume-high.svg old mode 100755 new mode 100644 index bae36d94..74c2377e --- a/packages/icons/src/assets/default/volume-high.svg +++ b/packages/icons/src/assets/default/volume-high.svg @@ -1,8 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/default/volume-low.svg b/packages/icons/src/assets/default/volume-low.svg index 07fcbb44..a004a971 100644 --- a/packages/icons/src/assets/default/volume-low.svg +++ b/packages/icons/src/assets/default/volume-low.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/default/volume-off.svg b/packages/icons/src/assets/default/volume-off.svg index e776b6a9..0c59006c 100644 --- a/packages/icons/src/assets/default/volume-off.svg +++ b/packages/icons/src/assets/default/volume-off.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/fullscreen-enter.svg b/packages/icons/src/assets/minimal/fullscreen-enter.svg index 63623fa5..873a94ef 100644 --- a/packages/icons/src/assets/minimal/fullscreen-enter.svg +++ b/packages/icons/src/assets/minimal/fullscreen-enter.svg @@ -1,10 +1,6 @@ - - - + + + + + diff --git a/packages/icons/src/assets/minimal/fullscreen-exit.svg b/packages/icons/src/assets/minimal/fullscreen-exit.svg index 6c085818..8ffadf0f 100644 --- a/packages/icons/src/assets/minimal/fullscreen-exit.svg +++ b/packages/icons/src/assets/minimal/fullscreen-exit.svg @@ -1,10 +1,6 @@ - - - + + + + + diff --git a/packages/icons/src/assets/minimal/pause.svg b/packages/icons/src/assets/minimal/pause.svg index 94b7dc64..a5a1e0db 100644 --- a/packages/icons/src/assets/minimal/pause.svg +++ b/packages/icons/src/assets/minimal/pause.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/minimal/pip.svg b/packages/icons/src/assets/minimal/pip.svg new file mode 100644 index 00000000..df6f6ea2 --- /dev/null +++ b/packages/icons/src/assets/minimal/pip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/minimal/play.svg b/packages/icons/src/assets/minimal/play.svg index 9646cc51..68a80573 100644 --- a/packages/icons/src/assets/minimal/play.svg +++ b/packages/icons/src/assets/minimal/play.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/restart.svg b/packages/icons/src/assets/minimal/restart.svg new file mode 100644 index 00000000..6a058b5c --- /dev/null +++ b/packages/icons/src/assets/minimal/restart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/minimal/seek.svg b/packages/icons/src/assets/minimal/seek.svg new file mode 100644 index 00000000..4784dde0 --- /dev/null +++ b/packages/icons/src/assets/minimal/seek.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/minimal/volume-high.svg b/packages/icons/src/assets/minimal/volume-high.svg old mode 100755 new mode 100644 index bae36d94..74c2377e --- a/packages/icons/src/assets/minimal/volume-high.svg +++ b/packages/icons/src/assets/minimal/volume-high.svg @@ -1,8 +1,4 @@ - - - + + + diff --git a/packages/icons/src/assets/minimal/volume-low.svg b/packages/icons/src/assets/minimal/volume-low.svg index 07fcbb44..a004a971 100644 --- a/packages/icons/src/assets/minimal/volume-low.svg +++ b/packages/icons/src/assets/minimal/volume-low.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/icons/src/assets/minimal/volume-off.svg b/packages/icons/src/assets/minimal/volume-off.svg index e776b6a9..0c59006c 100644 --- a/packages/icons/src/assets/minimal/volume-off.svg +++ b/packages/icons/src/assets/minimal/volume-off.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/react/package.json b/packages/react/package.json index 3351cfa0..2fb788e7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@videojs/core": "workspace:*", + "@videojs/icons": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" }, diff --git a/packages/react/src/presets/types.ts b/packages/react/src/presets/types.ts index 5ace3e65..69f0d47a 100644 --- a/packages/react/src/presets/types.ts +++ b/packages/react/src/presets/types.ts @@ -1,3 +1,8 @@ -import type { PropsWithChildren } from 'react'; +import type { CSSProperties, PropsWithChildren } from 'react'; -export type BaseSkinProps = PropsWithChildren; +export type BaseSkinProps = PropsWithChildren< + T & { + style?: CSSProperties; + className?: string; + } +>; diff --git a/packages/react/src/presets/video/minimal-skin.css b/packages/react/src/presets/video/minimal-skin.css index abd1fe89..4561e29b 100644 --- a/packages/react/src/presets/video/minimal-skin.css +++ b/packages/react/src/presets/video/minimal-skin.css @@ -1,3 +1,376 @@ +.media-minimal-skin * { + box-sizing: border-box; +} + .media-minimal-skin { - color: var(--media-color, red); + position: relative; + isolation: isolate; + container: media-root / inline-size; + overflow: clip; + border-radius: var(--media-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; +} +.media-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) { + .media-minimal-skin::after { + box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15); + } +} + +/* Buffering indicator */ +.media-minimal-skin .media-buffering-indicator { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; + color: #fff; +} + +/* Poster image */ +.media-minimal-skin > img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + transition: opacity 0.25s; +} +.media-minimal-skin > img:not([data-visible]) { + opacity: 0; +} + +/* Fullscreen */ +.media-minimal-skin:fullscreen { + border-radius: 0; +} + +.media-minimal-skin > video { + display: block; + width: 100%; + height: 100%; +} + +/* Media Container UI Overlay Styling */ +.media-minimal-skin .media-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; +} +.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay { + transition-delay: 500ms; + transition-duration: 500ms; + opacity: 0; +} +@media (prefers-reduced-motion: reduce) { + .media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay { + transition-duration: 100ms; + } +} + +/* Media Control Bar UI/Styles */ +.media-minimal-skin .media-controls { + 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); + z-index: 10; +} +.media-minimal-skin .media-controls:not([data-visible]) { + opacity: 0; + transform: translateY(100%); + filter: blur(8px); + transition-delay: 500ms; + transition-duration: 500ms; + pointer-events: none; +} +@media (prefers-reduced-motion: reduce) { + .media-minimal-skin .media-controls:not([data-visible]) { + transition-duration: 100ms; + transform: translateY(0); + filter: blur(0); + scale: 1; + } +} +@container media-root (width > 640px) { + .media-minimal-skin .media-controls { + padding: 2.5rem 0.75rem 0.75rem 0.75rem; + gap: 0.875rem; + } +} + +/* Time Display */ +.media-minimal-skin .media-time-group { + display: flex; + align-items: center; + gap: 0.25rem; +} +.media-minimal-skin .media-duration-display { + display: contents; + color: oklab(1 0 0 / 0.5); +} +.media-minimal-skin .media-time-display { + text-shadow: 0 1px 0 oklab(0 0 0 / 0.2); + font-variant-numeric: tabular-nums; +} + +/* Button Groups (layout only) */ +.media-minimal-skin .media-button-group { + display: flex; + align-items: center; + gap: 0.075rem; +} +@container media-root (width > 640px) { + .media-minimal-skin .media-button-group { + gap: 0.125rem; + } +} + +/* Buttons */ +.media-minimal-skin .media-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; +} +.media-minimal-skin .media-button:hover, +.media-minimal-skin .media-button:focus-visible, +.media-minimal-skin .media-button[aria-expanded="true"] { + color: oklab(1 0 0 / 0.8); + text-decoration: none; +} +.media-minimal-skin .media-button:focus-visible { + outline-color: oklab(1 0 0); + outline-offset: 2px; +} +.media-minimal-skin .media-button[disabled] { + cursor: not-allowed; + opacity: 0.5; + filter: grayscale(1); +} + +/* Icons */ +.media-minimal-skin .media-icon__container { + position: relative; +} +.media-minimal-skin .media-icon { + grid-area: 1 / 1; + width: 18px; + height: 18px; + filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25)); + transition-behavior: allow-discrete; + transition-property: display, opacity; + transition-duration: 150ms; + transition-timing-function: ease-out; +} +.media-minimal-skin .media-icon--hidden { + display: none; + opacity: 0; +} +.media-minimal-skin .media-icon--flipped { + scale: -1 1; +} +.media-minimal-skin .media-icon--seek ~ .media-icon__label { + position: absolute; + right: 0; + bottom: -3px; + font-size: 0.75em; + font-weight: 480; +} +.media-minimal-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label { + left: 0; + right: unset; +} + +/* TimeSlider Component Styles */ +.media-minimal-skin .media-slider { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-radius: calc(infinity * 1px); + outline: none; +} + +/* Horizontal orientation styles */ +.media-minimal-skin .media-slider[data-orientation="horizontal"] { + min-width: 5rem; + width: 100%; + height: 1.25rem; +} + +/* Vertical orientation styles */ +.media-minimal-skin .media-slider[data-orientation="vertical"] { + height: 4.5rem; + width: 1.25rem; +} + +.media-minimal-skin .media-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 */ +.media-minimal-skin .media-slider__track[data-orientation="horizontal"] { + width: 100%; + height: 0.1875rem; +} + +/* Vertical track styles */ +.media-minimal-skin .media-slider__track[data-orientation="vertical"] { + width: 0.1875rem; + height: 100%; +} + +.media-minimal-skin .media-slider:focus-visible .media-slider__track { + outline-color: oklab(1 0 0); + outline-offset: 6px; +} + +.media-minimal-skin .media-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; +} +.media-minimal-skin .media-slider:hover .media-slider__thumb, +.media-minimal-skin .media-slider:focus-within .media-slider__thumb { + opacity: 1; + scale: 1; +} +.media-minimal-skin .media-slider__track[data-orientation="horizontal"] .media-slider__thumb { + cursor: ew-resize; +} +.media-minimal-skin .media-slider__track[data-orientation="vertical"] .media-slider__thumb { + cursor: ns-resize; +} + +.media-minimal-skin .media-slider__pointer { + display: none; +} + +.media-minimal-skin .media-slider__progress { + background-color: oklab(1 0 0); + border-radius: inherit; +} + +.media-minimal-skin .media-slider__time-display { + font-variant-numeric: tabular-nums; +} + +.media-minimal-skin .media-popup-animation { + transition-property: transform, scale, opacity, filter; + transition-duration: 200ms; + transform: scale(1); + transform-origin: bottom; + opacity: 1; + filter: blur(0px); +} +.media-minimal-skin .media-popup-animation[data-starting-style], +.media-minimal-skin .media-popup-animation[data-ending-style] { + transform: scale(0); + opacity: 0; + filter: blur(8px); +} +.media-minimal-skin .media-popup-animation[data-instant] { + transition-duration: 0ms; +} + +.media-minimal-skin .media-popover-popup { + padding: 0.5rem; + margin: 0; + border: 0; + background: transparent; +} + +/* Tooltip Component Styles */ +.media-minimal-skin .media-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) { + .media-minimal-skin .media-tooltip-popup { + background-color: oklab(0 0 0 / 0.7); + } +} +@media (prefers-contrast: more) { + .media-minimal-skin .media-tooltip-popup { + background-color: oklab(0 0 0 / 0.9); + } +} + +.media-minimal-skin .media-tooltip { + white-space: nowrap; } diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index f1d19cc5..448cd272 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -1,8 +1,161 @@ +import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core'; +import { + FullscreenEnterIcon, + FullscreenExitIcon, + PauseIcon, + PipIcon, + PlayIcon, + RestartIcon, + SeekIcon, + SpinnerIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@videojs/icons/react/minimal'; +import { cn } from '@videojs/utils/style'; +import { type ComponentProps, forwardRef, type ReactNode } from 'react'; +import { Container } from '@/player/context'; +import { BufferingIndicator } from '@/ui/buffering-indicator'; +import { Controls } from '@/ui/controls'; +import { FullscreenButton } from '@/ui/fullscreen-button'; +import { MuteButton } from '@/ui/mute-button'; +import { PiPButton } from '@/ui/pip-button'; +import { PlayButton } from '@/ui/play-button'; +import { SeekButton } from '@/ui/seek-button'; +import { Time } from '@/ui/time'; import type { BaseSkinProps } from '../types'; +const SEEK_TIME = 10; + export type MinimalVideoSkinProps = BaseSkinProps; -export function MinimalVideoSkin(props: MinimalVideoSkinProps) { - const { children } = props; - return
{children}
; +const Button = forwardRef>(function Button(props, ref) { + return + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + + + + + + + + {/* Temporary spacer */} +
+ +
+ ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> +
+ + +
+ + ); } diff --git a/packages/react/src/presets/video/skin.css b/packages/react/src/presets/video/skin.css index 9c9762b2..7fc9992f 100644 --- a/packages/react/src/presets/video/skin.css +++ b/packages/react/src/presets/video/skin.css @@ -1,3 +1,379 @@ -.media-skin { - color: var(--media-color, red); +.media-default-skin * { + box-sizing: border-box; +} + +.media-default-skin { + position: relative; + isolation: isolate; + container: media-root / inline-size; + overflow: clip; + border-radius: var(--media-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; +} +.media-default-skin::before, +.media-default-skin::after { + content: ""; + position: absolute; + pointer-events: none; + border-radius: inherit; + z-index: 10; +} +.media-default-skin::before { + inset: 1px; + box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15); +} +.media-default-skin::after { + inset: 0; + box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.1); +} + +/* Buffering indicator */ +.media-default-skin .media-buffering-indicator { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; + color: #fff; +} +.media-default-skin .media-buffering-indicator .media-surface { + padding: 0.25rem; + border-radius: 100%; +} + +/* Poster image */ +.media-default-skin > img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + transition: opacity 0.25s; +} +.media-default-skin > img:not([data-visible]) { + opacity: 0; +} + +/* Fullscreen */ +.media-default-skin:fullscreen { + border-radius: 0; +} + +.media-default-skin > video { + display: block; + width: 100%; + height: 100%; +} + +/* Media Container UI Overlay Styling */ +.media-default-skin .media-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; +} +.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay { + transition-delay: 500ms; + transition-duration: 300ms; + opacity: 0; +} +@media (prefers-reduced-motion: reduce) { + .media-default-skin .media-controls:not([data-visible]) ~ .media-overlay { + transition-duration: 100ms; + } +} + +/* Common Surface Styles - e.g. tooltips, popovers, controls */ +.media-default-skin .media-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) { + .media-default-skin .media-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) { + .media-default-skin .media-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 */ +.media-default-skin .media-controls { + 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; + z-index: 10; +} +.media-default-skin .media-controls:not([data-visible]) { + pointer-events: none; + filter: blur(8px); + scale: 0.9; + opacity: 0; + transition-delay: 500ms; + transition-duration: 300ms; +} +@media (prefers-reduced-motion: reduce) { + .media-default-skin .media-controls:not([data-visible]) { + transition-duration: 100ms; + filter: blur(0); + scale: 1; + } +} + +/* Time Display */ +.media-default-skin .media-time-group { + display: flex; + align-items: center; + flex: 1; + gap: 0.75rem; + padding-inline: 0.375rem; +} +.media-default-skin .media-time-display { + text-shadow: 0 1px 0 oklab(0 0 0 / 0.25); + font-variant-numeric: tabular-nums; +} + +/* Buttons */ +.media-default-skin .media-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; + text-shadow: 0 1px 0 oklab(0 0 0 / 0.25); +} +.media-default-skin .media-button:hover, +.media-default-skin .media-button:focus-visible, +.media-default-skin .media-button[aria-expanded="true"] { + background-color: oklab(1 0 0 / 0.1); + color: oklab(1 0 0); + text-decoration: none; +} +.media-default-skin .media-button:focus-visible { + outline-color: oklch(62.3% 0.214 259.815); + outline-offset: 2px; +} +.media-default-skin .media-button[disabled] { + cursor: not-allowed; + opacity: 0.5; + filter: grayscale(1); +} + +/* Icons */ +.media-default-skin .media-icon__container { + position: relative; +} +.media-default-skin .media-icon { + grid-area: 1 / 1; + width: 18px; + height: 18px; + filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25)); + transition-behavior: allow-discrete; + transition-property: display, opacity; + transition-duration: 150ms; + transition-timing-function: ease-out; +} +.media-default-skin .media-icon--hidden { + display: none; + opacity: 0; +} +.media-default-skin .media-icon--flipped { + scale: -1 1; +} +.media-default-skin .media-icon--seek ~ .media-icon__label { + position: absolute; + right: 0; + bottom: -3px; + font-size: 0.75em; + font-weight: 480; +} +.media-default-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label { + left: 0; + right: unset; +} + +/* TimeSlider Component Styles */ +.media-default-skin .media-slider { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-radius: calc(infinity * 1px); + outline: none; +} + +/* Horizontal orientation styles */ +.media-default-skin .media-slider[data-orientation="horizontal"] { + min-width: 5rem; + width: 100%; + height: 1.25rem; +} + +/* Vertical orientation styles */ +.media-default-skin .media-slider[data-orientation="vertical"] { + height: 5rem; + width: 1.25rem; +} + +.media-default-skin .media-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 */ +.media-default-skin .media-slider__track[data-orientation="horizontal"] { + width: 100%; + height: 0.25rem; +} + +/* Vertical track styles */ +.media-default-skin .media-slider__track[data-orientation="vertical"] { + width: 0.25rem; + height: 100%; +} + +.media-default-skin .media-slider:focus-visible .media-slider__track { + outline-color: oklch(62.3% 0.214 259.815); + outline-offset: 6px; +} + +.media-default-skin .media-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; +} +.media-default-skin .media-slider__thumb:active { + width: 0.75rem; + height: 0.75rem; +} +.media-default-skin .media-slider:hover .media-slider__thumb, +.media-default-skin .media-slider:focus-within .media-slider__thumb { + opacity: 1; +} +.media-default-skin .media-slider__track[data-orientation="horizontal"] .media-slider__thumb { + cursor: ew-resize; +} +.media-default-skin .media-slider__track[data-orientation="vertical"] .media-slider__thumb { + cursor: ns-resize; +} + +.media-default-skin .media-slider__pointer { + background-color: oklab(1 0 0 / 0.2); + border-radius: inherit; +} + +.media-default-skin .media-slider__progress { + background-color: oklab(1 0 0); + border-radius: inherit; +} + +.media-default-skin .media-slider__time-display { + font-variant-numeric: tabular-nums; +} + +.media-default-skin .media-popup-animation { + transition-property: transform, scale, opacity, filter; + transition-duration: 200ms; + transform: scale(1); + transform-origin: bottom; + opacity: 1; + filter: blur(0px); +} +.media-default-skin .media-popup-animation[data-starting-style], +.media-default-skin .media-popup-animation[data-ending-style] { + transform: scale(0); + opacity: 0; + filter: blur(8px); +} +.media-default-skin .media-popup-animation[data-instant] { + transition-duration: 0ms; +} + +.media-default-skin .media-popover-popup { + padding: 0.75rem 0.25rem; + border-radius: calc(infinity * 1px); + margin: 0; + border: 0; +} + +/* Tooltip Component Styles */ +.media-default-skin .media-tooltip-popup { + color: oklab(1 0 0); + padding: 0.25rem 0.625rem; + border-radius: calc(infinity * 1px); + font-size: 0.75rem; +} + +.media-default-skin .media-tooltip { + white-space: nowrap; } diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index f183d9d1..12f3f0af 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -1,8 +1,155 @@ +import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core'; +import { + FullscreenEnterIcon, + FullscreenExitIcon, + PauseIcon, + PipIcon, + PlayIcon, + RestartIcon, + SeekIcon, + SpinnerIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@videojs/icons/react'; +import { cn } from '@videojs/utils/style'; +import { type ComponentProps, forwardRef, type ReactNode } from 'react'; +import { Container } from '@/player/context'; +import { BufferingIndicator } from '@/ui/buffering-indicator'; +import { Controls } from '@/ui/controls'; +import { FullscreenButton } from '@/ui/fullscreen-button'; +import { MuteButton } from '@/ui/mute-button'; +import { PiPButton } from '@/ui/pip-button'; +import { PlayButton } from '@/ui/play-button'; +import { SeekButton } from '@/ui/seek-button'; +import { Time } from '@/ui/time'; import type { BaseSkinProps } from '../types'; +const SEEK_TIME = 10; + export type VideoSkinProps = BaseSkinProps; -export function VideoSkin(props: VideoSkinProps) { - const { children } = props; - return
{children}
; +const Button = forwardRef>(function Button(props, ref) { + return + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + {/* Temporary spacer */} +
+ + + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + +
+ + ); } diff --git a/packages/react/src/ui/buffering-indicator/index.ts b/packages/react/src/ui/buffering-indicator/index.ts new file mode 100644 index 00000000..07b783bc --- /dev/null +++ b/packages/react/src/ui/buffering-indicator/index.ts @@ -0,0 +1 @@ +export * from './buffering-indicator'; diff --git a/packages/react/src/ui/fullscreen-button/index.ts b/packages/react/src/ui/fullscreen-button/index.ts new file mode 100644 index 00000000..89e44ae6 --- /dev/null +++ b/packages/react/src/ui/fullscreen-button/index.ts @@ -0,0 +1 @@ +export { FullscreenButton, type FullscreenButtonProps } from './fullscreen-button'; diff --git a/packages/react/src/ui/mute-button/index.ts b/packages/react/src/ui/mute-button/index.ts new file mode 100644 index 00000000..790df8ea --- /dev/null +++ b/packages/react/src/ui/mute-button/index.ts @@ -0,0 +1 @@ +export { MuteButton, type MuteButtonProps } from './mute-button'; diff --git a/packages/react/src/ui/pip-button/index.ts b/packages/react/src/ui/pip-button/index.ts new file mode 100644 index 00000000..bcec67ed --- /dev/null +++ b/packages/react/src/ui/pip-button/index.ts @@ -0,0 +1 @@ +export * from './pip-button'; diff --git a/packages/react/src/ui/play-button/index.ts b/packages/react/src/ui/play-button/index.ts new file mode 100644 index 00000000..90c51e50 --- /dev/null +++ b/packages/react/src/ui/play-button/index.ts @@ -0,0 +1 @@ +export { PlayButton, type PlayButtonProps } from './play-button'; diff --git a/packages/react/src/ui/poster/index.ts b/packages/react/src/ui/poster/index.ts new file mode 100644 index 00000000..c915ed25 --- /dev/null +++ b/packages/react/src/ui/poster/index.ts @@ -0,0 +1 @@ +export { Poster, type PosterProps } from './poster'; diff --git a/packages/react/src/ui/seek-button/index.ts b/packages/react/src/ui/seek-button/index.ts new file mode 100644 index 00000000..7903d86f --- /dev/null +++ b/packages/react/src/ui/seek-button/index.ts @@ -0,0 +1 @@ +export * from './seek-button'; diff --git a/packages/react/src/utils/types.ts b/packages/react/src/utils/types.ts index ed04567d..894eed77 100644 --- a/packages/react/src/utils/types.ts +++ b/packages/react/src/utils/types.ts @@ -6,7 +6,7 @@ export type HTMLProps = React.HTMLAttributes & { }; /** Render function signature - receives props and state, returns element. */ -export type RenderFunction = (props: Props, state: State) => ReactElement; +export type RenderFunction = (props: Props, state: State) => ReactElement | null; /** Render prop - either a React element or a render function. */ export type RenderProp = ReactElement | RenderFunction; diff --git a/packages/react/src/utils/use-composed-refs.ts b/packages/react/src/utils/use-composed-refs.ts index d74cfd06..2a3e1164 100644 --- a/packages/react/src/utils/use-composed-refs.ts +++ b/packages/react/src/utils/use-composed-refs.ts @@ -37,16 +37,20 @@ export function composeRefs(...refs: (OptionalRef | OptionalRef[])[]): return (node): (() => void) | void => { const cleanups = flatRefs.map((ref) => setRef(ref, node)); - return () => { - for (let i = 0; i < cleanups.length; i++) { - const cleanup = cleanups[i]; - if (isFunction(cleanup)) { - cleanup(); - } else { - setRef(flatRefs[i], null); + // Only return cleanup if any refs returned one (React 19+). + // React 18 handles cleanup by calling the ref callback with null. + if (cleanups.some(isFunction)) { + return () => { + for (let i = 0; i < cleanups.length; i++) { + const cleanup = cleanups[i]; + if (isFunction(cleanup)) { + cleanup(); + } else { + setRef(flatRefs[i], null); + } } - } - }; + }; + } }; } diff --git a/packages/utils/src/style/cn.ts b/packages/utils/src/style/cn.ts index f3cbd944..352c45e0 100644 --- a/packages/utils/src/style/cn.ts +++ b/packages/utils/src/style/cn.ts @@ -1,11 +1,33 @@ +import { isPlainObject, isString } from '../predicate'; + +type ClassValue = string | Record | undefined; + /** * A (very basic) utility to merge class names and make them a little easier to read. * Aims to replicate the API of popular libraries like `clsx` and `classnames` but with a much simpler implementation. * This is not intended to be a full replacement for those libraries, but it should be sufficient for our use case. * It also allows us to avoid adding an additional dependency to our packages. - * @param classes - An array of class names, which can be strings or undefined. Undefined values will be filtered out. - * @returns A single string of class names, separated by spaces. + * + * @example + * ```ts + * cn('foo', { bar: true, baz: false }, 'qux'); + * // => 'foo bar qux' + * ``` */ -export function cn(...classes: (string | undefined)[]): string { - return classes.filter(Boolean).join(' '); +export function cn(...classes: ClassValue[]): string { + const result: string[] = []; + + for (const value of classes) { + if (isString(value) && value) { + result.push(value); + } else if (isPlainObject(value)) { + for (const key in value) { + if (value[key]) { + result.push(key); + } + } + } + } + + return result.join(' '); } diff --git a/packages/utils/src/style/tests/cn.test.ts b/packages/utils/src/style/tests/cn.test.ts index 22a27bd3..12ba0679 100644 --- a/packages/utils/src/style/tests/cn.test.ts +++ b/packages/utils/src/style/tests/cn.test.ts @@ -29,4 +29,32 @@ describe('cn', () => { it('preserves class names with multiple words', () => { expect(cn('foo bar', 'baz')).toBe('foo bar baz'); }); + + it('includes keys with truthy values from an object', () => { + expect(cn({ foo: true, bar: false })).toBe('foo'); + }); + + it('includes keys for any truthy value', () => { + expect(cn({ a: 1, b: 'yes', c: 0, d: '', e: null, f: undefined, g: true })).toBe('a b g'); + }); + + it('handles an empty object', () => { + expect(cn({})).toBe(''); + }); + + it('handles an object where all values are falsy', () => { + expect(cn({ foo: false, bar: 0, baz: '' })).toBe(''); + }); + + it('mixes strings and objects', () => { + expect(cn('foo', { bar: true, baz: false }, 'qux')).toBe('foo bar qux'); + }); + + it('handles multiple objects', () => { + expect(cn({ a: true }, { b: true, c: false })).toBe('a b'); + }); + + it('mixes strings, objects, and undefined', () => { + expect(cn('foo', undefined, { bar: true }, '', { baz: false })).toBe('foo bar'); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbd7d644..b47fe28c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: '@videojs/core': specifier: workspace:* version: link:../core + '@videojs/icons': + specifier: workspace:* + version: link:../icons '@videojs/store': specifier: workspace:* version: link:../store diff --git a/site/src/content/docs/concepts/skins.mdx b/site/src/content/docs/concepts/skins.mdx index 0b4acbdd..127d479a 100644 --- a/site/src/content/docs/concepts/skins.mdx +++ b/site/src/content/docs/concepts/skins.mdx @@ -6,7 +6,7 @@ description: Packaged sets of video player UI components and styles that you can import FrameworkCase from '@/components/docs/FrameworkCase.astro'; import { FrostedSkinDemo } from '@/examples/react/FrostedSkin/FrostedSkinDemo'; import { MinimalSkinDemo } from '@/examples/react/MinimalSkin/MinimalSkinDemo'; -import MinimalFrame from '@/components/frames/Minimal.astro'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; import DocsLink from '@/components/docs/DocsLink.astro'; In Video.js v10, skins are **complete, packaged player designs** that include both UI components and their styles. This is a significant change from v8, where skins were purely CSS themes applied to a fixed component structure. @@ -32,14 +32,22 @@ A modern, glassy design with backdrop blur effects and polished interactions. ```tsx -import { VideoProvider, FrostedSkin, Video } from '@videojs/react-preview'; -import '@videojs/react-preview/skins/frosted.css'; +import { createPlayer, features, Poster } from '@videojs/react'; +import { VideoSkin, Video } from '@videojs/react/video'; +import '@videojs/react/video/skin.css'; - - - - +const Player = createPlayer({ features: [...features.video] }); + +export function VideoPlayer() { + return ( + + + + + ); +} ``` @@ -52,9 +60,9 @@ import '@videojs/react-preview/skins/frosted.css'; ``` - + - + ### Minimal @@ -62,14 +70,22 @@ A clean, straightforward design that focuses on simplicity and clarity. ```tsx -import { VideoProvider, MinimalSkin, Video } from '@videojs/react-preview'; -import '@videojs/react-preview/skins/minimal.css'; +import { createPlayer, features, Poster } from '@videojs/react'; +import { MinimalVideoSkin, Video } from '@videojs/react/video'; +import '@videojs/react/video/minimal-skin.css'; - - - - +const Player = createPlayer({ features: [...features.video] }); + +export function VideoPlayer() { + return ( + + + + + ); +} ``` @@ -82,9 +98,9 @@ import '@videojs/react-preview/skins/minimal.css'; ``` - + - + ## Customizing skins @@ -94,7 +110,6 @@ Ready to make it your own? Learn how to ## Building your own skin - A skin is simply a component that: 1. Wraps content in a `MediaContainer` diff --git a/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx b/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx index 73a6f88f..17612873 100644 --- a/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx +++ b/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx @@ -1,9 +1,13 @@ -import { FrostedSkin, Video, VideoProvider } from '@videojs/react-preview'; + import { VJS8_DEMO_VIDEO } from '@/consts'; -import '@videojs/react-preview/skins/frosted.css'; +import { createPlayer, features, Poster } from '@videojs/react'; +import { VideoSkin, Video } from '@videojs/react/video'; +import '@videojs/react/video/skin.css'; + +const Player = createPlayer({ features: [...features.video] }); /** - * Live demo of the FrostedSkin design. + * Live demo of the (default) frosted video skin design. * * Features demonstrated: * - Glassmorphic controls with backdrop blur @@ -14,14 +18,11 @@ import '@videojs/react-preview/skins/frosted.css'; */ export function FrostedSkinDemo() { return ( - - - - + + + + ); } diff --git a/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx b/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx index d1d7b016..a2dab599 100644 --- a/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx +++ b/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx @@ -1,9 +1,12 @@ -import { MinimalSkin, Video, VideoProvider } from '@videojs/react-preview'; import { VJS8_DEMO_VIDEO } from '@/consts'; -import '@videojs/react-preview/skins/minimal.css'; +import { createPlayer, features, Poster } from '@videojs/react'; +import { MinimalVideoSkin, Video } from '@videojs/react/video'; +import '@videojs/react/video/minimal-skin.css'; + +const Player = createPlayer({ features: [...features.video] }); /** - * Live demo of the MinimalSkin design. + * Live demo of the minimal video skin design. * * Features demonstrated: * - Clean, uncluttered interface @@ -13,14 +16,11 @@ import '@videojs/react-preview/skins/minimal.css'; */ export function MinimalSkinDemo() { return ( - - - - + + + + ); }