feat(core): add vimeo media host and html/react components (#1667)

This commit is contained in:
Wesley Luyten
2026-06-18 09:36:31 +10:00
committed by GitHub
parent 035b509c7a
commit 1b31f3e8d7
25 changed files with 1551 additions and 77 deletions
+2
View File
@@ -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]() {},
@@ -102,6 +102,10 @@ export function CustomMediaElement<T extends Constructor<MediaHost>>(
tag: string,
MediaHost: T
): CustomMediaConstructor<T> {
// 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 `<track>` / `<source>` syncing.
const syncTargetAttributes = tag !== 'iframe';
const mediaHostAttrToProp = new Map<string, string>();
let isDefined = false;
@@ -229,10 +233,9 @@ export function CustomMediaElement<T extends Constructor<MediaHost>>(
const allowedKeys = getAttrsFromProps(ctor.properties);
const disallowedKeys = [...mediaHostAttrToProp.keys()];
const attrs: Record<string, string> = 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<string, string> = 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<T extends Constructor<MediaHost>>(
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<T extends Constructor<MediaHost>>(
);
}
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<T extends Constructor<MediaHost>>(
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<T extends Constructor<MediaHost>>(
}
#syncMediaChildren(): void {
if (tag === 'iframe') return;
const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement;
const mediaChildren = new Set(
defaultSlot
@@ -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());
@@ -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<Base extends Constructor<EventTarget & { destroy?(): void }>>(
BaseClass: Base
): MixinReturn<Base, MediaPlayedRangesAPI> {
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<Base, MediaPlayedRangesAPI>;
}
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;
}
@@ -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);
});
});
+608
View File
@@ -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<Video> {
#target: HTMLIFrameElement | null = null;
#player: VimeoPlayer | null = null;
#loadComplete = createPublicPromise<void>();
#src = vimeoMediaDefaultProps.src;
#autoplay = vimeoMediaDefaultProps.autoplay;
#defaultMuted = vimeoMediaDefaultProps.defaultMuted;
#loop = vimeoMediaDefaultProps.loop;
#controls = vimeoMediaDefaultProps.controls;
#playsInline = vimeoMediaDefaultProps.playsInline;
#preload = vimeoMediaDefaultProps.preload;
#poster = vimeoMediaDefaultProps.poster;
#config = vimeoMediaDefaultProps.config;
#paused = true;
#ended = false;
#seeking = false;
#currentTime = 0;
#duration = Number.NaN;
#volume = 1;
#muted = false;
#playbackRate = 1;
#progress = 0;
#videoWidth = Number.NaN;
#videoHeight = Number.NaN;
#readyState = READY_STATE_HAVE_NOTHING;
#error: ErrorLike | null = null;
#isFullscreen = false;
#isPictureInPicture = false;
#disablePictureInPicture = false;
#textTracksHost: HTMLVideoElement | null = null;
#textTracksDisconnect: AbortController | null = null;
static PLAYER_SOFTWARE_NAME = 'vimeo-video';
/** Underlying `@vimeo/player` instance (null before attach). */
get engine() {
return this.#player;
}
get target(): HTMLIFrameElement | null {
return this.#target;
}
/** Bind the iframe hosting the embed, creating a `@vimeo/player` instance. */
attach(target: HTMLIFrameElement | null): void {
if (!target || this.#target === target) return;
if (this.#target) this.detach();
this.#target = target;
if (!target.src) {
const initialSrc = buildVimeoIframeSrc(this.#src, this.#snapshotProps());
if (initialSrc) target.src = initialSrc;
}
this.#loadComplete = createPublicPromise<void>();
this.#player = new VimeoPlayer(target);
this.#bindPlayerEvents(this.#player);
this.#setupTextTracks(this.#player);
this.dispatchEvent(new Event('loadstart'));
}
detach(): void {
if (!this.#target) return;
this.#teardownTextTracks();
this.#player?.destroy().catch(() => {});
this.#player = null;
this.#target = null;
this.#resetState();
}
override destroy() {
this.detach();
super.destroy();
}
get src() {
return this.#src;
}
set src(value) {
if (this.#src === value) return;
this.#src = value;
void this.load();
}
get currentSrc() {
return this.#target?.src ?? '';
}
get readyState() {
return this.#readyState;
}
/** Reload the current source via Vimeo's `loadVideo`; no-op until `attach()`. */
async load() {
if (!this.#player || !this.#src) return;
this.#resetState();
this.#loadComplete = createPublicPromise<void>();
this.dispatchEvent(new Event('emptied'));
this.dispatchEvent(new Event('loadstart'));
const loadOptions = toLoadVideoOptions(this.#src, this.#config);
if (!loadOptions) return;
// Vimeo dispatches an `error` event separately on failure.
await this.#player.loadVideo(loadOptions).catch(() => {});
}
get paused() {
return this.#paused;
}
get ended() {
return this.#ended;
}
get seeking() {
return this.#seeking;
}
async play() {
await this.#loadComplete;
await this.#player?.play();
}
pause() {
void this.#player?.pause().catch(() => {});
}
get currentTime() {
return this.#currentTime;
}
set currentTime(value) {
if (this.#currentTime === value) return;
this.#currentTime = value;
this.#afterLoad((p) => p.setCurrentTime(value));
}
get duration() {
return this.#duration;
}
get volume() {
return this.#volume;
}
set volume(value) {
if (this.#volume === value) return;
this.#volume = value;
this.#afterLoad((p) => p.setVolume(value));
}
get muted() {
return this.#muted;
}
set muted(value) {
if (this.#muted === value) return;
this.#muted = value;
this.#afterLoad((p) => p.setMuted(value));
}
get playbackRate() {
return this.#playbackRate;
}
set playbackRate(value) {
if (this.#playbackRate === value) return;
this.#playbackRate = value;
this.#afterLoad((p) => p.setPlaybackRate(value));
}
get autoplay() {
return this.#autoplay;
}
set autoplay(value) {
this.#autoplay = value;
}
get defaultMuted() {
return this.#defaultMuted;
}
set defaultMuted(value) {
this.#defaultMuted = value;
}
get loop() {
return this.#loop;
}
set loop(value) {
this.#loop = value;
this.#afterLoad((p) => p.setLoop(value));
}
get controls() {
return this.#controls;
}
set controls(value) {
this.#controls = value;
}
get playsInline() {
return this.#playsInline;
}
set playsInline(value) {
this.#playsInline = value;
}
get preload() {
return this.#preload;
}
set preload(value) {
this.#preload = value;
}
get poster() {
return this.#poster;
}
set poster(value) {
this.#poster = value;
}
get config() {
return this.#config as Record<string, unknown>;
}
set config(value) {
this.#config = value as VimeoConfig;
}
get buffered() {
return this.#progress > 0 ? createTimeRanges(0, this.#progress) : EMPTY_TIME_RANGES;
}
get seekable() {
return this.#duration > 0 && Number.isFinite(this.#duration)
? createTimeRanges(0, this.#duration)
: EMPTY_TIME_RANGES;
}
get error() {
return this.#error;
}
get textTracks() {
this.#textTracksHost ??= globalThis.document?.createElement('video') ?? null;
return (this.#textTracksHost?.textTracks as TextTrackListLike) ?? EMPTY_TEXT_TRACKS;
}
get videoWidth() {
return this.#videoWidth;
}
get videoHeight() {
return this.#videoHeight;
}
get isFullscreen() {
return this.#isFullscreen;
}
async requestFullscreen() {
await this.#loadComplete;
await this.#player?.requestFullscreen?.();
this.#isFullscreen = true;
}
async exitFullscreen() {
await this.#loadComplete;
await this.#player?.exitFullscreen?.();
this.#isFullscreen = false;
}
get isPictureInPicture() {
return this.#isPictureInPicture;
}
get disablePictureInPicture() {
return this.#disablePictureInPicture;
}
set disablePictureInPicture(value) {
this.#disablePictureInPicture = value;
}
async requestPictureInPicture() {
await this.#loadComplete;
await this.#player?.requestPictureInPicture?.().then(() => {
this.#isPictureInPicture = true;
}, console.error);
}
async exitPictureInPicture() {
await this.#loadComplete;
await this.#player?.exitPictureInPicture?.().then(() => {
this.#isPictureInPicture = false;
}, console.error);
}
/** Defer a player call until `loadComplete` resolves, swallowing rejections. */
#afterLoad(fn: (player: VimeoPlayer) => Promise<unknown>) {
this.#loadComplete.then(
() => this.#player && void fn(this.#player).catch(() => {}),
() => {}
);
}
#snapshotProps() {
return {
autoplay: this.#autoplay,
defaultMuted: this.#defaultMuted,
loop: this.#loop,
controls: this.#controls,
playsInline: this.#playsInline,
preload: this.#preload || vimeoMediaDefaultProps.preload,
config: this.#config,
};
}
#resetState() {
this.#currentTime = 0;
this.#duration = Number.NaN;
this.#muted = false;
this.#paused = !this.#autoplay;
this.#ended = false;
this.#playbackRate = 1;
this.#progress = 0;
this.#readyState = READY_STATE_HAVE_NOTHING;
this.#seeking = false;
this.#volume = 1;
this.#error = null;
this.#videoWidth = Number.NaN;
this.#videoHeight = Number.NaN;
this.#isFullscreen = false;
this.#isPictureInPicture = false;
}
async #onLoaded() {
this.#readyState = READY_STATE_HAVE_METADATA;
const player = this.#player;
if (player) {
// Each value falls back to the current one so a single failure isn't fatal.
const [muted, volume, duration] = await Promise.all([
player.getMuted().catch(() => this.#muted),
player.getVolume().catch(() => this.#volume),
player.getDuration().catch(() => this.#duration),
]);
this.#muted = muted;
this.#volume = volume;
this.#duration = duration;
}
for (const type of ['loadedmetadata', 'durationchange', 'volumechange', 'loadcomplete']) {
this.dispatchEvent(new Event(type));
}
this.#loadComplete.resolve();
}
#bindPlayerEvents(player: VimeoPlayer) {
const emit = (type: string) => this.dispatchEvent(new Event(type));
player.on('loaded', () => this.#onLoaded());
player.on('bufferstart', () => emit('waiting'));
player.on('play', () => {
this.#paused = false;
emit('play');
});
player.on('playing', () => {
this.#readyState = READY_STATE_HAVE_FUTURE_DATA;
this.#paused = false;
emit('playing');
});
player.on('seeking', () => {
this.#seeking = true;
emit('seeking');
});
player.on('seeked', () => {
this.#seeking = false;
emit('seeked');
});
player.on('pause', () => {
this.#paused = true;
emit('pause');
});
player.on('ended', () => {
this.#paused = true;
this.#ended = true;
emit('ended');
});
player.on('playbackratechange', ({ playbackRate }) => {
this.#playbackRate = playbackRate;
emit('ratechange');
});
player.on('volumechange', ({ volume }) => {
this.#volume = volume;
emit('volumechange');
});
player.on('durationchange', ({ duration }) => {
this.#duration = duration;
emit('durationchange');
});
player.on('timeupdate', ({ seconds, duration }) => {
this.#currentTime = seconds;
if (Number.isFinite(duration) && duration !== this.#duration) this.#duration = duration;
emit('timeupdate');
});
player.on('progress', ({ seconds }) => {
this.#progress = seconds;
emit('progress');
});
player.on('resize', ({ videoWidth, videoHeight }) => {
this.#videoWidth = videoWidth;
this.#videoHeight = videoHeight;
emit('resize');
});
player.on('fullscreenchange', ({ fullscreen }) => {
this.#isFullscreen = fullscreen;
emit('fullscreenchange');
});
player.on('enterpictureinpicture', () => {
this.#isPictureInPicture = true;
emit('enterpictureinpicture');
});
player.on('leavepictureinpicture', () => {
this.#isPictureInPicture = false;
emit('leavepictureinpicture');
});
player.on('error', () => {
this.#error = { code: 1, message: 'Vimeo playback error' };
emit('error');
// Unblock callers awaiting load so play()/fullscreen/PiP don't hang.
this.#loadComplete.resolve();
});
}
#setupTextTracks(player: VimeoPlayer) {
const doc = globalThis.document;
if (isUndefined(doc)) return;
this.#teardownTextTracks();
const host = doc.createElement('video');
this.#textTracksHost = host;
player
.getTextTracks()
.then((tracks) => {
for (const track of tracks) {
if (!isString(track.kind) || isNull(track.kind)) continue;
try {
host.addTextTrack?.(track.kind as TextTrackKind, track.label ?? '', track.language ?? '');
} catch {
// jsdom or unsupported environments.
}
}
})
.catch(() => {});
this.#textTracksDisconnect = new AbortController();
host.textTracks?.addEventListener?.(
'change',
() => {
const showing = Array.from(host.textTracks).find((t) => t.mode === 'showing');
if (showing) player.enableTextTrack(showing.language, showing.kind).catch(() => {});
else player.disableTextTrack().catch(() => {});
},
{ signal: this.#textTracksDisconnect.signal }
);
}
#teardownTextTracks() {
this.#textTracksDisconnect?.abort();
this.#textTracksDisconnect = null;
this.#textTracksHost = null;
}
}
/** Extract a Vimeo video id from a numeric id, vimeo.com URL, or player URL. */
export function parseVimeoVideoId(src: string) {
return parseVimeoSource(src)?.id ?? null;
}
/**
* Parse a Vimeo source string. Recognizes numeric ids, `vimeo.com/<id>`,
* `vimeo.com/video/<id>`, `player.vimeo.com/video/<id>`, `vimeo.com/event/<id>`
* (live events), and unlisted/event hashes via `?h=` or a `/<hash>` segment.
*/
export function parseVimeoSource(src: string): VimeoSource | null {
if (!src) return null;
if (/^\d+$/.test(src)) return { id: Number(src), kind: 'video', hash: null };
const match = MATCH_SRC.exec(src);
if (!match) return null;
const kind = match[1] === 'event/' ? 'event' : 'video';
let queryHash: string | null = null;
try {
queryHash = new URL(src).searchParams.get('h');
} catch {
// src isn't a valid URL — ignore.
}
return { id: Number(match[2]), kind, hash: queryHash ?? match[3] ?? null };
}
/** Build the iframe `src` URL for an initial Vimeo embed from the given props. */
export function buildVimeoIframeSrc(src: string, props: Partial<VimeoMediaProps> = {}) {
const parsed = parseVimeoSource(src);
if (!parsed) return '';
const params: Record<string, unknown> = {
// Hide Vimeo chrome by default; pass nothing only when controls is explicitly true.
controls: props.controls === true ? null : 0,
autoplay: props.autoplay,
loop: props.loop,
muted: props.defaultMuted,
playsinline: props.playsInline ?? vimeoMediaDefaultProps.playsInline,
preload: props.preload ?? vimeoMediaDefaultProps.preload,
transparent: false,
h: parsed.hash,
// Vimeo-specific knobs (`autopause`, `byline`, `dnt`, …) flow through here.
...(props.config ?? undefined),
};
if (parsed.kind === 'event') {
const hashPath = parsed.hash ? `/${parsed.hash}` : '';
delete params.h;
return `${EMBED_EVENT_BASE}/${parsed.id}/embed${hashPath}?${serialize(params)}`;
}
return `${EMBED_VIDEO_BASE}/${parsed.id}?${serialize(params)}`;
}
const EMBED_VIDEO_BASE = 'https://player.vimeo.com/video';
const EMBED_EVENT_BASE = 'https://vimeo.com/event';
const MATCH_SRC = /vimeo\.com\/(video\/|event\/)?(\d+)(?:\/([\w-]+))?/;
const READY_STATE_HAVE_NOTHING = 0;
const READY_STATE_HAVE_METADATA = 1;
const READY_STATE_HAVE_FUTURE_DATA = 3;
function createTimeRanges(start: number, end: number) {
return { length: 1, start: () => start, end: () => end };
}
function toLoadVideoOptions(src: string, config: VimeoConfig) {
const parsed = parseVimeoSource(src);
if (!parsed) return null;
const base = parsed.kind === 'event' ? `${EMBED_EVENT_BASE}/${parsed.id}/embed` : `${EMBED_VIDEO_BASE}/${parsed.id}`;
const url = `${base}${parsed.hash ? `?h=${parsed.hash}` : ''}` as VimeoUrl;
return { url, ...config } as LoadVideoOptions;
}
function serialize(props: Record<string, unknown>) {
const params = new URLSearchParams();
for (const key in props) {
const val = props[key];
if (val === true || val === '') params.set(key, '1');
else if (val === false) params.set(key, '0');
else if (val != null) params.set(key, String(val));
}
return params.toString();
}
interface PublicPromise<T> extends Promise<T> {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createPublicPromise<T>(): PublicPromise<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
}) as PublicPromise<T>;
promise.resolve = resolve;
promise.reject = reject;
return promise;
}
@@ -0,0 +1,356 @@
import { describe, expect, it, vi } from 'vitest';
import { buildVimeoIframeSrc, parseVimeoSource, parseVimeoVideoId, VimeoMedia, vimeoMediaDefaultProps } from '..';
vi.mock('@vimeo/player', () => {
class MockPlayer {
static instances: MockPlayer[] = [];
target: unknown;
handlers = new Map<string, Set<(data: unknown) => void>>();
destroyed = false;
play = vi.fn(async () => {});
pause = vi.fn(async () => {});
setVolume = vi.fn(async (v: number) => v);
setMuted = vi.fn(async (v: boolean) => v);
setCurrentTime = vi.fn(async (s: number) => s);
setPlaybackRate = vi.fn(async (r: number) => r);
setLoop = vi.fn(async (v: boolean) => v);
loadVideo = vi.fn(async () => {});
unload = vi.fn(async () => {});
requestFullscreen = vi.fn(async () => {});
exitFullscreen = vi.fn(async () => {});
requestPictureInPicture = vi.fn(async () => {});
exitPictureInPicture = vi.fn(async () => {});
enableTextTrack = vi.fn(async () => {});
disableTextTrack = vi.fn(async () => {});
getMuted = vi.fn(async () => false);
getVolume = vi.fn(async () => 1);
getDuration = vi.fn(async () => 60);
getCurrentTime = vi.fn(async () => 0);
getTextTracks = vi.fn(async () => [] as unknown[]);
destroy = vi.fn(async () => {
this.destroyed = true;
});
constructor(target: unknown) {
this.target = target;
MockPlayer.instances.push(this);
}
on(event: string, handler: (data: unknown) => void): void {
let set = this.handlers.get(event);
if (!set) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
}
off(event: string, handler?: (data: unknown) => void): void {
const set = this.handlers.get(event);
if (!set) return;
if (handler) set.delete(handler);
else set.clear();
}
emit(event: string, data: unknown = {}): void {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
}
return { default: MockPlayer };
});
function createIframe(): HTMLIFrameElement {
return document.createElement('iframe');
}
async function waitForVimeoLoaded(media: VimeoMedia): Promise<void> {
if (media.readyState >= 1 && Number.isFinite(media.duration)) return;
await new Promise<void>((resolve) => {
media.addEventListener('loadcomplete', () => resolve(), { once: true });
});
}
async function attachAndLoad(media: VimeoMedia): Promise<{ iframe: HTMLIFrameElement; player: MockPlayerLike }> {
const iframe = createIframe();
media.attach(iframe);
const player = media.engine as unknown as MockPlayerLike;
player.emit('loaded');
await waitForVimeoLoaded(media);
return { iframe, player };
}
interface MockPlayerLike {
emit(event: string, data?: unknown): void;
play: ReturnType<typeof vi.fn>;
pause: ReturnType<typeof vi.fn>;
setVolume: ReturnType<typeof vi.fn>;
setMuted: ReturnType<typeof vi.fn>;
setCurrentTime: ReturnType<typeof vi.fn>;
setPlaybackRate: ReturnType<typeof vi.fn>;
setLoop: ReturnType<typeof vi.fn>;
loadVideo: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
}
describe('parseVimeoVideoId', () => {
it('extracts numeric id from numeric string', () => {
expect(parseVimeoVideoId('76979871')).toBe(76979871);
});
it('extracts id from vimeo.com URL', () => {
expect(parseVimeoVideoId('https://vimeo.com/76979871')).toBe(76979871);
});
it('extracts id from player.vimeo.com URL', () => {
expect(parseVimeoVideoId('https://player.vimeo.com/video/76979871')).toBe(76979871);
});
it('extracts id from vimeo.com/video URL', () => {
expect(parseVimeoVideoId('https://vimeo.com/video/76979871')).toBe(76979871);
});
it('returns null for empty input', () => {
expect(parseVimeoVideoId('')).toBe(null);
});
it('returns null for non-Vimeo URLs', () => {
expect(parseVimeoVideoId('https://example.com/video.mp4')).toBe(null);
});
});
describe('parseVimeoSource', () => {
it('detects events', () => {
expect(parseVimeoSource('https://vimeo.com/event/12345')).toEqual({ id: 12345, kind: 'event', hash: null });
});
it('extracts h param from query string', () => {
expect(parseVimeoSource('https://vimeo.com/12345?h=abc')).toEqual({ id: 12345, kind: 'video', hash: 'abc' });
});
it('extracts hash from event path', () => {
expect(parseVimeoSource('https://vimeo.com/event/12345/abc')).toEqual({ id: 12345, kind: 'event', hash: 'abc' });
});
});
describe('buildVimeoIframeSrc', () => {
it('builds embed URL from id with default playsinline and hidden controls', () => {
const src = buildVimeoIframeSrc('76979871');
expect(src).toContain('https://player.vimeo.com/video/76979871');
expect(src).toContain('playsinline=1');
expect(src).toContain('preload=metadata');
expect(src).toContain('controls=0');
});
it('encodes autoplay, defaultMuted, loop', () => {
const src = buildVimeoIframeSrc('76979871', {
autoplay: true,
defaultMuted: true,
loop: true,
});
expect(src).toContain('autoplay=1');
expect(src).toContain('muted=1');
expect(src).toContain('loop=1');
});
it('disables controls by default and when controls=false', () => {
expect(buildVimeoIframeSrc('76979871', { controls: false })).toContain('controls=0');
});
it('shows Vimeo controls when controls=true', () => {
const src = buildVimeoIframeSrc('76979871', { controls: true });
expect(src).not.toContain('controls=0');
});
it('forwards preload and Vimeo-specific config knobs', () => {
const src = buildVimeoIframeSrc('76979871', { preload: 'auto', config: { autopause: true } });
expect(src).toContain('preload=auto');
expect(src).toContain('autopause=1');
});
it('embeds h hash for unlisted videos', () => {
expect(buildVimeoIframeSrc('https://vimeo.com/12345?h=secret')).toContain('h=secret');
});
it('builds event embed URL with hashPath', () => {
const src = buildVimeoIframeSrc('https://vimeo.com/event/123/abc');
expect(src).toContain('https://vimeo.com/event/123/embed/abc');
expect(src).not.toContain('h=');
});
it('merges arbitrary config into params', () => {
const src = buildVimeoIframeSrc('76979871', { config: { background: true, byline: false } });
expect(src).toContain('background=1');
expect(src).toContain('byline=0');
});
it('returns empty string for invalid src', () => {
expect(buildVimeoIframeSrc('not-a-vimeo-url')).toBe('');
});
});
describe('VimeoMedia', () => {
it('has expected default state before attach', () => {
const media = new VimeoMedia();
expect(media.engine).toBe(null);
expect(media.target).toBe(null);
expect(media.paused).toBe(true);
expect(media.ended).toBe(false);
expect(media.currentTime).toBe(0);
expect(media.duration).toBeNaN();
expect(media.src).toBe(vimeoMediaDefaultProps.src);
expect(media.buffered.length).toBe(0);
expect(media.played.length).toBeGreaterThanOrEqual(1);
});
it('creates a Player when attached to an iframe', () => {
const media = new VimeoMedia();
const iframe = createIframe();
media.attach(iframe);
expect(media.target).toBe(iframe);
expect(media.engine).not.toBe(null);
});
it('emits loadstart on attach and loadedmetadata/loadcomplete after loaded', async () => {
const media = new VimeoMedia();
const events: string[] = [];
for (const type of ['loadstart', 'loadedmetadata', 'loadcomplete', 'durationchange'] as const) {
media.addEventListener(type, () => events.push(type));
}
const { player } = await attachAndLoad(media);
expect(events).toContain('loadstart');
expect(events).toContain('loadedmetadata');
expect(events).toContain('loadcomplete');
expect(events).toContain('durationchange');
expect(media.duration).toBe(60);
// Re-emit doesn't re-fire load events:
events.length = 0;
player.emit('timeupdate', { seconds: 1, duration: 60 });
expect(events).toEqual([]);
});
it('updates state from player events', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
const playSpy = vi.fn();
media.addEventListener('play', playSpy);
player.emit('play', { seconds: 0, duration: 60, percent: 0 });
expect(media.paused).toBe(false);
expect(playSpy).toHaveBeenCalled();
player.emit('timeupdate', { seconds: 12.5, duration: 60, percent: 0.2 });
expect(media.currentTime).toBe(12.5);
expect(media.duration).toBe(60);
player.emit('progress', { seconds: 30 });
expect(media.buffered.length).toBe(1);
expect(media.buffered.end(0)).toBe(30);
player.emit('resize', { videoWidth: 1280, videoHeight: 720 });
expect(media.videoWidth).toBe(1280);
expect(media.videoHeight).toBe(720);
player.emit('volumechange', { volume: 0.25 });
expect(media.volume).toBe(0.25);
player.emit('pause', { seconds: 12.5, duration: 60, percent: 0.2 });
expect(media.paused).toBe(true);
player.emit('ended', { seconds: 60, duration: 60, percent: 1 });
expect(media.ended).toBe(true);
});
it('forwards play() and pause() to the player', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
await media.play();
expect(player.play).toHaveBeenCalledTimes(1);
media.pause();
expect(player.pause).toHaveBeenCalledTimes(1);
});
it('forwards setters to the player after load', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
media.currentTime = 30;
media.volume = 0.5;
media.muted = true;
media.playbackRate = 1.5;
media.loop = true;
// setters defer via loadComplete microtask — flush.
await Promise.resolve();
await Promise.resolve();
expect(player.setCurrentTime).toHaveBeenCalledWith(30);
expect(player.setVolume).toHaveBeenCalledWith(0.5);
expect(player.setMuted).toHaveBeenCalledWith(true);
expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
expect(player.setLoop).toHaveBeenCalledWith(true);
});
it('calls loadVideo when src changes after attach', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
player.loadVideo.mockClear();
media.src = '76979871';
await Promise.resolve();
expect(player.loadVideo).toHaveBeenCalledWith({ url: 'https://player.vimeo.com/video/76979871' });
});
it('forwards fullscreen and pip requests', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
await media.requestFullscreen();
await media.exitFullscreen();
await media.requestPictureInPicture();
await media.exitPictureInPicture();
expect((player as unknown as { requestFullscreen: ReturnType<typeof vi.fn> }).requestFullscreen).toHaveBeenCalled();
expect((player as unknown as { exitFullscreen: ReturnType<typeof vi.fn> }).exitFullscreen).toHaveBeenCalled();
expect(
(player as unknown as { requestPictureInPicture: ReturnType<typeof vi.fn> }).requestPictureInPicture
).toHaveBeenCalled();
expect(
(player as unknown as { exitPictureInPicture: ReturnType<typeof vi.fn> }).exitPictureInPicture
).toHaveBeenCalled();
});
it('tracks played ranges via the played-ranges mixin', async () => {
const media = new VimeoMedia();
const { player } = await attachAndLoad(media);
player.emit('play', {});
player.emit('timeupdate', { seconds: 1 });
player.emit('timeupdate', { seconds: 2 });
player.emit('timeupdate', { seconds: 3 });
player.emit('pause', {});
const played = media.played;
expect(played.length).toBe(1);
expect(played.start(0)).toBe(0);
expect(played.end(0)).toBe(3);
});
it('destroys the player on detach', () => {
const media = new VimeoMedia();
const iframe = createIframe();
media.attach(iframe);
const player = media.engine as unknown as MockPlayerLike;
media.detach();
expect(player.destroy).toHaveBeenCalled();
expect(media.target).toBe(null);
expect(media.engine).toBe(null);
});
});