diff --git a/apps/sandbox/app/constants.ts b/apps/sandbox/app/constants.ts index c5ff095d4..4d65496ae 100644 --- a/apps/sandbox/app/constants.ts +++ b/apps/sandbox/app/constants.ts @@ -9,6 +9,7 @@ export const PRESETS = [ 'mux-audio', 'simple-hls-video', 'dash-video', + 'vimeo-video', 'audio', 'background-video', ] as const; diff --git a/apps/sandbox/app/shared/html/sandbox-state.ts b/apps/sandbox/app/shared/html/sandbox-state.ts index 6300dbd9d..fdeeb4ab4 100644 --- a/apps/sandbox/app/shared/html/sandbox-state.ts +++ b/apps/sandbox/app/shared/html/sandbox-state.ts @@ -24,10 +24,10 @@ export type HtmlSandboxState = { preload: PreloadValue; }; -export function createHtmlSandboxState(audioOnly?: boolean): HtmlSandboxState { +export function createHtmlSandboxState(audioOnly?: boolean, vimeoOnly?: boolean): HtmlSandboxState { return { skin: getInitialSkin(), - source: getInitialSource(audioOnly), + source: getInitialSource(audioOnly, vimeoOnly), styling: getInitialStyling(), autoplay: getInitialAutoplay(), muted: getInitialMuted(), diff --git a/apps/sandbox/app/shared/react/use-source.ts b/apps/sandbox/app/shared/react/use-source.ts index 99f49064b..da73cfbda 100644 --- a/apps/sandbox/app/shared/react/use-source.ts +++ b/apps/sandbox/app/shared/react/use-source.ts @@ -2,8 +2,8 @@ import { getInitialSource, onSourceChange } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import { useEffect, useState } from 'react'; -export function useSource(audioOnly?: boolean): SourceId { - const [source, setSource] = useState(() => getInitialSource(audioOnly)); +export function useSource(audioOnly?: boolean, vimeoOnly?: boolean): SourceId { + const [source, setSource] = useState(() => getInitialSource(audioOnly, vimeoOnly)); useEffect(() => onSourceChange(setSource), []); return source; } diff --git a/apps/sandbox/app/shared/sandbox-listener.ts b/apps/sandbox/app/shared/sandbox-listener.ts index 90a242bcf..3605e058e 100644 --- a/apps/sandbox/app/shared/sandbox-listener.ts +++ b/apps/sandbox/app/shared/sandbox-listener.ts @@ -1,6 +1,6 @@ import { SKINS } from '@app/constants'; import type { Skin } from '@app/types'; -import { DEFAULT_AUDIO_SOURCE, SOURCES, type SourceId } from './sources'; +import { DEFAULT_AUDIO_SOURCE, DEFAULT_VIMEO_SOURCE, SOURCES, type SourceId } from './sources'; export const PRELOAD_VALUES = ['none', 'metadata', 'auto'] as const; export type PreloadValue = (typeof PRELOAD_VALUES)[number]; @@ -55,13 +55,17 @@ export function onSkinChange(callback: (skin: Skin) => void): () => void { }; } -export function getInitialSource(audioOnly?: boolean): SourceId { +export function getInitialSource(audioOnly?: boolean, vimeoOnly?: boolean): SourceId { const stored = currentSource; if (audioOnly && SOURCES[stored].type !== 'mp4') { return DEFAULT_AUDIO_SOURCE; } + if (vimeoOnly && SOURCES[stored].type !== 'vimeo') { + return DEFAULT_VIMEO_SOURCE; + } + return stored; } diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index 12dc1294e..f9bd39fc6 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -53,14 +53,28 @@ export const SOURCES = { url: 'https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd', type: 'dash', }, + 'vimeo-1': { + label: 'The World In HDR 4K', + url: 'https://vimeo.com/648359100', + type: 'vimeo', + }, + 'vimeo-2': { + label: 'Caminandes 1: Llama Drama', + url: 'https://vimeo.com/638371504', + type: 'vimeo', + }, } as const; export type SourceId = keyof typeof SOURCES; export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[]; -export const NON_DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type !== 'dash'); +export const NON_DASH_SOURCE_IDS = SOURCE_IDS.filter( + (id) => SOURCES[id].type !== 'dash' && SOURCES[id].type !== 'vimeo' +); export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4'); export const DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'dash'); +export const VIMEO_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'vimeo'); +export const DEFAULT_VIMEO_SOURCE: SourceId = 'vimeo-1'; export const DEFAULT_SOURCE: SourceId = 'hls-1'; export const DEFAULT_AUDIO_SOURCE: SourceId = 'mp4-1'; export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1'; diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index 5335902ed..59ab472fb 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -6,9 +6,11 @@ import { DEFAULT_AUDIO_SOURCE, DEFAULT_DASH_SOURCE, DEFAULT_SOURCE, + DEFAULT_VIMEO_SOURCE, MP4_SOURCE_IDS, NON_DASH_SOURCE_IDS, SOURCES, + VIMEO_SOURCE_IDS, } from '@app/shared/sources'; import type { Platform, Preset, Styling } from '@app/types'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -106,11 +108,21 @@ export function App() { } }, [preset, source, setSource]); - // Constrain source away from DASH for non-DASH presets + // Constrain source to Vimeo when switching to vimeo-video + useEffect(() => { + if (preset === 'vimeo-video' && SOURCES[source].type !== 'vimeo') { + setSource(DEFAULT_VIMEO_SOURCE); + } + }, [preset, source, setSource]); + + // Constrain source away from DASH and Vimeo for non-matching presets useEffect(() => { if (preset !== 'dash-video' && SOURCES[source].type === 'dash') { setSource(DEFAULT_SOURCE); } + if (preset !== 'vimeo-video' && SOURCES[source].type === 'vimeo') { + setSource(DEFAULT_SOURCE); + } }, [preset, source, setSource]); // CDN and background video do not have a Tailwind skin variant. @@ -121,7 +133,13 @@ export function App() { }, [platform, preset, styling]); const availableSources = - preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : NON_DASH_SOURCE_IDS; + preset === 'audio' + ? MP4_SOURCE_IDS + : preset === 'dash-video' + ? DASH_SOURCE_IDS + : preset === 'vimeo-video' + ? VIMEO_SOURCE_IDS + : NON_DASH_SOURCE_IDS; const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]); @@ -151,6 +169,7 @@ export function App() { isSimpleHlsVideo={preset === 'simple-hls-video'} isMuxVideo={preset === 'mux-video'} isMuxAudio={preset === 'mux-audio'} + isVimeoVideo={preset === 'vimeo-video'} platforms={PLATFORMS} stylings={STYLINGS} presets={PRESETS} diff --git a/apps/sandbox/app/shell/navbar.tsx b/apps/sandbox/app/shell/navbar.tsx index d540aa005..a1bd1745c 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -28,6 +28,7 @@ type NavbarProps = { isSimpleHlsVideo: boolean; isMuxVideo: boolean; isMuxAudio: boolean; + isVimeoVideo: boolean; platforms: readonly Platform[]; stylings: readonly Styling[]; presets: readonly Preset[]; @@ -50,6 +51,7 @@ const PRESET_LABELS: Record = { 'mux-audio': 'Mux Audio', 'simple-hls-video': 'Simple HLS Video', 'dash-video': 'DASH Video', + 'vimeo-video': 'Vimeo Video', audio: 'Audio', 'background-video': 'Background Video', }; @@ -78,6 +80,7 @@ export function Navbar({ isSimpleHlsVideo, isMuxVideo, isMuxAudio, + isVimeoVideo, platforms, stylings, presets, diff --git a/apps/sandbox/templates/html-vimeo-video/index.html b/apps/sandbox/templates/html-vimeo-video/index.html new file mode 100644 index 000000000..ef278e8aa --- /dev/null +++ b/apps/sandbox/templates/html-vimeo-video/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — HTML Vimeo Video + + + + +
+ + + diff --git a/apps/sandbox/templates/html-vimeo-video/main.ts b/apps/sandbox/templates/html-vimeo-video/main.ts new file mode 100644 index 000000000..b58e00be7 --- /dev/null +++ b/apps/sandbox/templates/html-vimeo-video/main.ts @@ -0,0 +1,67 @@ +import '@app/styles.css'; +import '@videojs/html/video/player'; +import '@videojs/html/media/vimeo-video'; +import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { loadVideoSkinTag } from '@app/shared/html/skins'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; +import { SOURCES } from '@app/shared/sources'; + +const html = String.raw; + +const state = createHtmlSandboxState(false, true); +const loadLatest = createLatestLoader(); + +async function render() { + const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling)); + if (!tag) return; + + const autoplay = state.autoplay ? 'autoplay' : ''; + const muted = state.muted ? 'muted' : ''; + const loop = state.loop ? 'loop' : ''; + + document.getElementById('root')!.innerHTML = html` + + <${tag} class="aspect-video max-w-4xl mx-auto"> + + + + `; +} + +render(); + +onSkinChange((skin) => { + state.skin = skin; + render(); +}); + +onSourceChange((source) => { + state.source = source; + render(); +}); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); diff --git a/apps/sandbox/templates/react-vimeo-video/index.html b/apps/sandbox/templates/react-vimeo-video/index.html new file mode 100644 index 000000000..e1c632a26 --- /dev/null +++ b/apps/sandbox/templates/react-vimeo-video/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — React Vimeo Video + + + + +
+ + + diff --git a/apps/sandbox/templates/react-vimeo-video/main.tsx b/apps/sandbox/templates/react-vimeo-video/main.tsx new file mode 100644 index 000000000..2e4dc239c --- /dev/null +++ b/apps/sandbox/templates/react-vimeo-video/main.tsx @@ -0,0 +1,36 @@ +import '@app/styles.css'; +import { VideoProvider } from '@app/shared/react/providers'; +import { VideoSkinComponent } from '@app/shared/react/skins'; +import { useAutoplay } from '@app/shared/react/use-autoplay'; +import { useLoop } from '@app/shared/react/use-loop'; +import { useMuted } from '@app/shared/react/use-muted'; +import { useSkin } from '@app/shared/react/use-skin'; +import { useSource } from '@app/shared/react/use-source'; +import { SOURCES } from '@app/shared/sources'; +import type { Styling } from '@app/types'; +import { VimeoVideo } from '@videojs/react/media/vimeo-video'; +import { useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; + +function readStyling(): Styling { + return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css'; +} + +function App() { + const skin = useSkin(); + const source = useSource(false, true); + const styling = useMemo(readStyling, []); + const autoplay = useAutoplay(); + const muted = useMuted(); + const loop = useLoop(); + + return ( + + + + + + ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/core/package.json b/packages/core/package.json index 622922870..58baae429 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,6 +51,7 @@ "@videojs/spf": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*", + "@vimeo/player": "^2.26.0", "dashjs": "^5.0.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index 057c9d3f5..d00fa9eb9 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -179,6 +179,11 @@ export interface MediaFullscreenCapability { exitFullscreen(): Promise; } +export interface MediaPictureInPictureEvents { + enterpictureinpicture: EventLike; + leavepictureinpicture: EventLike; +} + export interface MediaPictureInPictureCapability { readonly isPictureInPicture: boolean; requestPictureInPicture(): Promise; @@ -255,6 +260,7 @@ export interface VideoEvents MediaPlaybackRateEvents, MediaBufferEvents, MediaErrorEvents, + MediaPictureInPictureEvents, TextTrackListEvents {} export interface Video diff --git a/packages/core/src/dom/media/vimeo/index.ts b/packages/core/src/dom/media/vimeo/index.ts new file mode 100644 index 000000000..c6d25e17b --- /dev/null +++ b/packages/core/src/dom/media/vimeo/index.ts @@ -0,0 +1,779 @@ +import type { VimeoEmbedParameters, VimeoUrl } from '@vimeo/player'; +import Player from '@vimeo/player'; +import type { + MediaEngineHost, + MediaPictureInPictureCapability, + MediaTextTrackCapability, + TimeRangeLike, + VideoEvents, +} from '../../../core/media/types'; +import { TypedEventTarget } from '../../../core/media/types'; + +export interface VimeoMediaProps { + /** Vimeo video URL or numeric video ID as a string. */ + src: string; + /** Do-not-track: disables cookies and analytics (default: `true` for GDPR compliance). */ + dnt: boolean; + autoplay: boolean; + autopause: boolean; + background: boolean; + byline: boolean; + /** Hex color string without `#`, e.g. `'ff0000'`. */ + color: string; + controls: boolean; + loop: boolean; + muted: boolean; + playsinline: boolean; + portrait: boolean; + /** Video quality preference, e.g. `'360p'`, `'720p'`, `'1080p'`, `'auto'`. */ + quality: string; + responsive: boolean; + /** Enables speed controls (requires Vimeo Plus/Pro account). */ + speed: boolean; + /** BCP 47 language code to default the caption/subtitle track. */ + texttrack: string; + title: boolean; + transparent: boolean; +} + +export const vimeoMediaDefaultProps: VimeoMediaProps = { + src: '', + dnt: true, + autoplay: false, + autopause: true, + background: false, + byline: true, + color: '', + controls: false, + loop: false, + muted: false, + playsinline: true, + portrait: true, + quality: 'auto', + responsive: false, + speed: true, + texttrack: '', + title: true, + transparent: true, +}; + +const EMPTY_TIME_RANGES: Readonly = Object.freeze({ + length: 0, + start() { + return 0; + }, + end() { + return 0; + }, +}); + +export class VimeoMedia + extends TypedEventTarget() + implements + MediaEngineHost, + VimeoMediaProps, + MediaPictureInPictureCapability, + MediaTextTrackCapability +{ + #player: Player | null = null; + #container: HTMLElement | null = null; + #overlay: HTMLDivElement | null = null; + #activationCleanup: (() => void) | null = null; + #destroyed = false; + + // PiP only works on Safari via webkit's cross-origin iframe mechanism. + readonly isPipCapable = + typeof navigator !== 'undefined' && + /Version\/.*Safari\//.test(navigator.userAgent) && + !/Chrome|Chromium/.test(navigator.userAgent); + + // Cached playback state — kept in sync via Vimeo SDK events so sync + // getters always return a meaningful value without extra async round-trips. + #paused = true; + #ended = false; + #duration = NaN; + #currentTime = 0; + #buffered = 0; + #readyState = 0; + #volume = 1; + // `#muted` serves both as the initial embed prop and the cached live value. + // The setter updates it (for pre-mount config) and the volumechange handler + // keeps it current after the player is running. + #muted = vimeoMediaDefaultProps.muted; + #playbackRate = 1; + #seeking = false; + #pip = false; + #error: { code: number; message: string } | null = null; + #textTracksVideo: HTMLVideoElement | null = null; + #mountedTracks: TextTrack[] = []; + #textTracksAbort: AbortController | null = null; + + // Embed props + #src = vimeoMediaDefaultProps.src; + #dnt = vimeoMediaDefaultProps.dnt; + #autoplay = vimeoMediaDefaultProps.autoplay; + #autopause = vimeoMediaDefaultProps.autopause; + #background = vimeoMediaDefaultProps.background; + #byline = vimeoMediaDefaultProps.byline; + #color = vimeoMediaDefaultProps.color; + #controls = vimeoMediaDefaultProps.controls; + #loop = vimeoMediaDefaultProps.loop; + #playsinline = vimeoMediaDefaultProps.playsinline; + #portrait = vimeoMediaDefaultProps.portrait; + #quality = vimeoMediaDefaultProps.quality; + #responsive = vimeoMediaDefaultProps.responsive; + #speed = vimeoMediaDefaultProps.speed; + #texttrack = vimeoMediaDefaultProps.texttrack; + #title = vimeoMediaDefaultProps.title; + #transparent = vimeoMediaDefaultProps.transparent; + + get engine() { + return this.#player; + } + + get target() { + return this.#container; + } + + attach(container: HTMLElement) { + this.#container = container; + container.style.position = 'relative'; + + // Overlay: sits above the iframe so pointer events reach + // for controlsFeature (hover-to-show, idle timer, etc.). + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;inset:0;z-index:1;'; + container.appendChild(overlay); + this.#overlay = overlay; + + // PiP requires the iframe's browsing context to have user activation. + // Since the overlay intercepts clicks, the iframe never gets direct + // interaction. Work around this by focusing the iframe on every user click + // anywhere in the document — iframe.focus() during a user gesture gives + // the iframe activation via Chrome's focus-delegation mechanism. + const focusIframe = () => { + const iframe = this.#container?.querySelector('iframe'); + iframe?.focus(); + }; + globalThis.document?.addEventListener('click', focusIframe, { capture: true }); + this.#activationCleanup = () => globalThis.document?.removeEventListener('click', focusIframe, { capture: true }); + + if (this.#src) this.#mount(); + } + + detach() { + this.#overlay?.remove(); + this.#overlay = null; + this.#activationCleanup?.(); + this.#activationCleanup = null; + this.#unmount(); + this.#container = null; + } + + destroy() { + if (this.#destroyed) return; + this.#destroyed = true; + this.detach(); + } + + // -- Source -- + + get src() { + return this.#src; + } + + set src(value: string) { + if (this.#src === value) return; + this.#src = value; + if (!this.#container) return; + if (value) this.#mount(); + else this.#unmount(); + } + + get currentSrc() { + return this.#src; + } + + get readyState() { + return this.#readyState; + } + + load() { + if (this.#container && this.#src) this.#mount(); + } + + // -- Playback -- + + play() { + return this.#player?.play() ?? Promise.resolve(); + } + + pause() { + this.#player?.pause(); + } + + get paused() { + return this.#paused; + } + + get ended() { + return this.#ended; + } + + // -- Seek -- + + get currentTime() { + return this.#currentTime; + } + + set currentTime(value: number) { + if (!this.#player) return; + if (!this.#paused) { + this.#player.setCurrentTime(value); + return; + } + const seekingPlayer = this.#player; + const previousTime = this.#currentTime; + this.#currentTime = value; + this.#seeking = true; + this.dispatchEvent(new Event('seeking')); + this.#player.setCurrentTime(value).then( + (time) => { + if (this.#player !== seekingPlayer) return; + this.#currentTime = time; + this.#seeking = false; + this.dispatchEvent(new Event('timeupdate')); + this.dispatchEvent(new Event('seeked')); + }, + () => { + if (this.#player !== seekingPlayer || !this.#seeking) return; + this.#currentTime = previousTime; + this.#seeking = false; + this.dispatchEvent(new Event('seeked')); + } + ); + } + + get duration() { + return this.#duration; + } + + get seeking() { + return this.#seeking; + } + + // -- Volume -- + + get volume() { + return this.#volume; + } + + set volume(value: number) { + this.#player?.setVolume(value); + } + + get muted() { + return this.#muted; + } + + set muted(value: boolean) { + this.#muted = value; + this.#player?.setMuted(value); + } + + // -- Playback rate -- + + get playbackRate() { + return this.#playbackRate; + } + + set playbackRate(value: number) { + this.#player?.setPlaybackRate(value); + } + + // -- Buffer -- + + get buffered(): TimeRangeLike { + if (this.#buffered > 0) { + const end = this.#buffered; + return Object.freeze({ length: 1, start: () => 0, end: () => end }); + } + return EMPTY_TIME_RANGES; + } + + get seekable(): TimeRangeLike { + return EMPTY_TIME_RANGES; + } + + // -- Picture-in-picture -- + + get isPictureInPicture() { + return this.#pip; + } + + requestPictureInPicture() { + // Only attempt PiP on Safari — webkit's cross-origin iframe mechanism works + // without requiring user activation in the iframe's browsing context. + if (this.isPipCapable) this.#postVimeoMethod('requestPictureInPicture'); + return Promise.resolve(); + } + + exitPictureInPicture() { + if (this.isPipCapable) this.#postVimeoMethod('exitPictureInPicture'); + return Promise.resolve(); + } + + #postVimeoMethod(method: string) { + const iframe = this.#container?.querySelector('iframe'); + if (!iframe?.contentWindow || !iframe.src) return; + try { + const origin = new URL(iframe.src).origin; + iframe.contentWindow.postMessage({ method }, origin); + } catch { + // Ignore cross-origin or URL parse errors. + } + } + + // -- Error -- + + get error() { + return this.#error; + } + + // -- Text tracks -- + + get textTracks(): TextTrackList { + if (!this.#textTracksVideo) { + this.#textTracksVideo = document.createElement('video'); + } + return this.#textTracksVideo.textTracks; + } + + // -- Embed params -- + + get dnt() { + return this.#dnt; + } + + set dnt(value: boolean) { + if (this.#dnt === value) return; + this.#dnt = value; + if (this.#player) this.#mount(); + } + + get autoplay() { + return this.#autoplay; + } + + set autoplay(value: boolean) { + this.#autoplay = value; + } + + get autopause() { + return this.#autopause; + } + + set autopause(value: boolean) { + this.#autopause = value; + this.#player?.setAutopause(value); + } + + get background() { + return this.#background; + } + + set background(value: boolean) { + if (this.#background === value) return; + this.#background = value; + if (this.#player) this.#mount(); + } + + get byline() { + return this.#byline; + } + + set byline(value: boolean) { + if (this.#byline === value) return; + this.#byline = value; + if (this.#player) this.#mount(); + } + + get color() { + return this.#color; + } + + set color(value: string) { + this.#color = value; + if (value) this.#player?.setColor(value); + } + + get controls() { + return this.#controls; + } + + set controls(value: boolean) { + if (this.#controls === value) return; + this.#controls = value; + if (this.#player) this.#mount(); + } + + get loop() { + return this.#loop; + } + + set loop(value: boolean) { + this.#loop = value; + this.#player?.setLoop(value); + } + + get playsinline() { + return this.#playsinline; + } + + set playsinline(value: boolean) { + if (this.#playsinline === value) return; + this.#playsinline = value; + if (this.#player) this.#mount(); + } + + get portrait() { + return this.#portrait; + } + + set portrait(value: boolean) { + if (this.#portrait === value) return; + this.#portrait = value; + if (this.#player) this.#mount(); + } + + get quality() { + return this.#quality; + } + + set quality(value: string) { + this.#quality = value; + this.#player?.setQuality(value); + } + + get responsive() { + return this.#responsive; + } + + set responsive(value: boolean) { + if (this.#responsive === value) return; + this.#responsive = value; + } + + get speed() { + return this.#speed; + } + + set speed(value: boolean) { + if (this.#speed === value) return; + this.#speed = value; + if (this.#player) this.#mount(); + } + + get texttrack() { + return this.#texttrack; + } + + set texttrack(value: string) { + if (this.#texttrack === value) return; + this.#texttrack = value; + if (!this.#player) return; + if (value) this.#player.enableTextTrack(value); + else this.#player.disableTextTrack(); + } + + get title() { + return this.#title; + } + + set title(value: boolean) { + if (this.#title === value) return; + this.#title = value; + if (this.#player) this.#mount(); + } + + get transparent() { + return this.#transparent; + } + + set transparent(value: boolean) { + if (this.#transparent === value) return; + this.#transparent = value; + if (this.#player) this.#mount(); + } + + // -- Private -- + + #mount() { + this.#unmount(); + if (!this.#container || !this.#src) return; + + const options: VimeoEmbedParameters = { + dnt: this.#dnt, + autoplay: this.#autoplay, + autopause: this.#autopause, + background: this.#background, + byline: this.#byline, + controls: this.#controls, + loop: this.#loop, + muted: this.#muted, + playsinline: this.#playsinline, + portrait: this.#portrait, + quality: this.#quality, + responsive: false, + speed: this.#speed, + title: this.#title, + transparent: this.#transparent, + }; + + if (this.#color) options.color = this.#color; + if (this.#texttrack) options.texttrack = this.#texttrack; + + const src = this.#src; + if (/^\d+$/.test(src)) { + options.id = parseInt(src, 10); + } else { + // Cast: VimeoUrl is a template literal type; we accept any vimeo.com URL. + options.url = src as VimeoUrl; + } + + this.#player = new Player(this.#container, options); + + // Make the iframe fill its container completely in both HTML and React contexts. + const iframe = this.#container.querySelector('iframe'); + if (iframe) { + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + iframe.style.display = 'block'; + } + + this.#subscribe(this.#player); + + // The 'error' event covers SDK-level playback errors, but network failures, + // invalid URLs, and private/deleted videos only surface as a ready() rejection. + // On success, re-apply iframe sizing because the SDK resets dimensions once ready. + const mountedPlayer = this.#player; + mountedPlayer.ready().then( + () => { + if (this.#player !== mountedPlayer) return; + const iframe = this.#container?.querySelector('iframe'); + if (iframe) { + iframe.style.width = '100%'; + iframe.style.height = '100%'; + } + }, + (err: Error) => { + if (this.#player !== mountedPlayer || this.#error) return; + this.#error = { code: 4, message: err?.message ?? 'Failed to load Vimeo video' }; + this.dispatchEvent(new Event('error')); + } + ); + } + + #unmount() { + if (!this.#player) return; + this.#textTracksAbort?.abort(); + this.#textTracksAbort = null; + for (const track of this.#mountedTracks) { + track.mode = 'disabled'; + } + this.#mountedTracks = []; + this.#player.destroy(); + this.#player = null; + this.#resetState(); + } + + #subscribe(player: Player) { + player.on('play', () => { + this.#paused = false; + this.#ended = false; + // Vimeo only fires 'play' when the video is actually ready to play, + // so readyState must be HAVE_ENOUGH_DATA before syncing the store. + this.#readyState = 4; + this.dispatchEvent(new Event('play')); + this.dispatchEvent(new Event('playing')); + }); + + player.on('pause', () => { + this.#paused = true; + this.dispatchEvent(new Event('pause')); + }); + + player.on('ended', () => { + this.#paused = true; + this.#ended = true; + this.dispatchEvent(new Event('ended')); + this.dispatchEvent(new Event('pause')); + }); + + player.on('timeupdate', ({ seconds }) => { + this.#currentTime = seconds; + this.dispatchEvent(new Event('timeupdate')); + }); + + player.on('durationchange', ({ duration }) => { + this.#duration = duration; + this.dispatchEvent(new Event('durationchange')); + }); + + player.on('volumechange', ({ volume, muted }) => { + this.#volume = volume; + // The Vimeo SDK historically emits only { volume } — `muted` was added + // later and may be absent. Fall back to cached value to avoid corruption. + this.#muted = muted ?? this.#muted; + this.dispatchEvent(new Event('volumechange')); + }); + + player.on('playbackratechange', ({ playbackRate }) => { + this.#playbackRate = playbackRate; + this.dispatchEvent(new Event('ratechange')); + }); + + player.on('seeking', () => { + // Seeking means the current position's data is no longer available. + this.#readyState = 2; + this.#seeking = true; + this.dispatchEvent(new Event('seeking')); + }); + + player.on('seeked', () => { + this.#readyState = 4; + this.#seeking = false; + this.dispatchEvent(new Event('seeked')); + }); + + player.on('bufferstart', () => { + // Rebuffering — data at current position only. + this.#readyState = 2; + this.dispatchEvent(new Event('waiting')); + }); + + player.on('bufferend', () => { + this.#readyState = 4; + this.dispatchEvent(new Event('canplay')); + // Dispatch 'playing' so playbackFeature re-syncs waiting → false. + if (!this.#paused) this.dispatchEvent(new Event('playing')); + }); + + player.on('loaded', () => { + // 'loaded' fires when the video is ready to play — treat as HAVE_ENOUGH_DATA. + this.#readyState = 4; + this.dispatchEvent(new Event('loadstart')); + this.dispatchEvent(new Event('loadedmetadata')); + this.dispatchEvent(new Event('loadeddata')); + this.dispatchEvent(new Event('canplay')); + this.dispatchEvent(new Event('canplaythrough')); + }); + + player.on('error', ({ name, message }) => { + // NotAllowedError means a browser permission/gesture requirement wasn't met + // (e.g. requestPictureInPicture cross-origin, autoplay blocked). These are + // not playback errors and should not trigger the error dialog. + if (name === 'NotAllowedError') return; + // Map Vimeo error names to MediaError codes (3 = MEDIA_ERR_DECODE, 4 = MEDIA_ERR_SRC_NOT_SUPPORTED) + this.#error = { code: name === 'NotFoundError' ? 4 : 3, message }; + this.dispatchEvent(new Event('error')); + }); + + player.on('enterpictureinpicture', () => { + this.#pip = true; + this.dispatchEvent(new Event('enterpictureinpicture')); + }); + + player.on('leavepictureinpicture', () => { + this.#pip = false; + this.dispatchEvent(new Event('leavepictureinpicture')); + }); + + player.on('progress', ({ seconds }) => { + this.#buffered = seconds; + this.dispatchEvent(new Event('progress')); + }); + + player.on('resize', () => { + this.dispatchEvent(new Event('resize')); + }); + + // Seed cached state from current player values to handle already-loaded videos. + const seededPlayer = player; + Promise.all([ + player.getVolume(), + player.getMuted(), + player.getPaused(), + player.getDuration(), + player.getPlaybackRate(), + ]) + .then(([volume, muted, paused, duration, rate]) => { + if (this.#player !== seededPlayer) return; + this.#volume = volume; + this.#muted = muted; + this.#paused = paused; + this.#duration = duration; + this.#playbackRate = rate; + // Notify the store of the actual initial state — the feature's sync() + // ran before these promises resolved, so the store needs a nudge. + this.dispatchEvent(new Event('volumechange')); + this.dispatchEvent(new Event('durationchange')); + this.dispatchEvent(new Event('ratechange')); + }) + .catch(() => {}); + + // Populate the synthetic TextTrackList from the Vimeo API. + if (!this.#textTracksVideo) { + this.#textTracksVideo = document.createElement('video'); + } + const textTracksVideo = this.#textTracksVideo; + const mountedPlayer = player; + + player + .getTextTracks() + .then((vimeoTracks) => { + if (this.#player !== mountedPlayer) return; + for (const t of vimeoTracks) { + const track = textTracksVideo.addTextTrack(t.kind as TextTrackKind, t.label, t.language); + track.mode = t.mode === 'showing' ? 'showing' : 'disabled'; + this.#mountedTracks.push(track); + } + }) + .catch(() => {}); + + // Forward track-mode changes made by the UI back to the Vimeo iframe. + this.#textTracksAbort?.abort(); + this.#textTracksAbort = new AbortController(); + textTracksVideo.textTracks.addEventListener( + 'change', + () => { + const active = Array.from(textTracksVideo.textTracks).find( + (t) => t.mode === 'showing' && this.#mountedTracks.includes(t as TextTrack) + ); + if (active) { + player.enableTextTrack(active.language, active.kind); + } else { + player.disableTextTrack(); + } + }, + { signal: this.#textTracksAbort.signal } + ); + } + + #resetState() { + this.#paused = true; + this.#ended = false; + this.#duration = NaN; + this.#currentTime = 0; + this.#buffered = 0; + this.#readyState = 0; + this.#volume = 1; + this.#playbackRate = 1; + this.#seeking = false; + this.#pip = false; + this.#error = null; + // #muted is intentionally preserved — it's both prop config and cached state. + this.dispatchEvent(new Event('emptied')); + } +} diff --git a/packages/core/src/dom/media/vimeo/tests/vimeo-media.test.ts b/packages/core/src/dom/media/vimeo/tests/vimeo-media.test.ts new file mode 100644 index 000000000..0318e6e7e --- /dev/null +++ b/packages/core/src/dom/media/vimeo/tests/vimeo-media.test.ts @@ -0,0 +1,840 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Captured Player constructor call args and instance per test. +let mockPlayerInstance: ReturnType; +let capturedElement: unknown; +let capturedOptions: unknown; + +function makeMockPlayer() { + const handlers: Record void> = {}; + + const player = { + on: vi.fn((event: string, handler: (data?: unknown) => void) => { + handlers[event] = handler; + }), + off: vi.fn(), + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(() => Promise.resolve()), + destroy: vi.fn(() => Promise.resolve()), + setCurrentTime: vi.fn(() => Promise.resolve(0)), + setVolume: vi.fn(() => Promise.resolve(0)), + setMuted: vi.fn(() => Promise.resolve(false)), + setPlaybackRate: vi.fn(() => Promise.resolve(1)), + setLoop: vi.fn(() => Promise.resolve(false)), + setAutopause: vi.fn(() => Promise.resolve(false)), + setColor: vi.fn(() => Promise.resolve('')), + setQuality: vi.fn(() => Promise.resolve(null)), + requestPictureInPicture: vi.fn(() => Promise.resolve()), + exitPictureInPicture: vi.fn(() => Promise.resolve()), + getTextTracks: vi.fn(() => Promise.resolve([])), + enableTextTrack: vi.fn(() => Promise.resolve({})), + disableTextTrack: vi.fn(() => Promise.resolve({})), + getVolume: vi.fn(() => Promise.resolve(1)), + getMuted: vi.fn(() => Promise.resolve(false)), + getPaused: vi.fn(() => Promise.resolve(true)), + getDuration: vi.fn(() => Promise.resolve(NaN)), + getPlaybackRate: vi.fn(() => Promise.resolve(1)), + ready: vi.fn(() => Promise.resolve()), + // Helper to fire events from tests. + _emit(event: string, data?: unknown) { + handlers[event]?.(data); + }, + }; + + return player; +} + +vi.mock('@vimeo/player', () => { + return { + // biome-ignore lint: must be a regular function so `new Player()` works + default: vi.fn(function (element: unknown, options: unknown) { + capturedElement = element; + capturedOptions = options; + mockPlayerInstance = makeMockPlayer(); + return mockPlayerInstance; + }), + }; +}); + +// jsdom exposes textTracks as a plain Array without EventTarget methods. +// Intercept document.createElement('video') to return a proper mock. +const _origCreate = document.createElement.bind(document); +vi.spyOn(document, 'createElement').mockImplementation((tag, ...args) => { + if (tag !== 'video') return _origCreate(tag, ...args); + + const textTracks = Object.assign(new EventTarget(), { + length: 0, + [Symbol.iterator]: function* () {}, + }) as unknown as TextTrackList; + + const mockVideo = { + textTracks, + addTextTrack: vi.fn((kind: TextTrackKind, label = '', language = '') => { + const track: Partial = { + kind, + label, + language, + mode: 'disabled' as TextTrackMode, + cues: null, + activeCues: null, + }; + return track as TextTrack; + }), + }; + return mockVideo as unknown as HTMLVideoElement; +}); + +// vi.mock is hoisted, so this import resolves to the mocked module. +import Player from '@vimeo/player'; +import { VimeoMedia, vimeoMediaDefaultProps } from '../index'; + +afterEach(() => { + vi.clearAllMocks(); + capturedElement = undefined; + capturedOptions = undefined; +}); + +function makeContainer() { + return document.createElement('div'); +} + +function setup(src = '123456789') { + const media = new VimeoMedia(); + media.src = src; + const container = makeContainer(); + media.attach(container); + return { media, container }; +} + +const MockPlayer = vi.mocked(Player); + +describe('VimeoMedia', () => { + describe('defaults', () => { + it('sets dnt: true by default', () => { + setup(); + expect((capturedOptions as { dnt: boolean }).dnt).toBe(true); + }); + + it('starts paused', () => { + const { media } = setup(); + expect(media.paused).toBe(true); + }); + + it('starts with NaN duration', () => { + const { media } = setup(); + expect(media.duration).toBeNaN(); + }); + + it('starts with readyState 0', () => { + const { media } = setup(); + expect(media.readyState).toBe(0); + }); + }); + + describe('state seeding', () => { + it('dispatches durationchange and ratechange after seeding so time/rate features sync', async () => { + // biome-ignore lint: must be a regular function so `new Player()` works + MockPlayer.mockImplementationOnce(function (element: unknown, options: unknown) { + capturedElement = element; + capturedOptions = options; + mockPlayerInstance = makeMockPlayer(); + mockPlayerInstance.getDuration.mockResolvedValue(120); + mockPlayerInstance.getPlaybackRate.mockResolvedValue(1.5); + return mockPlayerInstance; + }); + + const { media } = setup(); + + const events: string[] = []; + media.addEventListener('durationchange', () => events.push('durationchange')); + media.addEventListener('ratechange', () => events.push('ratechange')); + + // Promise.all over 5 resolved promises takes several microtask ticks to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events).toContain('durationchange'); + expect(events).toContain('ratechange'); + expect(media.duration).toBe(120); + expect(media.playbackRate).toBe(1.5); + }); + }); + + describe('attach', () => { + it('creates a Player with the container element', () => { + const media = new VimeoMedia(); + media.src = '123456789'; + const container = makeContainer(); + media.attach(container); + + expect(capturedElement).toBe(container); + }); + + it('parses a numeric src as an id option', () => { + setup('123456789'); + expect((capturedOptions as { id: number }).id).toBe(123456789); + }); + + it('parses a URL src as a url option', () => { + setup('https://vimeo.com/123456789'); + expect((capturedOptions as { url: string }).url).toBe('https://vimeo.com/123456789'); + }); + + it('does not create a Player when src is empty', () => { + MockPlayer.mockClear(); + const media = new VimeoMedia(); + const container = makeContainer(); + media.attach(container); + + expect(MockPlayer).not.toHaveBeenCalled(); + }); + + it('exposes the Player instance via engine', () => { + const { media } = setup(); + expect(media.engine).toBe(mockPlayerInstance); + }); + + it('exposes the container via target', () => { + const { media, container } = setup(); + expect(media.target).toBe(container); + }); + }); + + describe('detach', () => { + it('destroys the Player', () => { + const { media } = setup(); + const player = mockPlayerInstance; + + media.detach(); + + expect(player.destroy).toHaveBeenCalledOnce(); + }); + + it('nullifies engine and target', () => { + const { media } = setup(); + + media.detach(); + + expect(media.engine).toBeNull(); + expect(media.target).toBeNull(); + }); + + it('dispatches emptied event', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('emptied', handler); + + media.detach(); + + expect(handler).toHaveBeenCalledOnce(); + }); + }); + + describe('destroy', () => { + it('is idempotent', () => { + const { media } = setup(); + const player = mockPlayerInstance; + + media.destroy(); + media.destroy(); + + expect(player.destroy).toHaveBeenCalledOnce(); + }); + }); + + describe('src setter', () => { + it('remounts the Player when src changes', () => { + const { media, container } = setup('111'); + + expect(MockPlayer).toHaveBeenCalledOnce(); + + media.src = '222'; + + expect(MockPlayer).toHaveBeenCalledTimes(2); + expect(capturedElement).toBe(container); + }); + + it('destroys the previous Player on src change', () => { + const { media } = setup('111'); + const firstPlayer = mockPlayerInstance; + + media.src = '222'; + + expect(firstPlayer.destroy).toHaveBeenCalledOnce(); + }); + + it('unmounts without mounting when src is set to empty', () => { + const { media } = setup('111'); + const firstPlayer = mockPlayerInstance; + + MockPlayer.mockClear(); + media.src = ''; + + expect(firstPlayer.destroy).toHaveBeenCalledOnce(); + expect(MockPlayer).not.toHaveBeenCalled(); + }); + + it('is a no-op when src is set to the same value', () => { + const { media } = setup('111'); + + MockPlayer.mockClear(); + media.src = '111'; + + expect(MockPlayer).not.toHaveBeenCalled(); + }); + }); + + describe('event forwarding', () => { + it('play event dispatches play and playing', () => { + const { media } = setup(); + const playHandler = vi.fn(); + const playingHandler = vi.fn(); + media.addEventListener('play', playHandler); + media.addEventListener('playing', playingHandler); + + mockPlayerInstance._emit('play'); + + expect(playHandler).toHaveBeenCalledOnce(); + expect(playingHandler).toHaveBeenCalledOnce(); + }); + + it('play event sets paused=false, ended=false', () => { + const { media } = setup(); + + mockPlayerInstance._emit('play'); + + expect(media.paused).toBe(false); + expect(media.ended).toBe(false); + }); + + it('pause event dispatches pause and sets paused=true', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('pause', handler); + + mockPlayerInstance._emit('pause'); + + expect(handler).toHaveBeenCalledOnce(); + expect(media.paused).toBe(true); + }); + + it('ended event dispatches ended and pause, sets paused=true, ended=true', () => { + const { media } = setup(); + const endedHandler = vi.fn(); + const pauseHandler = vi.fn(); + media.addEventListener('ended', endedHandler); + media.addEventListener('pause', pauseHandler); + + mockPlayerInstance._emit('ended'); + + expect(endedHandler).toHaveBeenCalledOnce(); + expect(pauseHandler).toHaveBeenCalledOnce(); + expect(media.paused).toBe(true); + expect(media.ended).toBe(true); + }); + + it('timeupdate event updates currentTime and dispatches timeupdate', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('timeupdate', handler); + + mockPlayerInstance._emit('timeupdate', { seconds: 42.5 }); + + expect(media.currentTime).toBe(42.5); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('durationchange event updates duration and dispatches durationchange', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('durationchange', handler); + + mockPlayerInstance._emit('durationchange', { duration: 120 }); + + expect(media.duration).toBe(120); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('volumechange event updates volume/muted and dispatches volumechange', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('volumechange', handler); + + mockPlayerInstance._emit('volumechange', { volume: 0.5, muted: true }); + + expect(media.volume).toBe(0.5); + expect(media.muted).toBe(true); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('volumechange event without muted preserves cached muted state', () => { + const { media } = setup(); + + // Establish a known muted state via a full payload first. + mockPlayerInstance._emit('volumechange', { volume: 1, muted: true }); + expect(media.muted).toBe(true); + + // SDK emits without muted (legacy behaviour) — cached value must survive. + mockPlayerInstance._emit('volumechange', { volume: 0.8 }); + + expect(media.volume).toBe(0.8); + expect(media.muted).toBe(true); + }); + + it('playbackratechange event updates playbackRate and dispatches ratechange', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('ratechange', handler); + + mockPlayerInstance._emit('playbackratechange', { playbackRate: 2 }); + + expect(media.playbackRate).toBe(2); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('seeking event sets seeking=true and dispatches seeking', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('seeking', handler); + + mockPlayerInstance._emit('seeking'); + + expect(media.seeking).toBe(true); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('seeked event sets seeking=false and dispatches seeked', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('seeked', handler); + + mockPlayerInstance._emit('seeking'); + mockPlayerInstance._emit('seeked'); + + expect(media.seeking).toBe(false); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('bufferstart dispatches waiting', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('waiting', handler); + + mockPlayerInstance._emit('bufferstart'); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('bufferstart sets readyState to 2 and dispatches waiting', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('waiting', handler); + + mockPlayerInstance._emit('bufferstart'); + + expect(media.readyState).toBe(2); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('bufferend sets readyState to 4 and dispatches canplay', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('canplay', handler); + + mockPlayerInstance._emit('bufferend'); + + expect(media.readyState).toBe(4); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('bufferend dispatches playing when not paused', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('playing', handler); + + mockPlayerInstance._emit('play'); + handler.mockClear(); + mockPlayerInstance._emit('bufferend'); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('bufferend does not dispatch playing when paused', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('playing', handler); + + mockPlayerInstance._emit('bufferend'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('loaded event sets readyState to 4 and dispatches loadstart, loadedmetadata, loadeddata, canplay, canplaythrough', () => { + const { media } = setup(); + const dispatched: string[] = []; + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough'] as const) { + media.addEventListener(type, () => dispatched.push(type)); + } + + mockPlayerInstance._emit('loaded'); + + expect(media.readyState).toBe(4); + expect(dispatched).toEqual(['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough']); + }); + + it('play event sets readyState to 4', () => { + const { media } = setup(); + + mockPlayerInstance._emit('play'); + + expect(media.readyState).toBe(4); + }); + + it('seeking event sets readyState to 2', () => { + const { media } = setup(); + + mockPlayerInstance._emit('seeking'); + + expect(media.readyState).toBe(2); + }); + + it('seeked event sets readyState to 4', () => { + const { media } = setup(); + + mockPlayerInstance._emit('seeking'); + mockPlayerInstance._emit('seeked'); + + expect(media.readyState).toBe(4); + }); + + it('error event populates error and dispatches error', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('error', handler); + + mockPlayerInstance._emit('error', { name: 'NotFoundError', message: 'Video not found' }); + + expect(media.error).toEqual({ code: 4, message: 'Video not found' }); + expect(handler).toHaveBeenCalledOnce(); + }); + }); + + describe('playback controls', () => { + it('play() calls player.play()', () => { + const { media } = setup(); + + media.play(); + + expect(mockPlayerInstance.play).toHaveBeenCalledOnce(); + }); + + it('pause() calls player.pause()', () => { + const { media } = setup(); + + media.pause(); + + expect(mockPlayerInstance.pause).toHaveBeenCalledOnce(); + }); + + it('currentTime setter calls player.setCurrentTime()', () => { + const { media } = setup(); + + media.currentTime = 30; + + expect(mockPlayerInstance.setCurrentTime).toHaveBeenCalledWith(30); + }); + + it('currentTime setter while paused synthesizes seeking, timeupdate, seeked events and updates currentTime', async () => { + const { media } = setup(); + mockPlayerInstance.setCurrentTime.mockResolvedValueOnce(30); + + const events: string[] = []; + media.addEventListener('seeking', () => events.push('seeking')); + media.addEventListener('timeupdate', () => events.push('timeupdate')); + media.addEventListener('seeked', () => events.push('seeked')); + + media.currentTime = 30; + + expect(events).toEqual(['seeking']); + expect(media.seeking).toBe(true); + + await Promise.resolve(); + + expect(events).toEqual(['seeking', 'timeupdate', 'seeked']); + expect(media.currentTime).toBe(30); + expect(media.seeking).toBe(false); + }); + + it('currentTime setter while paused reverts and dispatches seeked when setCurrentTime rejects', async () => { + const { media } = setup(); + mockPlayerInstance.setCurrentTime.mockRejectedValueOnce(new Error('invalid time')); + + const events: string[] = []; + media.addEventListener('seeked', () => events.push('seeked')); + + const previousTime = media.currentTime; + media.currentTime = Number.NaN; + + await Promise.resolve(); + + expect(events).toEqual(['seeked']); + expect(media.currentTime).toBe(previousTime); + expect(media.seeking).toBe(false); + }); + + it('currentTime setter while paused ignores resolution if player changed before promise resolves', async () => { + const { media } = setup(); + + let resolveSeek!: (time: number) => void; + mockPlayerInstance.setCurrentTime.mockReturnValueOnce( + new Promise((resolve) => { + resolveSeek = resolve; + }) + ); + + const events: string[] = []; + media.addEventListener('timeupdate', () => events.push('timeupdate')); + media.addEventListener('seeked', () => events.push('seeked')); + + media.currentTime = 30; + media.detach(); + + resolveSeek(30); + await Promise.resolve(); + + expect(events).toEqual([]); + }); + + it('volume setter calls player.setVolume()', () => { + const { media } = setup(); + + media.volume = 0.75; + + expect(mockPlayerInstance.setVolume).toHaveBeenCalledWith(0.75); + }); + + it('muted setter calls player.setMuted()', () => { + const { media } = setup(); + + media.muted = true; + + expect(mockPlayerInstance.setMuted).toHaveBeenCalledWith(true); + }); + + it('playbackRate setter calls player.setPlaybackRate()', () => { + const { media } = setup(); + + media.playbackRate = 1.5; + + expect(mockPlayerInstance.setPlaybackRate).toHaveBeenCalledWith(1.5); + }); + }); + + describe('embed props', () => { + it('passes all default embed props to Player', () => { + setup(); + const opts = capturedOptions as typeof vimeoMediaDefaultProps; + expect(opts.dnt).toBe(vimeoMediaDefaultProps.dnt); + expect(opts.controls).toBe(vimeoMediaDefaultProps.controls); + expect(opts.responsive).toBe(false); // always false — VimeoMedia handles layout + expect(opts.transparent).toBe(vimeoMediaDefaultProps.transparent); + }); + + it('autopause setter calls player.setAutopause()', () => { + const { media } = setup(); + + media.autopause = false; + + expect(mockPlayerInstance.setAutopause).toHaveBeenCalledWith(false); + }); + + it('color setter calls player.setColor() when non-empty', () => { + const { media } = setup(); + + media.color = 'ff0000'; + + expect(mockPlayerInstance.setColor).toHaveBeenCalledWith('ff0000'); + }); + + it('loop setter calls player.setLoop()', () => { + const { media } = setup(); + + media.loop = true; + + expect(mockPlayerInstance.setLoop).toHaveBeenCalledWith(true); + }); + + it('quality setter calls player.setQuality()', () => { + const { media } = setup(); + + media.quality = '1080p'; + + expect(mockPlayerInstance.setQuality).toHaveBeenCalledWith('1080p'); + }); + + it.each([ + ['dnt', false], + ['byline', false], + ['portrait', false], + ['title', false], + ['controls', true], + ['background', true], + ['playsinline', false], + ['speed', false], + ['transparent', false], + ] as const)('%s setter remounts the player with the new value', (prop, newValue) => { + const { media } = setup(); + const callsBefore = MockPlayer.mock.calls.length; + + (media as unknown as Record)[prop] = newValue; + + expect(MockPlayer.mock.calls.length).toBe(callsBefore + 1); + const opts = MockPlayer.mock.calls.at(-1)![1] as Record; + expect(opts[prop]).toBe(newValue); + }); + + it('embed-only setter does not remount when value is unchanged', () => { + const { media } = setup(); + const callsBefore = MockPlayer.mock.calls.length; + + media.dnt = vimeoMediaDefaultProps.dnt; + + expect(MockPlayer.mock.calls.length).toBe(callsBefore); + }); + + it('embed-only setter before mount stores value without mounting', () => { + const media = new VimeoMedia(); + media.dnt = false; + expect(MockPlayer).not.toHaveBeenCalled(); + + const container = makeContainer(); + media.src = '123456789'; + media.attach(container); + + expect((capturedOptions as { dnt: boolean }).dnt).toBe(false); + }); + + it('texttrack setter calls enableTextTrack() when player is running', () => { + const { media } = setup(); + + media.texttrack = 'en'; + + expect(mockPlayerInstance.enableTextTrack).toHaveBeenCalledWith('en'); + }); + + it('texttrack setter calls disableTextTrack() when set to empty string', () => { + const { media } = setup(); + media.texttrack = 'en'; + + media.texttrack = ''; + + expect(mockPlayerInstance.disableTextTrack).toHaveBeenCalled(); + }); + + it('texttrack setter stores value for use on next mount', () => { + const media = new VimeoMedia(); + media.texttrack = 'fr'; + expect(mockPlayerInstance?.enableTextTrack).not.toHaveBeenCalled(); + + const container = makeContainer(); + media.src = '123456789'; + media.attach(container); + + expect((capturedOptions as { texttrack: string }).texttrack).toBe('fr'); + }); + }); + + describe('muted prop applied on mount', () => { + it('passes muted=true to Player when set before attach', () => { + const media = new VimeoMedia(); + media.src = '123456789'; + media.muted = true; + const container = makeContainer(); + media.attach(container); + + expect((capturedOptions as { muted: boolean }).muted).toBe(true); + }); + }); + + describe('picture-in-picture', () => { + it('starts with isPictureInPicture=false', () => { + const { media } = setup(); + expect(media.isPictureInPicture).toBe(false); + }); + + it('enterpictureinpicture event sets isPictureInPicture=true and dispatches event', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('enterpictureinpicture', handler); + + mockPlayerInstance._emit('enterpictureinpicture'); + + expect(media.isPictureInPicture).toBe(true); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('leavepictureinpicture event sets isPictureInPicture=false and dispatches event', () => { + const { media } = setup(); + const handler = vi.fn(); + media.addEventListener('leavepictureinpicture', handler); + + mockPlayerInstance._emit('enterpictureinpicture'); + mockPlayerInstance._emit('leavepictureinpicture'); + + expect(media.isPictureInPicture).toBe(false); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('requestPictureInPicture() posts requestPictureInPicture to the iframe', () => { + // isPipCapable is false in jsdom (non-Safari) — no postMessage sent. + const { media, container } = setup(); + const iframe = document.createElement('iframe'); + Object.defineProperty(iframe, 'src', { value: 'https://player.vimeo.com/video/123', writable: false }); + const postMessage = vi.fn(); + Object.defineProperty(iframe, 'contentWindow', { value: { postMessage }, writable: false }); + container.appendChild(iframe); + + media.requestPictureInPicture(); + + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('requestPictureInPicture() posts to the iframe when isPipCapable is true', () => { + const { media, container } = setup(); + Object.defineProperty(media, 'isPipCapable', { value: true, writable: false }); + + const iframe = document.createElement('iframe'); + Object.defineProperty(iframe, 'src', { value: 'https://player.vimeo.com/video/123', writable: false }); + const postMessage = vi.fn(); + Object.defineProperty(iframe, 'contentWindow', { value: { postMessage }, writable: false }); + container.appendChild(iframe); + + media.requestPictureInPicture(); + + expect(postMessage).toHaveBeenCalledWith({ method: 'requestPictureInPicture' }, 'https://player.vimeo.com'); + }); + + it('exitPictureInPicture() posts exitPictureInPicture to the iframe', () => { + // isPipCapable is false in jsdom — no postMessage sent. + const { media, container } = setup(); + const iframe = document.createElement('iframe'); + Object.defineProperty(iframe, 'src', { value: 'https://player.vimeo.com/video/123', writable: false }); + const postMessage = vi.fn(); + Object.defineProperty(iframe, 'contentWindow', { value: { postMessage }, writable: false }); + container.appendChild(iframe); + + media.exitPictureInPicture(); + + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('detach resets isPictureInPicture to false', () => { + const { media } = setup(); + mockPlayerInstance._emit('enterpictureinpicture'); + expect(media.isPictureInPicture).toBe(true); + + media.detach(); + + expect(media.isPictureInPicture).toBe(false); + }); + }); +}); diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts index ae347cebf..c5989c746 100644 --- a/packages/core/src/dom/presentation/pip.ts +++ b/packages/core/src/dom/presentation/pip.ts @@ -2,7 +2,8 @@ import { isFunction } from '@videojs/utils/predicate'; import type { MediaPictureInPictureCapability } from '../../core/media/types'; import type { WebKitVideoElement } from './types'; -export function isPictureInPictureEnabled() { +export function isPictureInPictureEnabled(media?: EventTarget) { + if (media && 'isPipCapable' in media && (media as { isPipCapable: boolean }).isPipCapable === false) return false; if (document.pictureInPictureEnabled) { const isSafari = /.*Version\/.*Safari\/.*/.test(navigator.userAgent); const isPWA = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches; @@ -52,12 +53,15 @@ export async function exitPictureInPicture(media: EventTarget) { return; } - if (isFunction(document.exitPictureInPicture)) { - return document.exitPictureInPicture(); - } - + // Check the media's own method first — iframe-based providers (e.g. Vimeo) + // manage PiP inside their own document, so document.exitPictureInPicture() + // on the parent page would fail with InvalidStateError. const video = media as unknown as MediaPictureInPictureCapability; if (isFunction(video.exitPictureInPicture)) { return video.exitPictureInPicture() as Promise; } + + if (isFunction(document.exitPictureInPicture)) { + return document.exitPictureInPicture(); + } } diff --git a/packages/core/src/dom/presentation/tests/pip.test.ts b/packages/core/src/dom/presentation/tests/pip.test.ts new file mode 100644 index 000000000..51e92dec5 --- /dev/null +++ b/packages/core/src/dom/presentation/tests/pip.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isPictureInPictureEnabled } from '../pip'; + +describe('isPictureInPictureEnabled', () => { + beforeEach(() => { + vi.stubGlobal('document', { + pictureInPictureEnabled: true, + pictureInPictureElement: null, + createElement: () => ({}), + }); + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0' }); + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns true on Firefox when document.pictureInPictureEnabled is true', () => { + expect(isPictureInPictureEnabled()).toBe(true); + }); + + it('returns false when media has isPipCapable set to false', () => { + const media = Object.assign(new EventTarget(), { isPipCapable: false }); + expect(isPictureInPictureEnabled(media)).toBe(false); + }); + + it('returns true when media has isPipCapable set to true', () => { + const media = Object.assign(new EventTarget(), { isPipCapable: true }); + expect(isPictureInPictureEnabled(media)).toBe(true); + }); + + it('returns true when media does not have isPipCapable (standard HTMLVideoElement)', () => { + const media = new EventTarget(); + expect(isPictureInPictureEnabled(media)).toBe(true); + }); +}); diff --git a/packages/core/src/dom/store/features/pip.ts b/packages/core/src/dom/store/features/pip.ts index c0888b9e9..b3a025e8b 100644 --- a/packages/core/src/dom/store/features/pip.ts +++ b/packages/core/src/dom/store/features/pip.ts @@ -52,7 +52,7 @@ export const pipFeature = definePlayerFeature({ const { media } = target; set({ - pipAvailability: isPictureInPictureEnabled() ? 'available' : 'unsupported', + pipAvailability: isPictureInPictureEnabled(media) ? 'available' : 'unsupported', }); const sync = () => diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 51dc56ee2..39d70fb1a 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -16,6 +16,7 @@ const createConfig = (mode: BuildMode): UserConfig => ({ 'dom/media/mux/index': './src/dom/media/mux/index.ts', 'dom/media/native-hls/index': './src/dom/media/native-hls/index.ts', 'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts', + 'dom/media/vimeo/index': './src/dom/media/vimeo/index.ts', }, platform: 'neutral', format: 'es', diff --git a/packages/html/src/cdn/media/vimeo-video.ts b/packages/html/src/cdn/media/vimeo-video.ts new file mode 100644 index 000000000..122e7a08d --- /dev/null +++ b/packages/html/src/cdn/media/vimeo-video.ts @@ -0,0 +1 @@ +import '../../define/media/vimeo-video'; diff --git a/packages/html/src/define/global.css b/packages/html/src/define/global.css index c16026599..b6ec3f296 100644 --- a/packages/html/src/define/global.css +++ b/packages/html/src/define/global.css @@ -12,6 +12,7 @@ Required to override any default video and image styles (such as Tailwind's CSS reset) and ensure they fill the container as expected. */ video-player video, +video-player vimeo-video, video-player [slot="poster"], live-video-player video, live-video-player [slot="poster"] { diff --git a/packages/html/src/define/media/vimeo-video.ts b/packages/html/src/define/media/vimeo-video.ts new file mode 100644 index 000000000..d35f8b4a9 --- /dev/null +++ b/packages/html/src/define/media/vimeo-video.ts @@ -0,0 +1,14 @@ +import { VimeoVideo } from '../../media/vimeo-video'; +import { safeDefine } from '../safe-define'; + +export class VimeoVideoElement extends VimeoVideo { + static readonly tagName = 'vimeo-video'; +} + +safeDefine(VimeoVideoElement); + +declare global { + interface HTMLElementTagNameMap { + [VimeoVideoElement.tagName]: VimeoVideoElement; + } +} diff --git a/packages/html/src/media/vimeo-video/index.ts b/packages/html/src/media/vimeo-video/index.ts new file mode 100644 index 000000000..1ba17ac0a --- /dev/null +++ b/packages/html/src/media/vimeo-video/index.ts @@ -0,0 +1,112 @@ +import type { Media } from '@videojs/core/dom'; +import { VimeoMedia, vimeoMediaDefaultProps } from '@videojs/core/dom/media/vimeo'; +import { isUndefined } from '@videojs/utils/predicate'; +import { MediaAttachMixin } from '../../store/media-attach-mixin'; + +const OBSERVED_ATTRIBUTES = [ + 'src', + 'dnt', + 'autoplay', + 'autopause', + 'background', + 'byline', + 'color', + 'controls', + 'loop', + 'muted', + 'playsinline', + 'portrait', + 'quality', + 'responsive', + 'speed', + 'texttrack', + 'title', + 'transparent', +] as const; + +type ObservedAttribute = (typeof OBSERVED_ATTRIBUTES)[number]; + +const ATTR_TO_PROP: Record = { + src: 'src', + dnt: 'dnt', + autoplay: 'autoplay', + autopause: 'autopause', + background: 'background', + byline: 'byline', + color: 'color', + controls: 'controls', + loop: 'loop', + muted: 'muted', + playsinline: 'playsinline', + portrait: 'portrait', + quality: 'quality', + responsive: 'responsive', + speed: 'speed', + texttrack: 'texttrack', + title: 'title', + transparent: 'transparent', +}; + +function parseAttrValue(attr: ObservedAttribute, value: string | null): unknown { + const defaultValue = (vimeoMediaDefaultProps as unknown as Record)[attr]; + if (isUndefined(value) || value === null) return defaultValue; + if (typeof defaultValue === 'boolean') return value !== 'false'; + return value; +} + +export class VimeoVideo extends MediaAttachMixin(HTMLElement) { + static get observedAttributes(): string[] { + return [...OBSERVED_ATTRIBUTES]; + } + + #media = new VimeoMedia(); + #container: HTMLDivElement; + + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +
+ `; + this.#container = shadow.querySelector('div')!; + } + + // Register VimeoMedia (not `this`) with the player store. + getMediaTarget(): Media | null { + return this.#media as unknown as Media; + } + + connectedCallback() { + // The mixin adds connectedCallback to the runtime prototype; call it to + // register VimeoMedia with the store context. + // @ts-expect-error -- connectedCallback is on the mixin prototype, not HTMLElement's TS type + super.connectedCallback?.(); + this.#media.attach(this.#container); + // Sync any attributes that were set before connection. + for (const attr of OBSERVED_ATTRIBUTES) { + const value = this.getAttribute(attr); + if (value !== null) this.#syncAttr(attr, value); + } + } + + disconnectedCallback() { + // @ts-expect-error -- disconnectedCallback is on the mixin prototype, not HTMLElement's TS type + super.disconnectedCallback?.(); + this.#media.detach(); + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null) { + this.#syncAttr(name as ObservedAttribute, next); + } + + #syncAttr(attr: ObservedAttribute, value: string | null) { + const prop = ATTR_TO_PROP[attr]; + if (!prop) return; + (this.#media as unknown as Record)[prop] = parseAttrValue(attr, value); + } +} diff --git a/packages/react/src/media/vimeo-video/index.tsx b/packages/react/src/media/vimeo-video/index.tsx new file mode 100644 index 000000000..cf21b01c9 --- /dev/null +++ b/packages/react/src/media/vimeo-video/index.tsx @@ -0,0 +1,44 @@ +'use client'; + +import type { VimeoMediaProps } from '@videojs/core/dom/media/vimeo'; +import { VimeoMedia, vimeoMediaDefaultProps } from '@videojs/core/dom/media/vimeo'; +import { cn } from '@videojs/utils/style'; +import type { HTMLAttributes, ReactNode, RefCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; +import { useComposedRefs } from '../../utils/use-composed-refs'; +import { useMediaInstance } from '../../utils/use-media-instance'; +import { useSyncProps } from '../../utils/use-sync-props'; + +export interface VimeoVideoProps + extends Omit, keyof VimeoMediaProps>, + Partial { + children?: ReactNode; +} + +export const VimeoVideo = forwardRef(function VimeoVideo({ children, ...props }, ref) { + const media = useMediaInstance(VimeoMedia); + + // VimeoMedia targets an HTMLElement container (not HTMLMediaElement), so we + // inline the attach callback instead of using useAttachMedia. + const attachRef: RefCallback = useCallback( + (element) => { + if (element) media.attach(element); + else media.detach(); + return () => media.detach(); + }, + [media] + ); + + const composedRef = useComposedRefs(attachRef, ref); + const htmlProps = useSyncProps(media, props, vimeoMediaDefaultProps); + + return ( +
+ {children} +
+ ); +}); + +export namespace VimeoVideo { + export type Props = VimeoVideoProps; +} diff --git a/packages/skins/src/default/css/components/media.css b/packages/skins/src/default/css/components/media.css index 3a236e34e..9e946475d 100644 --- a/packages/skins/src/default/css/components/media.css +++ b/packages/skins/src/default/css/components/media.css @@ -3,14 +3,19 @@ ========================================================================== */ .media-default-skin ::slotted(video), -.media-default-skin video { +.media-default-skin ::slotted(vimeo-video), +.media-default-skin video, +.media-default-skin .media-vimeo { display: block; width: 100%; height: 100%; object-fit: var(--media-object-fit, contain); object-position: var(--media-object-position, center); } -.media-default-skin ::slotted(video) { +.media-default-skin ::slotted(video), +.media-default-skin ::slotted(vimeo-video), +.media-default-skin .media-vimeo { + overflow: hidden; border-radius: var(--media-video-border-radius); } .media-default-skin video { @@ -18,6 +23,8 @@ } .media-default-skin:fullscreen ::slotted(video), -.media-default-skin:fullscreen video { +.media-default-skin:fullscreen ::slotted(vimeo-video), +.media-default-skin:fullscreen video, +.media-default-skin:fullscreen .media-vimeo { object-fit: contain; } diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index 8128ac0c5..2dd014af5 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -29,6 +29,13 @@ export const root = (isShadowDOM: boolean) => '[&_video]:block [&_video]:w-full [&_video]:h-full [&_video]:rounded-[inherit] [&_video]:[object-fit:var(--media-object-fit,contain)] [&_video]:[object-position:var(--media-object-position,center)]': !isShadowDOM, }, + // Vimeo embed element + { + '[&_::slotted(vimeo-video)]:block [&_::slotted(vimeo-video)]:w-full [&_::slotted(vimeo-video)]:h-full [&_::slotted(vimeo-video)]:overflow-hidden [&_::slotted(vimeo-video)]:rounded-(--media-video-border-radius)': + isShadowDOM, + '[&_.media-vimeo]:block [&_.media-vimeo]:w-full [&_.media-vimeo]:h-full [&_.media-vimeo]:overflow-hidden [&_.media-vimeo]:rounded-(--media-video-border-radius)': + !isShadowDOM, + }, '[--media-spring-timing-function:linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]', '[--media-video-border-radius:var(--media-border-radius,2rem)]', '[--media-controls-transition-duration:100ms]', diff --git a/packages/skins/src/minimal/css/components/media.css b/packages/skins/src/minimal/css/components/media.css index 9d0fd6c06..0145c97ee 100644 --- a/packages/skins/src/minimal/css/components/media.css +++ b/packages/skins/src/minimal/css/components/media.css @@ -3,14 +3,19 @@ ========================================================================== */ .media-minimal-skin ::slotted(video), -.media-minimal-skin video { +.media-minimal-skin ::slotted(vimeo-video), +.media-minimal-skin video, +.media-minimal-skin .media-vimeo { display: block; width: 100%; height: 100%; object-fit: var(--media-object-fit, contain); object-position: var(--media-object-position, center); } -.media-minimal-skin ::slotted(video) { +.media-minimal-skin ::slotted(video), +.media-minimal-skin ::slotted(vimeo-video), +.media-minimal-skin .media-vimeo { + overflow: hidden; border-radius: var(--media-video-border-radius); } .media-minimal-skin video { @@ -18,6 +23,8 @@ } .media-minimal-skin:fullscreen ::slotted(video), -.media-minimal-skin:fullscreen video { +.media-minimal-skin:fullscreen ::slotted(vimeo-video), +.media-minimal-skin:fullscreen video, +.media-minimal-skin:fullscreen .media-vimeo { object-fit: contain; } diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index dc3d0aeec..b9e19ddd3 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -4,7 +4,7 @@ import { reset } from './reset'; export const root = cn( reset, // Layout & containment - 'block relative isolate @container/media-root', + 'block relative isolate w-full @container/media-root', // Appearance 'rounded-(--media-border-radius,0.75rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] leading-normal subpixel-antialiased', diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index 054d38e19..9d8cb10a9 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -26,6 +26,13 @@ export const root = (isShadowDOM: boolean) => '[&_video]:block [&_video]:w-full [&_video]:h-full [&_video]:rounded-[inherit] [&_video]:[object-fit:var(--media-object-fit,contain)] [&_video]:[object-position:var(--media-object-position,center)]': !isShadowDOM, }, + // Vimeo embed element + { + '[&_::slotted(vimeo-video)]:block [&_::slotted(vimeo-video)]:w-full [&_::slotted(vimeo-video)]:h-full [&_::slotted(vimeo-video)]:overflow-hidden [&_::slotted(vimeo-video)]:rounded-(--media-video-border-radius)': + isShadowDOM, + '[&_.media-vimeo]:block [&_.media-vimeo]:w-full [&_.media-vimeo]:h-full [&_.media-vimeo]:overflow-hidden [&_.media-vimeo]:rounded-(--media-video-border-radius)': + !isShadowDOM, + }, '[--media-video-border-radius:var(--media-border-radius,0.75rem)]', '[--media-controls-background-color:transparent]', '[--media-controls-transition-duration:100ms]', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee50a0361..812e9c46a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../utils + '@vimeo/player': + specifier: ^2.26.0 + version: 2.30.4 dashjs: specifier: ^5.0.0 version: 5.1.1(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1) @@ -1000,28 +1003,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.6': resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.6': resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.6': resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.6': resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==} @@ -1660,105 +1659,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2352,42 +2335,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -2472,28 +2449,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -2582,84 +2555,72 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} @@ -2762,79 +2723,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -3240,28 +3188,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -3583,6 +3527,9 @@ packages: engines: {node: '>=20'} hasBin: true + '@vimeo/player@2.30.4': + resolution: {integrity: sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg==} + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5727,56 +5674,48 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -6241,6 +6180,9 @@ packages: resolution: {integrity: sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==} engines: {node: ^20.0.0 || >=22.0.0} + native-promise-only@0.8.1: + resolution: {integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -7999,6 +7941,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + weakmap-polyfill@2.0.4: + resolution: {integrity: sha512-ZzxBf288iALJseijWelmECm/1x7ZwQn3sMYIkDr2VvZp7r6SEKuT8D0O9Wiq6L9Nl5mazrOMcmiZE/2NCenaxw==} + engines: {node: '>=8.10.0'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -11386,6 +11332,11 @@ snapshots: - rollup - supports-color + '@vimeo/player@2.30.4': + dependencies: + native-promise-only: 0.8.1 + weakmap-polyfill: 2.0.4 + '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 @@ -14745,6 +14696,8 @@ snapshots: nanostores@1.1.1: {} + native-promise-only@0.8.1: {} + neotraverse@0.6.18: {} netlify-redirector@0.5.0: {} @@ -16661,6 +16614,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + weakmap-polyfill@2.0.4: {} + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} diff --git a/site/src/utils/installation/detect-renderer.ts b/site/src/utils/installation/detect-renderer.ts index d9b8b0ec3..59df9f7f2 100644 --- a/site/src/utils/installation/detect-renderer.ts +++ b/site/src/utils/installation/detect-renderer.ts @@ -12,11 +12,11 @@ const DOMAIN_RULES: Array<{ match: (hostname: string) => boolean; renderer: Rend // renderer: 'youtube', // label: 'YouTube', // }, - // { - // match: (h) => h === 'vimeo.com' || h === 'www.vimeo.com' || h === 'player.vimeo.com', - // renderer: 'vimeo', - // label: 'Vimeo', - // }, + { + match: (h) => h === 'vimeo.com' || h === 'www.vimeo.com' || h === 'player.vimeo.com', + renderer: 'vimeo', + label: 'Vimeo', + }, // { // match: (h) => h === 'stream.mux.com' || h === 'mux.com' || h === 'www.mux.com', // renderer: 'mux-video', @@ -127,7 +127,7 @@ const RENDERER_ARTICLES: Record = { // 'mux-background-video': 'a', // 'mux-video': 'a', // spotify: 'a', - // vimeo: 'a', + vimeo: 'a', // wistia: 'a', // youtube: 'a', }; diff --git a/site/src/utils/installation/types.ts b/site/src/utils/installation/types.ts index 14f315c66..4e656b1b4 100644 --- a/site/src/utils/installation/types.ts +++ b/site/src/utils/installation/types.ts @@ -1,4 +1,4 @@ -export type Renderer = 'background-video' | 'hls' | 'html5-audio' | 'html5-video'; +export type Renderer = 'background-video' | 'hls' | 'html5-audio' | 'html5-video' | 'vimeo'; export type Skin = 'video' | 'audio' | 'minimal-video' | 'minimal-audio'; @@ -7,7 +7,7 @@ export type UseCase = 'default-video' | 'default-audio' | 'background-video'; export type InstallMethod = 'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun'; export const VALID_RENDERERS: Record = { - 'default-video': ['html5-video', 'hls'], + 'default-video': ['html5-video', 'hls', 'vimeo'], 'default-audio': ['html5-audio'], 'background-video': ['background-video'], };