mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add vimeo media host and html/react components (#1667)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user