diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index 42733b5a..9fc0e95e 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -155,7 +155,11 @@ export function createSlider(options: SliderOptions): SliderApi { options.onValueChange?.(percent); // Focus the thumb for keyboard follow-up and screen reader tracking. - options.getThumbElement?.()?.focus(); + options.getThumbElement?.()?.focus({ + preventScroll: true, + // @ts-expect-error -- focusVisible is not yet in TypeScript's lib.dom typings. + focusVisible: false, + }); }, onPointerMove(event) { diff --git a/packages/core/src/dom/ui/tests/thumbnail.test.ts b/packages/core/src/dom/ui/tests/thumbnail.test.ts index 58386c00..49007072 100644 --- a/packages/core/src/dom/ui/tests/thumbnail.test.ts +++ b/packages/core/src/dom/ui/tests/thumbnail.test.ts @@ -308,6 +308,104 @@ describe('createThumbnail', () => { handle.destroy(); }); + + it('React lifecycle: updateSrc before img available, then connect after mount', () => { + let img: HTMLImageElement | null = null; + const onStateChange = vi.fn(); + + const handle = createThumbnail( + createOptions({ + getImg: () => img, + onStateChange, + }) + ); + + // 1. Render phase: updateSrc called but img ref is null (not mounted yet). + handle.updateSrc('sprite.jpg'); + expect(handle.loading).toBe(true); + + // 2. Commit phase: img becomes available (React sets ref) but image is still loading. + img = document.createElement('img'); + // Simulate a loading image: in a real browser, an img with src set is !complete + // while the network request is in flight. + Object.defineProperty(img, 'complete', { value: false, configurable: true }); + + // 3. useEffect: connect() binds events and checks img.complete. + handle.connect(); + + // Image is not complete, so loading should remain true. + expect(handle.loading).toBe(true); + + // 4. Image loads — event listener should catch it. + Object.defineProperty(img, 'naturalWidth', { value: 2560, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 1600, configurable: true }); + img.dispatchEvent(new Event('load')); + + expect(handle.loading).toBe(false); + expect(handle.naturalWidth).toBe(2560); + expect(onStateChange).toHaveBeenCalled(); + + handle.destroy(); + }); + + it('React lifecycle: handles already-loaded img when ref was null during updateSrc', () => { + let img: HTMLImageElement | null = null; + const onStateChange = vi.fn(); + + const handle = createThumbnail( + createOptions({ + getImg: () => img, + onStateChange, + }) + ); + + // 1. Render phase: updateSrc called but img ref is null. + handle.updateSrc('sprite.jpg'); + expect(handle.loading).toBe(true); + + // 2. Commit phase: img becomes available and is already cached/complete. + img = createMockImg(); + Object.defineProperty(img, 'complete', { value: true, configurable: true }); + + // 3. useEffect: connect() should detect the already-loaded image. + handle.connect(); + + expect(handle.loading).toBe(false); + expect(handle.naturalWidth).toBe(2560); + expect(onStateChange).toHaveBeenCalled(); + + handle.destroy(); + }); + + it('React lifecycle: handles errored img when ref was null during updateSrc', () => { + let img: HTMLImageElement | null = null; + const onStateChange = vi.fn(); + + const handle = createThumbnail( + createOptions({ + getImg: () => img, + onStateChange, + }) + ); + + // 1. Render phase: updateSrc called but img ref is null. + handle.updateSrc('bad.jpg'); + expect(handle.loading).toBe(true); + + // 2. Commit phase: img becomes available but image errored (complete but no dimensions). + img = document.createElement('img'); + Object.defineProperty(img, 'complete', { value: true, configurable: true }); + // naturalWidth defaults to 0 in jsdom — simulates an errored image. + + // 3. useEffect: connect() should detect the errored image. + handle.connect(); + + expect(handle.loading).toBe(false); + expect(handle.error).toBe(true); + expect(onStateChange).toHaveBeenCalled(); + + handle.destroy(); + }); }); describe('destroy', () => { diff --git a/packages/core/src/dom/ui/thumbnail.ts b/packages/core/src/dom/ui/thumbnail.ts index abec5125..8651834a 100644 --- a/packages/core/src/dom/ui/thumbnail.ts +++ b/packages/core/src/dom/ui/thumbnail.ts @@ -108,15 +108,21 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { function connect(): void { ensureBindings(); - // Handle the case where the img already loaded before listeners were bound - // (e.g., cached image in React where mount happens before useEffect). + // Handle the case where the img already loaded or errored before listeners + // were bound (e.g., cached image in React where mount happens before useEffect). const img = getImg(); - if (img?.complete && img.naturalWidth > 0 && lastSrc) { - naturalWidth = img.naturalWidth; - naturalHeight = img.naturalHeight; - loading = false; - error = false; + if (img?.complete && lastSrc) { + if (img.naturalWidth > 0) { + naturalWidth = img.naturalWidth; + naturalHeight = img.naturalHeight; + loading = false; + error = false; + } else { + loading = false; + error = true; + } + onStateChange(); } } diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index e6c11fc1..3fcf6390 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -11,6 +11,7 @@ import { iconState, overlay, popup, + preview, root, seek, slider, @@ -100,6 +101,14 @@ function getTemplateHTML() { + +
+
+ +
+ + ${renderIcon('spinner', { class: cn(icon, preview.spinner) })} +
diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 724cb4ce..41a06f6f 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -79,6 +79,14 @@ function getTemplateHTML() { + +
+
+ +
+ + ${renderIcon('spinner', { class: 'media-preview__spinner media-icon' })} +
diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index b7c4c67b..13925279 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -11,6 +11,7 @@ import { overlay, playbackRate, popup, + preview, root, seek, slider, @@ -94,6 +95,12 @@ function getTemplateHTML() { + +
+ + + ${renderIcon('spinner', { class: cn(icon, preview.spinner) })} +
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 2eb78e30..3aeef13f 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -75,6 +75,12 @@ function getTemplateHTML() { + +
+ + + ${renderIcon('spinner', { class: 'media-preview__spinner media-icon' })} +
diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 8e4ca266..b06a2307 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -26,6 +26,7 @@ import { iconState, overlay, popup, + preview, root, seek, slider, @@ -44,6 +45,7 @@ import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { Slider } from '@/ui/slider'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -233,6 +235,13 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> } /> +
+
+ +
+ + +
diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index a1225614..ccbe4f00 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -26,6 +26,7 @@ import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { Slider } from '@/ui/slider'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -164,6 +165,14 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { + +
+
+ +
+ + +
diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 822fb885..c1d7404f 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -25,6 +25,7 @@ import { overlay, playbackRate, popup, + preview, root, seek, slider, @@ -43,6 +44,7 @@ import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { Slider } from '@/ui/slider'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -228,6 +230,11 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> } /> +
+ + + +
diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 462044b8..8e6fc9d6 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -26,6 +26,7 @@ import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { Slider } from '@/ui/slider'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -159,6 +160,12 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { + +
+ + + +
diff --git a/packages/react/src/ui/thumbnail/index.ts b/packages/react/src/ui/thumbnail/index.ts new file mode 100644 index 00000000..d4ab7a50 --- /dev/null +++ b/packages/react/src/ui/thumbnail/index.ts @@ -0,0 +1 @@ +export * from './thumbnail'; diff --git a/packages/sandbox/app/shared/html/mux-storyboard.ts b/packages/sandbox/app/shared/html/mux-storyboard.ts new file mode 100644 index 00000000..242dcc4a --- /dev/null +++ b/packages/sandbox/app/shared/html/mux-storyboard.ts @@ -0,0 +1,9 @@ +import { getMuxAssetId } from '../mux'; +import type { SourceId } from '../sources'; + +export function renderMuxStoryboard(source: SourceId): string { + const id = getMuxAssetId(source); + return id + ? `` + : ''; +} diff --git a/packages/sandbox/app/shared/mux.ts b/packages/sandbox/app/shared/mux.ts new file mode 100644 index 00000000..e4800134 --- /dev/null +++ b/packages/sandbox/app/shared/mux.ts @@ -0,0 +1,10 @@ +import { SOURCES, type SourceId } from './sources'; + +export function getMuxAssetId(source: SourceId): string | undefined { + return SOURCES[source].url.match(/stream\.mux\.com\/([a-zA-Z0-9]+)/)?.[1]; +} + +export function getMuxPosterSrc(source: SourceId): string | undefined { + const id = getMuxAssetId(source); + return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined; +} diff --git a/packages/sandbox/app/shared/react/mux-poster.tsx b/packages/sandbox/app/shared/react/mux-poster.tsx new file mode 100644 index 00000000..ce7d2f02 --- /dev/null +++ b/packages/sandbox/app/shared/react/mux-poster.tsx @@ -0,0 +1,14 @@ +import { Poster } from '@videojs/react'; + +import { getMuxAssetId } from '../mux'; +import type { SourceId } from '../sources'; + +type MuxPosterProps = { + source: SourceId; +}; + +export function MuxPoster({ source }: MuxPosterProps) { + const id = getMuxAssetId(source); + if (!id) return null; + return ; +} diff --git a/packages/sandbox/app/shared/react/mux-storyboard.tsx b/packages/sandbox/app/shared/react/mux-storyboard.tsx new file mode 100644 index 00000000..a075c0b6 --- /dev/null +++ b/packages/sandbox/app/shared/react/mux-storyboard.tsx @@ -0,0 +1,12 @@ +import { getMuxAssetId } from '../mux'; +import type { SourceId } from '../sources'; + +type MuxStoryboardProps = { + source: SourceId; +}; + +export function MuxStoryboard({ source }: MuxStoryboardProps) { + const id = getMuxAssetId(source); + if (!id) return null; + return ; +} diff --git a/packages/sandbox/templates/html-hls-video/main.ts b/packages/sandbox/templates/html-hls-video/main.ts index 1e2a7496..44c09b68 100644 --- a/packages/sandbox/templates/html-hls-video/main.ts +++ b/packages/sandbox/templates/html-hls-video/main.ts @@ -3,6 +3,7 @@ import '@videojs/html/video/player'; import '@videojs/html/media/hls-video'; import '@videojs/html/video/skin'; import '@videojs/html/video/minimal-skin'; +import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard'; import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; import { loadVideoStylesheets } from '@app/shared/html/stylesheets'; import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; @@ -23,7 +24,9 @@ function render() { document.getElementById('root')!.innerHTML = html` <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + + ${renderMuxStoryboard(currentSource)} + `; diff --git a/packages/sandbox/templates/html-simple-hls-video/main.ts b/packages/sandbox/templates/html-simple-hls-video/main.ts index bf8f676d..cb003f2a 100644 --- a/packages/sandbox/templates/html-simple-hls-video/main.ts +++ b/packages/sandbox/templates/html-simple-hls-video/main.ts @@ -3,6 +3,7 @@ import '@videojs/html/video/player'; import '@videojs/html/media/simple-hls-video'; import '@videojs/html/video/skin'; import '@videojs/html/video/minimal-skin'; +import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard'; import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; import { loadVideoStylesheets } from '@app/shared/html/stylesheets'; import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; @@ -23,7 +24,9 @@ function render() { document.getElementById('root')!.innerHTML = html` <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + + ${renderMuxStoryboard(currentSource)} + `; diff --git a/packages/sandbox/templates/html-video-tailwind/main.ts b/packages/sandbox/templates/html-video-tailwind/main.ts index 20a47644..edd47596 100644 --- a/packages/sandbox/templates/html-video-tailwind/main.ts +++ b/packages/sandbox/templates/html-video-tailwind/main.ts @@ -1,7 +1,9 @@ import '@app/styles.css'; +import '@videojs/html/ui/poster'; import '@videojs/html/video/player'; import '@videojs/html/video/skin.tailwind'; import '@videojs/html/video/minimal-skin.tailwind'; +import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard'; import { TAILWIND_SKIN_TAGS } from '@app/shared/html/skin-tags'; import { setupVideoTailwind } from '@app/shared/html/tailwind-setup'; import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; @@ -22,7 +24,9 @@ function render() { document.getElementById('root')!.innerHTML = html` <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + `; diff --git a/packages/sandbox/templates/html-video/main.ts b/packages/sandbox/templates/html-video/main.ts index 883b911f..86480193 100644 --- a/packages/sandbox/templates/html-video/main.ts +++ b/packages/sandbox/templates/html-video/main.ts @@ -2,6 +2,8 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/video/skin'; import '@videojs/html/video/minimal-skin'; +import '@videojs/html/ui/poster'; +import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard'; import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; import { loadVideoStylesheets } from '@app/shared/html/stylesheets'; import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; @@ -22,7 +24,9 @@ function render() { document.getElementById('root')!.innerHTML = html` <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + `; diff --git a/packages/sandbox/templates/react-hls-video/main.tsx b/packages/sandbox/templates/react-hls-video/main.tsx index 2fa205e5..572973aa 100644 --- a/packages/sandbox/templates/react-hls-video/main.tsx +++ b/packages/sandbox/templates/react-hls-video/main.tsx @@ -1,6 +1,8 @@ import '@app/styles.css'; import '@videojs/react/video/skin.css'; import '@videojs/react/video/minimal-skin.css'; +import { MuxPoster } from '@app/shared/react/mux-poster'; +import { MuxStoryboard } from '@app/shared/react/mux-storyboard'; import { VideoProvider } from '@app/shared/react/providers'; import { VideoSkinComponent } from '@app/shared/react/skins'; import { useSkin } from '@app/shared/react/use-skin'; @@ -16,7 +18,10 @@ function App() { return ( - + + + + ); diff --git a/packages/sandbox/templates/react-simple-hls-video/main.tsx b/packages/sandbox/templates/react-simple-hls-video/main.tsx index b1bcc103..33cbfe76 100644 --- a/packages/sandbox/templates/react-simple-hls-video/main.tsx +++ b/packages/sandbox/templates/react-simple-hls-video/main.tsx @@ -1,6 +1,8 @@ import '@app/styles.css'; import '@videojs/react/video/skin.css'; import '@videojs/react/video/minimal-skin.css'; +import { MuxPoster } from '@app/shared/react/mux-poster'; +import { MuxStoryboard } from '@app/shared/react/mux-storyboard'; import { VideoProvider } from '@app/shared/react/providers'; import { VideoSkinComponent } from '@app/shared/react/skins'; import { useSkin } from '@app/shared/react/use-skin'; @@ -16,7 +18,10 @@ function App() { return ( - + + + + ); diff --git a/packages/sandbox/templates/react-video-tailwind/main.tsx b/packages/sandbox/templates/react-video-tailwind/main.tsx index 7d88e38c..ded1f56d 100644 --- a/packages/sandbox/templates/react-video-tailwind/main.tsx +++ b/packages/sandbox/templates/react-video-tailwind/main.tsx @@ -1,4 +1,6 @@ import '@app/styles.css'; +import { MuxPoster } from '@app/shared/react/mux-poster'; +import { MuxStoryboard } from '@app/shared/react/mux-storyboard'; import { VideoProvider } from '@app/shared/react/providers'; import { VideoSkinComponent } from '@app/shared/react/skins'; import { useSkin } from '@app/shared/react/use-skin'; @@ -14,7 +16,10 @@ function App() { return ( - ); diff --git a/packages/sandbox/templates/react-video/main.tsx b/packages/sandbox/templates/react-video/main.tsx index 82920010..209396b3 100644 --- a/packages/sandbox/templates/react-video/main.tsx +++ b/packages/sandbox/templates/react-video/main.tsx @@ -1,6 +1,8 @@ import '@app/styles.css'; import '@videojs/react/video/skin.css'; import '@videojs/react/video/minimal-skin.css'; +import { MuxPoster } from '@app/shared/react/mux-poster'; +import { MuxStoryboard } from '@app/shared/react/mux-storyboard'; import { VideoProvider } from '@app/shared/react/providers'; import { VideoSkinComponent } from '@app/shared/react/skins'; import { useSkin } from '@app/shared/react/use-skin'; @@ -16,7 +18,10 @@ function App() { return ( - ); diff --git a/packages/skins/src/default/css/components/buttons.css b/packages/skins/src/default/css/components/buttons.css index 92df1abd..6784e32d 100644 --- a/packages/skins/src/default/css/components/buttons.css +++ b/packages/skins/src/default/css/components/buttons.css @@ -22,6 +22,7 @@ transition-timing-function: ease-out; cursor: pointer; user-select: none; + touch-action: manipulation; &:focus-visible { outline-color: oklch(62.3% 0.214 259.815); diff --git a/packages/skins/src/default/css/components/media.css b/packages/skins/src/default/css/components/media.css index a7d33e67..98d0ec8d 100644 --- a/packages/skins/src/default/css/components/media.css +++ b/packages/skins/src/default/css/components/media.css @@ -24,10 +24,10 @@ inset: 0; width: 100%; height: 100%; - border-radius: inherit; object-fit: cover; transition: opacity 0.25s; pointer-events: none; + border-radius: inherit; &:not([data-visible]) { opacity: 0; diff --git a/packages/skins/src/default/css/components/popup.css b/packages/skins/src/default/css/components/popup.css index ab28d3a3..15f5fce9 100644 --- a/packages/skins/src/default/css/components/popup.css +++ b/packages/skins/src/default/css/components/popup.css @@ -9,12 +9,12 @@ color: inherit; overflow: visible; transition-property: transform, scale, opacity, filter; - transition-duration: 200ms; + transition-duration: 150ms; &[data-starting-style], &[data-ending-style] { opacity: 0; - transform: scale(0); + transform: scale(0.5); filter: blur(8px); } @@ -49,5 +49,5 @@ border-radius: calc(infinity * 1px); font-size: 0.75rem; white-space: nowrap; - --media-tooltip-side-offset: 0.5rem; + --media-tooltip-side-offset: 0.75rem; } diff --git a/packages/skins/src/default/css/components/preview.css b/packages/skins/src/default/css/components/preview.css new file mode 100644 index 00000000..2e1cfbdd --- /dev/null +++ b/packages/skins/src/default/css/components/preview.css @@ -0,0 +1,56 @@ +/* ========================================================================== + Media preview + ========================================================================== */ +.media-default-skin .media-preview { + background-color: oklch(0 0 0 / 0.9); + border-radius: 0.75rem; + + & .media-preview__thumbnail { + display: block; + position: relative; + border-radius: inherit; + overflow: clip; + + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-image: linear-gradient(to top, oklch(0 0 0 / 0.8), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0)); + } + } + + & .media-preview__timestamp { + position: absolute; + bottom: 0.5rem; + inset-inline: 0; + text-align: center; + font-variant-numeric: tabular-nums; + } + + & .media-overlay { + opacity: 1; + } + + & .media-preview__spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + } + + & .media-preview__thumbnail, + & .media-preview__spinner { + transition: opacity 150ms ease-out; + } + + &:has(.media-preview__thumbnail[data-loading]) { + & .media-preview__thumbnail { + opacity: 0; + } + & .media-preview__spinner { + opacity: 1; + } + } +} diff --git a/packages/skins/src/default/css/components/reset.css b/packages/skins/src/default/css/components/reset.css index c7315d7d..99e1bebe 100644 --- a/packages/skins/src/default/css/components/reset.css +++ b/packages/skins/src/default/css/components/reset.css @@ -6,7 +6,6 @@ .media-default-skin *::before, .media-default-skin *::after { box-sizing: border-box; - margin: 0; } .media-default-skin img, .media-default-skin video, diff --git a/packages/skins/src/default/css/components/slider.css b/packages/skins/src/default/css/components/slider.css index 99a5be73..f767d126 100644 --- a/packages/skins/src/default/css/components/slider.css +++ b/packages/skins/src/default/css/components/slider.css @@ -10,6 +10,7 @@ flex: 1; border-radius: calc(infinity * 1px); outline: none; + cursor: pointer; &[data-orientation="horizontal"] { min-width: 5rem; @@ -141,8 +142,3 @@ height: var(--media-slider-fill); } } - -/* Time display within slider */ -.media-default-skin .media-slider__time-display { - font-variant-numeric: tabular-nums; -} diff --git a/packages/skins/src/default/css/components/surface.css b/packages/skins/src/default/css/components/surface.css index 2773c329..1c8c1b0b 100644 --- a/packages/skins/src/default/css/components/surface.css +++ b/packages/skins/src/default/css/components/surface.css @@ -6,7 +6,7 @@ background-color: var(--media-surface-background-color); backdrop-filter: var(--media-surface-backdrop-filter); box-shadow: - inset 0 0 0 1px var(--media-surface-inner-border-color), + 0 0 0 1px var(--media-surface-outer-border-color), 0 1px 3px 0 var(--media-surface-shadow-color), 0 1px 2px -1px var(--media-surface-shadow-color); @@ -17,7 +17,7 @@ inset: 0; z-index: 10; border-radius: inherit; - box-shadow: 0 0 0 1px var(--media-surface-outer-border-color); + box-shadow: inset 0 0 0 1px var(--media-surface-inner-border-color); pointer-events: none; } diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css index 10f8f442..507e25e6 100644 --- a/packages/skins/src/default/css/video.css +++ b/packages/skins/src/default/css/video.css @@ -11,6 +11,7 @@ @import "./components/time.css"; @import "./components/buttons.css"; @import "./components/icons.css"; +@import "./components/preview.css"; @import "./components/slider.css"; @import "./components/popup.css"; @import "./components/captions.css"; @@ -99,3 +100,29 @@ background-color: oklch(1 0 0 / 0.2); box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05); } + +.media-default-skin .media-slider__preview { + position: absolute; + left: var(--media-slider-pointer); + bottom: calc(100% + 1.2rem); + translate: -50%; + opacity: 0; + scale: 0.8; + filter: blur(8px); + transition-property: scale, opacity, filter; + transition-duration: 150ms; + transform-origin: bottom; + + & .media-preview__thumbnail { + max-width: 11rem; + } + + &:has(.media-preview__thumbnail[data-loading]) { + max-height: 6rem; + } +} +.media-default-skin .media-slider[data-pointing] .media-slider__preview:has([role="img"]:not([data-hidden])) { + opacity: 1; + scale: 1; + filter: blur(0); +} diff --git a/packages/skins/src/default/tailwind/components/button.ts b/packages/skins/src/default/tailwind/components/button.ts index 84d86e6f..66c92273 100644 --- a/packages/skins/src/default/tailwind/components/button.ts +++ b/packages/skins/src/default/tailwind/components/button.ts @@ -2,7 +2,7 @@ import { cn } from '@videojs/utils/style'; export const button = { base: cn( - 'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center', + 'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center touch-manipulation', 'font-medium', 'outline-2 outline-transparent -outline-offset-2', 'transition-[background-color,color,outline-offset,scale] duration-150 ease-out', diff --git a/packages/skins/src/default/tailwind/components/popup.ts b/packages/skins/src/default/tailwind/components/popup.ts index 331d43b8..163d147d 100644 --- a/packages/skins/src/default/tailwind/components/popup.ts +++ b/packages/skins/src/default/tailwind/components/popup.ts @@ -4,9 +4,9 @@ const base = cn( // Reset default popover styles 'm-0 border-0 text-inherit overflow-visible', // Animation - 'transition-[transform,scale,opacity,filter] duration-200', - 'data-starting-style:opacity-0 data-starting-style:scale-0 data-starting-style:blur-sm', - 'data-ending-style:opacity-0 data-ending-style:scale-0 data-ending-style:blur-sm', + 'transition-[transform,scale,opacity,filter] duration-150', + 'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm', + 'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm', 'data-instant:duration-0', // Ensure we animate from the correct origin based on the side the popover is on 'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left' @@ -17,7 +17,7 @@ export const popup = { tooltip: cn( base, 'py-1 px-2.5 rounded-full text-[0.75rem] whitespace-nowrap', - '[--media-tooltip-side-offset:0.5rem]' + '[--media-tooltip-side-offset:0.75rem]' ), volume: 'py-2.5 px-1 rounded-full', }; diff --git a/packages/skins/src/default/tailwind/components/preview.ts b/packages/skins/src/default/tailwind/components/preview.ts new file mode 100644 index 00000000..58ed996e --- /dev/null +++ b/packages/skins/src/default/tailwind/components/preview.ts @@ -0,0 +1,18 @@ +import { cn } from '@videojs/utils/style'; + +export const preview = { + root: cn('group/preview pointer-events-none bg-black/90 rounded-xl'), + thumbnail: cn( + 'block relative overflow-clip rounded-[inherit]', + 'transition-opacity duration-150 ease-out', + 'after:absolute after:inset-0 after:rounded-[inherit]', + 'after:bg-linear-to-t after:from-black/80 after:via-black/30 after:to-black/0', + 'data-loading:opacity-0' + ), + timestamp: 'absolute bottom-2 inset-x-0 text-center tabular-nums', + spinner: cn( + 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0', + 'transition-opacity duration-150 ease-out', + 'group-has-[[role=img][data-loading]]/preview:opacity-100' + ), +}; diff --git a/packages/skins/src/default/tailwind/components/root.ts b/packages/skins/src/default/tailwind/components/root.ts index 1d8bc58c..ffcaa973 100644 --- a/packages/skins/src/default/tailwind/components/root.ts +++ b/packages/skins/src/default/tailwind/components/root.ts @@ -7,7 +7,7 @@ export const root = cn( 'rounded-(--media-border-radius,2rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Resets - '**:box-border **:m-0', + '**:box-border', '[&_button]:font-[inherit]', 'motion-safe:[interpolate-size:allow-keywords]' ); diff --git a/packages/skins/src/default/tailwind/components/slider.ts b/packages/skins/src/default/tailwind/components/slider.ts index d17623f2..9fc72188 100644 --- a/packages/skins/src/default/tailwind/components/slider.ts +++ b/packages/skins/src/default/tailwind/components/slider.ts @@ -2,7 +2,7 @@ import { cn } from '@videojs/utils/style'; export const slider = { root: cn( - 'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none', + 'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none cursor-pointer', // Horizontal 'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5', // Vertical diff --git a/packages/skins/src/default/tailwind/components/surface.ts b/packages/skins/src/default/tailwind/components/surface.ts index 3289d1fd..9639ef35 100644 --- a/packages/skins/src/default/tailwind/components/surface.ts +++ b/packages/skins/src/default/tailwind/components/surface.ts @@ -2,7 +2,7 @@ import { cn } from '@videojs/utils/style'; export const surface = cn( // Border and shadow - 'ring ring-inset shadow-sm', + 'ring shadow-sm', // Border to enhance contrast on lighter videos - 'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:pointer-events-none after:z-10' + 'after:absolute after:inset-0 after:ring after:ring-inset after:rounded-[inherit] after:pointer-events-none after:z-10' ); diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index 7d3ab13c..6cbfad1b 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -3,6 +3,7 @@ import { bufferingIndicator as baseBufferingIndicator } from './components/buffe import { controls as baseControls } from './components/controls'; import { error as baseError } from './components/error'; import { popup as basePopup } from './components/popup'; +import { preview as basePreview } from './components/preview'; import { root as baseRoot } from './components/root'; import { slider as baseSlider } from './components/slider'; import { surface as baseSurface } from './components/surface'; @@ -66,9 +67,9 @@ export const surface = cn( 'bg-white/10', 'backdrop-saturate-150 backdrop-blur-lg', // Border and shadow - 'ring-white/5 shadow-black/10', + 'ring-black/15 shadow-black/10', // Border to enhance contrast on lighter videos - 'after:ring-black/15', + 'after:ring-white/5', // Reduced transparency for users with preference '[@media(prefers-reduced-transparency:reduce)]:bg-black/70', // High contrast mode @@ -101,6 +102,25 @@ export const controls = cn( 'motion-reduce:not-data-visible:scale-100' ); +/* ========================================================================== + Preview (with video surface) + ========================================================================== */ + +export const preview = { + ...basePreview, + root: cn( + 'absolute left-(--media-slider-pointer) bottom-[calc(100%+1.2rem)] -translate-x-1/2', + 'opacity-0 scale-80 blur-sm origin-bottom', + 'transition-[scale,opacity,filter] duration-150', + 'group-data-pointing/slider:opacity-100 group-data-pointing/slider:scale-100 group-data-pointing/slider:blur-none', + '[&:has([role=img][data-hidden])]:opacity-0 [&:has([role=img][data-hidden])]:scale-80 [&:has([role=img][data-hidden])]:blur-sm', + '[&:has([role=img][data-loading])]:max-h-24', + surface, + basePreview.root + ), + thumbnail: cn(basePreview.thumbnail, 'max-w-44'), +}; + /* ========================================================================== Sliders ========================================================================== */ diff --git a/packages/skins/src/minimal/css/components/buttons.css b/packages/skins/src/minimal/css/components/buttons.css index 9ef1da14..378a1078 100644 --- a/packages/skins/src/minimal/css/components/buttons.css +++ b/packages/skins/src/minimal/css/components/buttons.css @@ -37,6 +37,7 @@ transition-timing-function: ease-out; cursor: pointer; user-select: none; + touch-action: manipulation; &:focus-visible { outline-color: currentColor; diff --git a/packages/skins/src/minimal/css/components/popup.css b/packages/skins/src/minimal/css/components/popup.css index cdafc961..09a70ef7 100644 --- a/packages/skins/src/minimal/css/components/popup.css +++ b/packages/skins/src/minimal/css/components/popup.css @@ -9,12 +9,12 @@ color: inherit; overflow: visible; transition-property: transform, scale, opacity, filter; - transition-duration: 200ms; + transition-duration: 150ms; &[data-starting-style], &[data-ending-style] { opacity: 0; - transform: scale(0); + transform: scale(0.5); filter: blur(8px); } @@ -46,7 +46,7 @@ 0 2px 4px -2px oklch(0 0 0 / 0.1); font-size: 0.75rem; white-space: nowrap; - --media-tooltip-side-offset: 0.5rem; + --media-tooltip-side-offset: 0.75rem; @media (prefers-reduced-transparency: reduce) { background-color: oklch(0 0 0 / 0.7); diff --git a/packages/skins/src/minimal/css/components/preview.css b/packages/skins/src/minimal/css/components/preview.css new file mode 100644 index 00000000..ac1618f8 --- /dev/null +++ b/packages/skins/src/minimal/css/components/preview.css @@ -0,0 +1,47 @@ +/* ========================================================================== + Media preview + ========================================================================== */ +.media-minimal-skin .media-preview { + & .media-preview__thumbnail-wrapper { + position: relative; + border-radius: 0.5rem; + background-color: oklch(0 0 0 / 0.9); + } + & .media-preview__thumbnail { + display: block; + border-radius: inherit; + } + + & .media-preview__timestamp { + display: block; + font-variant-numeric: tabular-nums; + text-align: center; + margin-top: 0.5rem; + } + + & .media-overlay { + opacity: 1; + } + + & .media-preview__spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + } + + & .media-preview__thumbnail, + & .media-preview__spinner { + transition: opacity 150ms ease-out; + } + + &:has(.media-preview__thumbnail[data-loading]) { + & .media-preview__thumbnail { + opacity: 0; + } + & .media-preview__spinner { + opacity: 1; + } + } +} diff --git a/packages/skins/src/minimal/css/components/reset.css b/packages/skins/src/minimal/css/components/reset.css index 3a59a221..95ae6b76 100644 --- a/packages/skins/src/minimal/css/components/reset.css +++ b/packages/skins/src/minimal/css/components/reset.css @@ -6,7 +6,6 @@ .media-minimal-skin *::before, .media-minimal-skin *::after { box-sizing: border-box; - margin: 0; } .media-minimal-skin img, .media-minimal-skin video, diff --git a/packages/skins/src/minimal/css/components/slider.css b/packages/skins/src/minimal/css/components/slider.css index 21f62bf9..294c76b4 100644 --- a/packages/skins/src/minimal/css/components/slider.css +++ b/packages/skins/src/minimal/css/components/slider.css @@ -10,6 +10,7 @@ flex: 1; border-radius: calc(infinity * 1px); outline: none; + cursor: pointer; &[data-orientation="horizontal"] { min-width: 5rem; @@ -138,8 +139,3 @@ height: var(--media-slider-fill); } } - -/* Time display within slider */ -.media-minimal-skin .media-slider__time-display { - font-variant-numeric: tabular-nums; -} diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index 9e69fade..4016c537 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -10,6 +10,7 @@ @import "./components/time.css"; @import "./components/buttons.css"; @import "./components/icons.css"; +@import "./components/preview.css"; @import "./components/slider.css"; @import "./components/popup.css"; @import "./components/captions.css"; @@ -106,3 +107,48 @@ background: transparent; padding: 0.25rem; } + +/* ========================================================================== + Slider preview + ========================================================================== */ + +.media-minimal-skin .media-slider__preview { + position: absolute; + left: var(--media-slider-pointer); + bottom: calc(100% + 0.5rem); + translate: -50%; + opacity: 0; + scale: 0.8; + filter: blur(8px); + transition-property: scale, opacity, filter; + transition-duration: 150ms; + transform-origin: bottom; + + & .media-preview__thumbnail-wrapper { + position: relative; + + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: + 0 0 0 1px oklch(0 0 0 / 0.05), + 0 1px 3px 0 oklch(0 0 0 / 0.2), + 0 1px 2px -1px oklch(0 0 0 / 0.2); + } + } + + & .media-preview__thumbnail { + max-width: 11rem; + } + + &:has(.media-preview__thumbnail[data-loading]) { + max-height: 6rem; + } +} +.media-minimal-skin .media-slider[data-pointing] .media-slider__preview:has([role="img"]:not([data-hidden])) { + opacity: 1; + scale: 1; + filter: blur(0); +} diff --git a/packages/skins/src/minimal/tailwind/components/button.ts b/packages/skins/src/minimal/tailwind/components/button.ts index 8d4bb58c..e3146c57 100644 --- a/packages/skins/src/minimal/tailwind/components/button.ts +++ b/packages/skins/src/minimal/tailwind/components/button.ts @@ -2,7 +2,7 @@ import { cn } from '@videojs/utils/style'; export const button = { base: cn( - 'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center', + 'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center touch-manipulation', 'outline-2 outline-transparent -outline-offset-2', 'font-medium text-shadow-inherit', 'transition-[background-color,color,outline-offset,scale] duration-150 ease-out', diff --git a/packages/skins/src/minimal/tailwind/components/popup.ts b/packages/skins/src/minimal/tailwind/components/popup.ts index 42865327..b94a2b6e 100644 --- a/packages/skins/src/minimal/tailwind/components/popup.ts +++ b/packages/skins/src/minimal/tailwind/components/popup.ts @@ -4,19 +4,19 @@ const base = cn( // Reset default popover styles 'm-0 border-0 text-inherit overflow-visible', // Animation - 'transition-[transform,scale,opacity,filter] duration-200', - 'data-starting-style:opacity-0 data-starting-style:scale-0 data-starting-style:blur-sm', - 'data-ending-style:opacity-0 data-ending-style:scale-0 data-ending-style:blur-sm', + 'transition-[transform,scale,opacity,filter] duration-150', + 'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm', + 'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm', 'data-instant:duration-0', // Ensure we animate from the correct origin based on the side the popover is on 'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left' ); export const popup = { + base, tooltip: cn( base, 'px-2 py-1 rounded-sm shadow-md shadow-black/10 bg-white/10 backdrop-blur-lg backdrop-saturate-150 text-[0.75rem] whitespace-nowrap', - '[--media-tooltip-side-offset:0.5rem]' + '[--media-tooltip-side-offset:0.75rem]' ), - volume: cn(base, 'py-2.5 px-1 rounded-full'), }; diff --git a/packages/skins/src/minimal/tailwind/components/preview.ts b/packages/skins/src/minimal/tailwind/components/preview.ts new file mode 100644 index 00000000..60937d01 --- /dev/null +++ b/packages/skins/src/minimal/tailwind/components/preview.ts @@ -0,0 +1,13 @@ +import { cn } from '@videojs/utils/style'; + +export const preview = { + root: 'group/preview pointer-events-none', + thumbnailWrapper: 'relative rounded-lg bg-black/90', + thumbnail: cn('block rounded-[inherit] transition-opacity duration-150 ease-out', 'data-loading:opacity-0'), + timestamp: 'mt-2 block text-center tabular-nums', + spinner: cn( + 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0', + 'transition-opacity duration-150 ease-out', + 'group-has-[[role=img][data-loading]]/preview:opacity-100' + ), +}; diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index c37c379a..5a31ef0a 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -7,7 +7,7 @@ export const root = cn( 'rounded-(--media-border-radius,0.75rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Resets - '**:box-border **:m-0', + '**:box-border', '[&_button]:font-[inherit]', 'motion-safe:[interpolate-size:allow-keywords]' ); diff --git a/packages/skins/src/minimal/tailwind/components/slider.ts b/packages/skins/src/minimal/tailwind/components/slider.ts index d7d7f698..c0cb7c0b 100644 --- a/packages/skins/src/minimal/tailwind/components/slider.ts +++ b/packages/skins/src/minimal/tailwind/components/slider.ts @@ -2,7 +2,7 @@ import { cn } from '@videojs/utils/style'; export const slider = { root: cn( - 'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none', + 'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none cursor-pointer', // Horizontal 'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5', // Vertical diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index 75878365..416b5b49 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -1,6 +1,7 @@ import { cn } from '@videojs/utils/style'; import { controls as baseControls } from './components/controls'; import { popup as basePopup } from './components/popup'; +import { preview as basePreview } from './components/preview'; import { root as baseRoot } from './components/root'; import { slider as baseSlider } from './components/slider'; @@ -85,13 +86,36 @@ export const controls = cn( '@sm/media-root:gap-3.5' ); +/* ========================================================================== + Preview + ========================================================================== */ + +export const preview = { + ...basePreview, + root: cn( + 'absolute left-(--media-slider-pointer) bottom-[calc(100%+0.5rem)] -translate-x-1/2', + 'opacity-0 scale-80 blur-sm origin-bottom', + 'transition-[scale,opacity,filter] duration-150', + 'group-data-pointing/slider:opacity-100 group-data-pointing/slider:scale-100 group-data-pointing/slider:blur-none', + '[&:has([role=img][data-hidden])]:opacity-0 [&:has([role=img][data-hidden])]:scale-80 [&:has([role=img][data-hidden])]:blur-sm', + '[&:has([role=img][data-loading])]:max-h-24', + basePreview.root + ), + thumbnailWrapper: cn( + basePreview.thumbnailWrapper, + 'after:absolute after:inset-0 after:rounded-[inherit]', + 'after:ring-1 after:ring-black/5 after:shadow-sm after:shadow-black/20' + ), + thumbnail: cn(basePreview.thumbnail, 'max-w-44'), +}; + /* ========================================================================== Sliders ========================================================================== */ export const slider = { ...baseSlider, - track: cn(baseSlider.track, 'shadow-[0_0_0_1px_oklch(0_0_0/0.05)]'), + track: cn(baseSlider.track, 'ring-1 ring-black/5'), }; /* ========================================================================== @@ -100,7 +124,7 @@ export const slider = { export const popup = { ...basePopup, - volume: cn('[--media-popover-side-offset:0.5rem] p-1 bg-transparent'), + volume: cn(basePopup.base, '[--media-popover-side-offset:0.5rem] p-1 bg-transparent'), }; /* ==========================================================================