From 1b31f3e8d7ecef111b84dbd3f9053efc626f256d Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 17 Jun 2026 16:36:31 -0700 Subject: [PATCH] feat(core): add vimeo media host and html/react components (#1667) --- apps/sandbox/app/constants.ts | 1 + apps/sandbox/app/shared/sources.ts | 2 + apps/sandbox/app/shell/app.tsx | 6 +- apps/sandbox/app/shell/navbar.tsx | 7 +- .../templates/html-vimeo-video/index.html | 14 + .../templates/html-vimeo-video/main.ts | 32 + .../templates/react-vimeo-video/index.html | 14 + .../templates/react-vimeo-video/main.tsx | 28 + packages/core/package.json | 1 + packages/core/src/dom/media/constants.ts | 2 + .../dom/media/custom-media-element/index.ts | 27 +- .../tests/custom-media-element.test.ts | 30 + .../dom/media/media-played-ranges/index.ts | 150 +++++ .../media-played-ranges/tests/index.test.ts | 103 +++ packages/core/src/dom/media/vimeo/index.ts | 608 ++++++++++++++++++ .../src/dom/media/vimeo/tests/index.test.ts | 356 ++++++++++ packages/core/tsdown.config.ts | 2 + packages/html/src/define/media/vimeo-video.ts | 14 + packages/html/src/media/vimeo-video/index.ts | 54 ++ .../react/src/media/vimeo-video/index.tsx | 48 ++ packages/react/src/utils/use-attach-iframe.ts | 16 + packages/utils/src/string/escape-html.ts | 9 + packages/utils/src/string/index.ts | 1 + .../src/string/tests/escape-html.test.ts | 14 + pnpm-lock.yaml | 89 +-- 25 files changed, 1551 insertions(+), 77 deletions(-) create mode 100644 apps/sandbox/templates/html-vimeo-video/index.html create mode 100644 apps/sandbox/templates/html-vimeo-video/main.ts create mode 100644 apps/sandbox/templates/react-vimeo-video/index.html create mode 100644 apps/sandbox/templates/react-vimeo-video/main.tsx create mode 100644 packages/core/src/dom/media/media-played-ranges/index.ts create mode 100644 packages/core/src/dom/media/media-played-ranges/tests/index.test.ts create mode 100644 packages/core/src/dom/media/vimeo/index.ts create mode 100644 packages/core/src/dom/media/vimeo/tests/index.test.ts create mode 100644 packages/html/src/define/media/vimeo-video.ts create mode 100644 packages/html/src/media/vimeo-video/index.ts create mode 100644 packages/react/src/media/vimeo-video/index.tsx create mode 100644 packages/react/src/utils/use-attach-iframe.ts create mode 100644 packages/utils/src/string/escape-html.ts create mode 100644 packages/utils/src/string/tests/escape-html.test.ts diff --git a/apps/sandbox/app/constants.ts b/apps/sandbox/app/constants.ts index 4ce9d883..5b7f4ae5 100644 --- a/apps/sandbox/app/constants.ts +++ b/apps/sandbox/app/constants.ts @@ -12,4 +12,5 @@ export const PRESETS = [ 'dash-video', 'audio', 'background-video', + 'vimeo-video', ] as const; diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index 11ba73cf..a9e75d59 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -79,6 +79,8 @@ export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1'; export const BACKGROUND_VIDEO_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4'; +export const VIMEO_VIDEO_SRC = 'https://vimeo.com/648359100'; + /** Returns true when the given source represents a live stream and should use the live-video skin. */ export function isLiveSource(id: SourceId): boolean { return (SOURCES[id] as { live?: boolean }).live === true; diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index 34f0826e..223424ba 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -18,6 +18,7 @@ import { Preview } from './preview'; function getPagePath(platform: Platform, preset: Preset): string { if (platform === 'cdn') return '/cdn/'; if (preset === 'background-video') return `/${platform}-background-video/`; + if (preset === 'vimeo-video') return `/${platform}-vimeo-video/`; return `/${platform}-${preset}/`; } @@ -113,9 +114,9 @@ export function App() { } }, [preset, source, setSource]); - // CDN and background video do not have a Tailwind skin variant. + // CDN, background video, and vimeo video do not have a Tailwind skin variant. useEffect(() => { - if ((platform === 'cdn' || preset === 'background-video') && styling === 'tailwind') { + if ((platform === 'cdn' || preset === 'background-video' || preset === 'vimeo-video') && styling === 'tailwind') { setStyling('css'); } }, [platform, preset, styling]); @@ -151,6 +152,7 @@ export function App() { isSimpleHls={preset.startsWith('simple-hls-')} 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 f8090f29..1d09558c 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -28,6 +28,7 @@ type NavbarProps = { isSimpleHls: boolean; isMuxVideo: boolean; isMuxAudio: boolean; + isVimeoVideo: boolean; platforms: readonly Platform[]; stylings: readonly Styling[]; presets: readonly Preset[]; @@ -53,6 +54,7 @@ const PRESET_LABELS: Record = { 'dash-video': 'DASH Video', audio: 'Audio', 'background-video': 'Background Video', + 'vimeo-video': 'Vimeo Video', }; export function Navbar({ @@ -79,6 +81,7 @@ export function Navbar({ isSimpleHls, isMuxVideo, isMuxAudio, + isVimeoVideo, platforms, stylings, presets, @@ -107,7 +110,7 @@ export function Navbar({ options={stylings.map((s) => ({ value: s, label: s === 'css' ? 'CSS' : 'Tailwind', - disabled: s === 'tailwind' && (isBackgroundVideo || platform === 'cdn'), + disabled: s === 'tailwind' && (isBackgroundVideo || isVimeoVideo || platform === 'cdn'), }))} /> @@ -137,7 +140,7 @@ export function Navbar({ return true; }) .map((id) => ({ value: id, label: sources[id].label }))} - disabled={isBackgroundVideo} + disabled={isBackgroundVideo || isVimeoVideo} /> 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 00000000..ef278e8a --- /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 00000000..2ededfea --- /dev/null +++ b/apps/sandbox/templates/html-vimeo-video/main.ts @@ -0,0 +1,32 @@ +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 { onSkinChange } from '@app/shared/sandbox-listener'; +import { VIMEO_VIDEO_SRC } from '@app/shared/sources'; + +const html = String.raw; + +const state = createHtmlSandboxState(); +const loadLatest = createLatestLoader(); + +async function render() { + const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling)); + if (!tag) return; + + document.getElementById('root')!.innerHTML = html` + + <${tag} class="aspect-video max-w-4xl mx-auto"> + + + + `; +} + +render(); + +onSkinChange((skin) => { + state.skin = skin; + 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 00000000..e1c632a2 --- /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 00000000..7ed4aa6c --- /dev/null +++ b/apps/sandbox/templates/react-vimeo-video/main.tsx @@ -0,0 +1,28 @@ +import '@app/styles.css'; +import { VideoProvider } from '@app/shared/react/providers'; +import { VideoSkinComponent } from '@app/shared/react/skins'; +import { useSkin } from '@app/shared/react/use-skin'; +import { VIMEO_VIDEO_SRC } 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 styling = useMemo(readStyling, []); + + return ( + + + + + + ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/core/package.json b/packages/core/package.json index e18bc9f4..a22eaa05 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.30.3", "dashjs": "^5.0.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" diff --git a/packages/core/src/dom/media/constants.ts b/packages/core/src/dom/media/constants.ts index 56acac56..cb92e46a 100644 --- a/packages/core/src/dom/media/constants.ts +++ b/packages/core/src/dom/media/constants.ts @@ -1,11 +1,13 @@ import type { RemotePlaybackLike, TextTrackListLike, TimeRangeLike } from '../../core/media/types'; +/** A frozen, empty `TimeRanges`-like value for hosts with no ranges. */ export const EMPTY_TIME_RANGES: TimeRangeLike = Object.freeze({ length: 0, start: () => 0, end: () => 0, }); +/** A frozen, empty `TextTrackList`-like value for hosts with no text tracks. */ export const EMPTY_TEXT_TRACKS: TextTrackListLike = Object.assign(new EventTarget(), { length: 0, *[Symbol.iterator]() {}, diff --git a/packages/core/src/dom/media/custom-media-element/index.ts b/packages/core/src/dom/media/custom-media-element/index.ts index d25ab0fa..f3fccc89 100644 --- a/packages/core/src/dom/media/custom-media-element/index.ts +++ b/packages/core/src/dom/media/custom-media-element/index.ts @@ -102,6 +102,10 @@ export function CustomMediaElement>( tag: string, MediaHost: T ): CustomMediaConstructor { + // Embed hosts (iframe) drive an external player rather than a native media + // element, so attribute changes are not mirrored onto the iframe target and + // there is no `` / `` syncing. + const syncTargetAttributes = tag !== 'iframe'; const mediaHostAttrToProp = new Map(); let isDefined = false; @@ -229,10 +233,9 @@ export function CustomMediaElement>( const allowedKeys = getAttrsFromProps(ctor.properties); const disallowedKeys = [...mediaHostAttrToProp.keys()]; - const attrs: Record = omit( - pick(namedNodeMapToObject(this.attributes), allowedKeys), - disallowedKeys - ); + const pickedAttrs = pick(namedNodeMapToObject(this.attributes), allowedKeys); + // Embed templates (iframe) need host-bound attrs (e.g. `src`) to build the initial URL. + const attrs: Record = syncTargetAttributes ? omit(pickedAttrs, disallowedKeys) : pickedAttrs; if (tag && !attrs.part) attrs.part = tag; this.shadowRoot!.innerHTML = ctor.getTemplateHTML(attrs); } @@ -260,7 +263,7 @@ export function CustomMediaElement>( return this.#mediaHost; } - get target(): HTMLVideoElement | HTMLAudioElement | null { + get target(): HTMLElement | null { return ( this.querySelector(':scope > [slot=media]') ?? this.querySelector(tag) ?? @@ -269,7 +272,15 @@ export function CustomMediaElement>( ); } - disconnectedCallback(): void { + connectedCallback() { + if (tag !== 'iframe') return; + // Add data attribute for styling and avoiding cross-origin issues. e.g. backdrop-filter + if (!this.hasAttribute('data-cross-origin-frame')) { + this.setAttribute('data-cross-origin-frame', ''); + } + } + + disconnectedCallback() { if (this.hasAttribute('keep-alive')) return; // Defer so a synchronous reparent (remove + insert) doesn't tear down // the host and its registered components. @@ -328,6 +339,8 @@ export function CustomMediaElement>( return; } + if (!syncTargetAttributes) return; + if (newValue === null) { this.target?.removeAttribute(attrName); } else if (this.target?.getAttribute(attrName) !== newValue) { @@ -336,6 +349,8 @@ export function CustomMediaElement>( } #syncMediaChildren(): void { + if (tag === 'iframe') return; + const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement; const mediaChildren = new Set( defaultSlot diff --git a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts index 53166fb7..6b9d430c 100644 --- a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts +++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts @@ -73,6 +73,17 @@ class TestAudioHost extends HTMLAudioElementHost { destroy() {} } +class TestIframeHost extends EventTarget { + target: EventTarget | null = null; + attach(target: EventTarget | null) { + this.target = target; + } + detach() { + this.target = null; + } + destroy() {} +} + let tagCounter = 0; function defineVideoElement() { @@ -96,6 +107,13 @@ function defineAudioElement() { return { Ctor, tag }; } +function defineIframeElement() { + const tag = `test-iframe-${++tagCounter}`; + const Ctor = CustomMediaElement('iframe', TestIframeHost as never); + customElements.define(tag, Ctor); + return { Ctor, tag }; +} + function create(def: { Ctor: new () => any; tag: string }) { const el = new def.Ctor(); document.body.appendChild(el); @@ -681,6 +699,18 @@ describe('CustomMediaElement', () => { }); }); + describe('connectedCallback', () => { + it('marks iframe embeds with data-cross-origin-frame for cross-origin-safe styling', () => { + const el = create(defineIframeElement()); + expect(el.hasAttribute('data-cross-origin-frame')).toBe(true); + }); + + it('does not add data-cross-origin-frame to native media elements', () => { + const el = create(defineVideoElement()); + expect(el.hasAttribute('data-cross-origin-frame')).toBe(false); + }); + }); + describe('disconnectedCallback', () => { it('calls destroy on the MediaHost when disconnected', async () => { const el = create(defineVideoElement()); diff --git a/packages/core/src/dom/media/media-played-ranges/index.ts b/packages/core/src/dom/media/media-played-ranges/index.ts new file mode 100644 index 00000000..062e17f9 --- /dev/null +++ b/packages/core/src/dom/media/media-played-ranges/index.ts @@ -0,0 +1,150 @@ +// Adapted from `media-played-ranges-mixin@0.1.0` from `muxinc/media-elements`, +// ported to TypeScript and reshaped as a class mixin to fit the v10 media-host +// architecture. +// +// Source: https://github.com/muxinc/media-elements +// License: MIT + +import { isNumber } from '@videojs/utils/predicate'; +import type { Constructor, MixinReturn } from '@videojs/utils/types'; +import type { TimeRangeLike } from '../../../core/media/types'; + +export interface PlayedRange { + start: number; + end: number; +} + +/** Surface a media host must expose for played-range tracking. */ +export interface MediaPlayedRangesHost extends EventTarget { + currentTime: number; + paused: boolean; +} + +/** Public surface contributed by {@link MediaPlayedRangesMixin}. */ +export interface MediaPlayedRangesAPI { + /** `TimeRanges`-like view of the ranges the user has actually played. */ + readonly played: TimeRangeLike; + destroy(): void; +} + +/** + * Mixin that tracks played ranges for media hosts lacking a native + * `HTMLMediaElement.played` (e.g. iframe-based embeds like Vimeo). + * + * Listens for standard media events the host dispatches on itself + * (`play`, `pause`, `ended`, `seeking`, `seeked`) and derives a + * `TimeRanges`-like `played` value from the host's `currentTime` / `paused`. + * + * @example + * class VimeoMedia extends MediaPlayedRangesMixin(EventTarget) { ... } + */ +export function MediaPlayedRangesMixin>( + BaseClass: Base +): MixinReturn { + class MediaPlayedRanges extends BaseClass { + #playedRanges: PlayedRange[] = []; + #currentPlayedRange: PlayedRange | null = null; + #rangeEpsilon = 0.5; + #disconnect = new AbortController(); + + constructor(...args: any[]) { + super(...args); + + const options = { signal: this.#disconnect.signal }; + this.addEventListener('play', () => this.#onPlaybackStart(this.#currentTime), options); + this.addEventListener('pause', () => this.#onPlaybackStop(this.#currentTime), options); + this.addEventListener('ended', () => this.#onPlaybackStop(this.#currentTime), options); + this.addEventListener('seeking', () => this.#commitCurrentRange(), options); + this.addEventListener('seeked', () => this.#onSeeked(this.#currentTime), options); + } + + /** The host (subclass) supplies `currentTime` / `paused`. */ + get #host(): MediaPlayedRangesHost { + return this as unknown as MediaPlayedRangesHost; + } + + get #currentTime(): number { + return this.#host.currentTime; + } + + get played(): TimeRangeLike { + const time = this.#currentTime; + if (!this.#host.paused && !this.#currentPlayedRange && isNumber(time)) { + this.#currentPlayedRange = { start: time, end: time }; + } + if (this.#currentPlayedRange && isNumber(time)) { + if (time > this.#currentPlayedRange.end) { + this.#currentPlayedRange.end = time; + } + this.#addPlayedRange(this.#currentPlayedRange.start, this.#currentPlayedRange.end); + } + if (!this.#playedRanges.length) { + return createTimeRanges([[0, 0]]); + } + return createTimeRanges(this.#playedRanges.map((r) => [r.start, r.end])); + } + + destroy(): void { + this.#disconnect.abort(); + super.destroy?.(); + } + + #onPlaybackStart(time: number): void { + const t = isNumber(time) ? time : this.#currentTime; + if (!this.#currentPlayedRange) { + this.#currentPlayedRange = { start: t, end: t }; + } + } + + #onSeeked(time: number): void { + const t = isNumber(time) ? time : this.#currentTime; + this.#currentPlayedRange = { start: t, end: t }; + } + + #onPlaybackStop(time: number): void { + const t = isNumber(time) ? time : this.#currentTime; + this.#commitCurrentRange(t); + } + + #commitCurrentRange(time?: number): void { + if (!this.#currentPlayedRange) return; + if (isNumber(time)) { + this.#currentPlayedRange.end = time; + } + const { start, end } = this.#currentPlayedRange; + this.#currentPlayedRange = null; + this.#addPlayedRange(start, end); + } + + #addPlayedRange(start: number, end: number): void { + if (start >= end) return; + const allRanges: PlayedRange[] = [...this.#playedRanges, { start, end }]; + allRanges.sort((a, b) => a.start - b.start); + const merged: PlayedRange[] = []; + for (const range of allRanges) { + const last = merged.length ? merged[merged.length - 1] : null; + if (!last) { + merged.push({ ...range }); + continue; + } + if (range.start <= last.end + this.#rangeEpsilon) { + last.start = Math.min(last.start, range.start); + last.end = Math.max(last.end, range.end); + } else { + merged.push({ ...range }); + } + } + this.#playedRanges = merged; + } + } + + return MediaPlayedRanges as unknown as MixinReturn; +} + +function createTimeRanges(ranges: number[][]): TimeRangeLike { + Object.defineProperties(ranges, { + start: { value: (i: number) => ranges[i]?.[0] ?? 0 }, + end: { value: (i: number) => ranges[i]?.[1] ?? 0 }, + }); + return ranges as unknown as TimeRangeLike; +} diff --git a/packages/core/src/dom/media/media-played-ranges/tests/index.test.ts b/packages/core/src/dom/media/media-played-ranges/tests/index.test.ts new file mode 100644 index 00000000..c308f1d7 --- /dev/null +++ b/packages/core/src/dom/media/media-played-ranges/tests/index.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { MediaPlayedRangesMixin } from '..'; + +class FakeMedia extends MediaPlayedRangesMixin(EventTarget) { + currentTime = 0; + paused = true; + + simulatePlay(time: number): void { + this.paused = false; + this.currentTime = time; + this.dispatchEvent(new Event('play')); + } + + tick(time: number): void { + this.currentTime = time; + } + + simulatePause(time: number): void { + this.currentTime = time; + this.paused = true; + this.dispatchEvent(new Event('pause')); + } + + seek(target: number): void { + this.dispatchEvent(new Event('seeking')); + this.currentTime = target; + this.dispatchEvent(new Event('seeked')); + } + + end(time: number): void { + this.currentTime = time; + this.paused = true; + this.dispatchEvent(new Event('ended')); + } +} + +describe('MediaPlayedRangesMixin', () => { + it('starts with a single empty range', () => { + const media = new FakeMedia(); + expect(media.played.length).toBe(1); + expect(media.played.start(0)).toBe(0); + expect(media.played.end(0)).toBe(0); + }); + + it('tracks a contiguous play segment', () => { + const media = new FakeMedia(); + media.simulatePlay(0); + media.tick(5); + media.simulatePause(5); + + const played = media.played; + expect(played.length).toBe(1); + expect(played.start(0)).toBe(0); + expect(played.end(0)).toBe(5); + }); + + it('merges adjacent ranges within the epsilon tolerance', () => { + const media = new FakeMedia(); + media.simulatePlay(0); + media.simulatePause(5); + media.simulatePlay(5.2); + media.simulatePause(8); + + const played = media.played; + expect(played.length).toBe(1); + expect(played.end(0)).toBe(8); + }); + + it('keeps non-adjacent ranges separate', () => { + const media = new FakeMedia(); + media.simulatePlay(0); + media.simulatePause(2); + media.seek(20); + media.simulatePlay(20); + media.simulatePause(25); + + const played = media.played; + expect(played.length).toBe(2); + expect(played.start(0)).toBe(0); + expect(played.end(0)).toBe(2); + expect(played.start(1)).toBe(20); + expect(played.end(1)).toBe(25); + }); + + it('commits a range on ended', () => { + const media = new FakeMedia(); + media.simulatePlay(0); + media.tick(10); + media.end(10); + expect(media.played.end(0)).toBe(10); + }); + + it('stops tracking after destroy', () => { + const media = new FakeMedia(); + media.destroy(); + media.simulatePlay(0); + media.tick(5); + media.simulatePause(5); + // Listeners were removed on destroy, so no range was recorded. + expect(media.played.length).toBe(1); + expect(media.played.end(0)).toBe(0); + }); +}); 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 00000000..3466fadb --- /dev/null +++ b/packages/core/src/dom/media/vimeo/index.ts @@ -0,0 +1,608 @@ +import { isNull, isString, isUndefined } from '@videojs/utils/predicate'; +import VimeoPlayer, { type LoadVideoOptions, type VimeoEmbedParameters, type VimeoUrl } from '@vimeo/player'; +import type { ErrorLike, MediaPreloadType, TextTrackListLike, Video } from '../../../core/media/types'; +import { EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from '../constants'; +import { MediaPlayedRangesMixin } from '../media-played-ranges'; + +export type { default as VimeoPlayerApi } from '@vimeo/player'; + +/** Public Vimeo embed configuration. Forwarded to `@vimeo/player`. */ +export interface VimeoConfig extends VimeoEmbedParameters { + referrerPolicy?: ReferrerPolicy; +} + +/** Parsed pieces of a Vimeo source URL. */ +export interface VimeoSource { + id: number; + /** `'video'` for regular clips, `'event'` for live events. */ + kind: 'video' | 'event'; + /** Unlisted-video / event hash (the `h` parameter). */ + hash: string | null; +} + +export interface VimeoMediaProps { + src: string; + autoplay: boolean; + defaultMuted: boolean; + muted: boolean; + loop: boolean; + controls: boolean; + playsInline: boolean; + preload: MediaPreloadType; + poster: string; + config: VimeoConfig; +} + +export const vimeoMediaDefaultProps: VimeoMediaProps = { + src: '', + autoplay: false, + defaultMuted: false, + muted: false, + loop: false, + controls: false, + playsInline: true, + preload: 'metadata', + poster: '', + config: {}, +}; + +export class VimeoMedia extends MediaPlayedRangesMixin(EventTarget) implements Partial