` tag: they take the same attributes and
+ * `on*` callbacks, and the same `ref` (the rendered element) and `mediaRef` (the object that plays
+ * the media) as ReactPlayer, so both pass straight through.
+ *
+ * The only translation needed is ReactPlayer's `src` + `config` into the structured `source` the
+ * v10 media take. v10 compares sources structurally, so a new object each render reloads nothing.
+ */
+export function createMediaPlayer(
+ MediaComponent: React.ComponentType
,
+ toMediaProps: ToMediaProps = engineSource
+) {
+ // The media components' own prop types differ per player; ReactPlayer passes a normalized set.
+ const Media = MediaComponent as React.ComponentType>;
+
+ const MediaPlayer = React.forwardRef(({ src, config, ...props }, ref) => (
+
+ ));
+
+ MediaPlayer.displayName = `MediaPlayer(${Media.displayName ?? Media.name ?? 'Media'})`;
+
+ return MediaPlayer;
+}
diff --git a/src/Player.tsx b/src/Player.tsx
index c88fab27e..08e28ae0b 100644
--- a/src/Player.tsx
+++ b/src/Player.tsx
@@ -1,12 +1,14 @@
-import React, { useCallback, useEffect, useRef } from 'react';
+import { useComposedRefs } from '@videojs/react';
+import React, { useEffect, useRef } from 'react';
import type { SyntheticEvent } from 'react';
+
import type { PlayerEntry } from './players.js';
import type { ReactPlayerProps } from './types.js';
type Player = React.ForwardRefExoticComponent<
ReactPlayerProps & {
activePlayer: PlayerEntry['player'];
- }
+ } & React.RefAttributes
>;
const Player: Player = React.forwardRef((props, ref) => {
@@ -14,6 +16,7 @@ const Player: Player = React.forwardRef((props, ref) => {
const Player = props.activePlayer;
const playerRef = useRef(null);
+ const mediaRef = useComposedRefs(playerRef, props.mediaRef);
const startOnPlayRef = useRef(true);
useEffect(() => {
@@ -75,7 +78,7 @@ const Player: Player = React.forwardRef((props, ref) => {
for (const key in props) {
if (key.startsWith('on') && !reactPlayerEventHandlers.includes(key)) {
- eventProps[key] = props[key as keyof ReactPlayerProps];
+ eventProps[key] = props[key as keyof typeof props];
}
}
@@ -85,18 +88,8 @@ const Player: Player = React.forwardRef((props, ref) => {
style={props.style}
className={props.className}
slot={props.slot}
- ref={useCallback(
- (node: HTMLVideoElement) => {
- playerRef.current = node;
-
- if (typeof ref === 'function') {
- ref(node);
- } else if (ref !== null) {
- ref.current = node;
- }
- },
- [ref]
- )}
+ ref={ref}
+ mediaRef={mediaRef}
src={props.src}
crossOrigin={props.crossOrigin}
preload={props.preload}
diff --git a/src/Preview.tsx b/src/Preview.tsx
index d1ef176ab..09ddc6554 100644
--- a/src/Preview.tsx
+++ b/src/Preview.tsx
@@ -48,9 +48,7 @@ const Preview = ({
const data = await response.json();
if (data.thumbnail_url) {
- const fetchedImage = data.thumbnail_url
- .replace('height=100', 'height=480')
- .replace('-d_295x166', '-d_640');
+ const fetchedImage = data.thumbnail_url.replace('height=100', 'height=480').replace('-d_295x166', '-d_640');
setImage(fetchedImage);
cache[src] = fetchedImage;
}
@@ -89,7 +87,7 @@ const Preview = ({
borderRadius: ICON_SIZE,
width: ICON_SIZE,
height: ICON_SIZE,
- position: isElement ? 'absolute' as const : undefined,
+ position: isElement ? ('absolute' as const) : undefined,
...flexCenter,
},
playIcon: {
diff --git a/src/ReactPlayer.tsx b/src/ReactPlayer.tsx
index 0b452128b..5e3eedc6c 100644
--- a/src/ReactPlayer.tsx
+++ b/src/ReactPlayer.tsx
@@ -1,17 +1,14 @@
import React, { lazy, Suspense, useEffect, useState } from 'react';
-import { defaultProps } from './props.js';
import Player from './Player.js';
-
-import type { ReactPlayerProps } from './types.js';
import type { PlayerEntry } from './players.js';
+import { defaultProps } from './props.js';
+import type { ReactPlayerProps } from './types.js';
const Preview = lazy(() => import(/* webpackChunkName: 'reactPlayerPreview' */ './Preview.js'));
const customPlayers: PlayerEntry[] = [];
-type ReactPlayer = React.ForwardRefExoticComponent<
- Omit & React.RefAttributes
-> &
+type ReactPlayer = React.ForwardRefExoticComponent> &
Partial<{
addCustomPlayer: (player: PlayerEntry) => void;
removeCustomPlayers: () => void;
@@ -20,17 +17,7 @@ type ReactPlayer = React.ForwardRefExoticComponent<
}>;
export const createReactPlayer = (players: PlayerEntry[], playerFallback: PlayerEntry) => {
- const getActivePlayer = (src?: string) => {
- for (const player of [...customPlayers, ...players]) {
- if (src && player.canPlay(src)) {
- return player;
- }
- }
- if (playerFallback) {
- return playerFallback;
- }
- return null;
- };
+ const findPlayer = (src: string) => [...customPlayers, ...players].find((player) => player.canPlay(src));
const ReactPlayer: ReactPlayer = React.forwardRef((_props, ref) => {
const props = { ...defaultProps, ..._props };
@@ -69,11 +56,7 @@ export const createReactPlayer = (players: PlayerEntry[], playerFallback: Player
};
const renderActivePlayer = (src?: string) => {
- const player = getActivePlayer(src);
- if (!player) return null;
-
- const { style, width, height, wrapper } = props;
- const config = props.config?.[player.key as keyof ReactPlayerProps['config']];
+ const player = (src && findPlayer(src)) || playerFallback;
return (
);
};
@@ -115,27 +97,10 @@ export const createReactPlayer = (players: PlayerEntry[], playerFallback: Player
customPlayers.length = 0;
};
- ReactPlayer.canPlay = (src?: string) => {
- if (src) {
- for (const Player of [...customPlayers, ...players]) {
- if (Player.canPlay(src)) {
- return true;
- }
- }
- }
- return false;
- };
+ ReactPlayer.canPlay = (src?: string) => !!src && !!findPlayer(src);
- ReactPlayer.canEnablePIP = (src?: string) => {
- if (src) {
- for (const Player of [...customPlayers, ...players]) {
- if (Player.canPlay(src) && Player.canEnablePIP?.()) {
- return true;
- }
- }
- }
- return false;
- };
+ ReactPlayer.canEnablePIP = (src?: string) =>
+ !!src && [...customPlayers, ...players].some((player) => player.canPlay(src) && player.canEnablePIP?.());
return ReactPlayer;
};
diff --git a/src/patterns.ts b/src/patterns.ts
index 37809c317..8e7502c9e 100644
--- a/src/patterns.ts
+++ b/src/patterns.ts
@@ -1,45 +1,20 @@
-export const AUDIO_EXTENSIONS =
- /\.(m4a|m4b|mp4a|mpga|mp2|mp2a|mp3|m2a|m3a|wav|weba|aac|oga|spx)($|\?)/i;
+import { resolveAdapterType, resolveMimeType, type AdapterType } from '@videojs/react';
+
+export const AUDIO_EXTENSIONS = /\.(m4a|m4b|mp4a|mpga|mp2|mp2a|mp3|m2a|m3a|wav|weba|aac|oga|spx)($|\?)/i;
export const VIDEO_EXTENSIONS = /\.(mp4|og[gv]|webm|mov|m4v)(#t=[,\d+]+)?($|\?)/i;
-export const HLS_EXTENSIONS = /\.(m3u8)($|\?)/i;
-export const DASH_EXTENSIONS = /\.(mpd)($|\?)/i;
-// Match Mux m3u8 URLs without the extension so users can use hls.js with Mux by adding the `.m3u8` extension. https://regexr.com/7um5f
-export const MATCH_URL_MUX = /stream\.mux\.com\/(?!\w+\.m3u8)(\w+)/;
-export const MATCH_URL_YOUTUBE =
- /(?:youtu\.be\/|youtube(?:-nocookie|education)?\.com\/(?:embed\/|v\/|watch\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))((\w|-){11})|youtube\.com\/playlist\?list=|youtube\.com\/user\//;
-export const MATCH_URL_VIMEO = /vimeo\.com\/(?!progressive_redirect).+/;
-export const MATCH_URL_WISTIA =
- /(?:wistia\.(?:com|net)|wi\.st)\/(?:medias|embed)\/(?:iframe\/)?([^?]+)/;
-export const MATCH_URL_SPOTIFY = /open\.spotify\.com\/(\w+)\/(\w+)/i;
-export const MATCH_URL_TWITCH = /(?:www\.|go\.)?twitch\.tv\/([a-zA-Z0-9_]+|(videos?\/|\?video=)\d+)($|\?)/;
-export const MATCH_URL_TIKTOK = /tiktok\.com\/(?:player\/v1\/|share\/video\/|@[^/]+\/video\/)([0-9]+)/;
-const canPlayFile = (url: string, test: (u: string) => boolean) => {
- if (Array.isArray(url)) {
- for (const item of url) {
- if (typeof item === 'string' && canPlayFile(item, test)) {
- return true;
- }
- if (canPlayFile(item.src, test)) {
- return true;
- }
- }
- return false;
- }
- return test(url);
-};
+export type PlayerKey = Exclude | 'html';
-export const canPlay = {
- html: (url: string) =>
- canPlayFile(url, (u: string) => AUDIO_EXTENSIONS.test(u) || VIDEO_EXTENSIONS.test(u)),
- hls: (url: string) => canPlayFile(url, (u: string) => HLS_EXTENSIONS.test(u)),
- dash: (url: string) => canPlayFile(url, (u: string) => DASH_EXTENSIONS.test(u)),
- mux: (url: string) => MATCH_URL_MUX.test(url),
- youtube: (url: string) => MATCH_URL_YOUTUBE.test(url),
- vimeo: (url: string) =>
- MATCH_URL_VIMEO.test(url) && !VIDEO_EXTENSIONS.test(url) && !HLS_EXTENSIONS.test(url),
- wistia: (url: string) => MATCH_URL_WISTIA.test(url),
- spotify: (url: string) => MATCH_URL_SPOTIFY.test(url),
- twitch: (url: string) => MATCH_URL_TWITCH.test(url),
- tiktok: (url: string) => MATCH_URL_TIKTOK.test(url),
+/** The key of the built-in player that plays `url`, from its Video.js adapter type. */
+const resolvePlayerKey = (url: string): PlayerKey | null => {
+ const type = resolveAdapterType(url);
+ // Mux stream URLs with the `.m3u8` extension resolve to `mux`, but are played with hls.js so
+ // users can opt into it by adding the extension.
+ if (type === 'mux' && resolveMimeType(url) === 'application/x-mpegurl') return 'hls';
+ if (type === 'video' || type === 'audio') return 'html';
+ // The extension patterns cover formats Video.js doesn't recognize (e.g. m4v, weba, oga).
+ if (!type && (AUDIO_EXTENSIONS.test(url) || VIDEO_EXTENSIONS.test(url))) return 'html';
+ return type;
};
+
+export const canPlay = (key: PlayerKey) => (url: string) => resolvePlayerKey(url) === key;
diff --git a/src/players.ts b/src/players.ts
index 10a4d1e66..25d9883eb 100644
--- a/src/players.ts
+++ b/src/players.ts
@@ -1,102 +1,109 @@
import { lazy } from 'react';
-import { canPlay } from './patterns.js';
-import type { VideoElementProps } from './types.js';
+
import HtmlPlayer from './HtmlPlayer.js';
+import { createMediaPlayer } from './MediaPlayer.js';
+import { canPlay } from './patterns.js';
+import { muxSource, wistiaSource, type ToMediaProps } from './sources.js';
+import type { PlayerComponent } from './types.js';
export type PlayerEntry = {
key: string;
- name: string;
canPlay: (src: string) => boolean;
canEnablePIP?: () => boolean;
- player?:
- | React.ComponentType
- | React.LazyExoticComponent>;
+ player?: PlayerComponent | LazyPlayer;
};
+type LazyPlayer = React.LazyExoticComponent;
+
+/** Lazily loads a `@videojs/react` media component and adapts it to ReactPlayer's props. */
+const lazyMedia = (
+ load: () => Promise>,
+ toMediaProps?: ToMediaProps
+): LazyPlayer => lazy(async () => ({ default: createMediaPlayer(await load(), toMediaProps) })) as LazyPlayer;
+
const Players: PlayerEntry[] = [
{
key: 'hls',
- name: 'hls.js',
- canPlay: canPlay.hls,
+ canPlay: canPlay('hls'),
canEnablePIP: () => true,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerHls' */ 'hls-video-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerHls' */ '@videojs/react/media/hlsjs-video').then((m) => m.HlsJsVideo)
+ ),
},
{
key: 'dash',
- name: 'dash.js',
- canPlay: canPlay.dash,
+ canPlay: canPlay('dash'),
canEnablePIP: () => true,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerDash' */ 'dash-video-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerDash' */ '@videojs/react/media/dash-video').then((m) => m.DashVideo)
+ ),
},
{
key: 'mux',
- name: 'Mux',
- canPlay: canPlay.mux,
+ canPlay: canPlay('mux'),
canEnablePIP: () => true,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerMux' */ '@mux/mux-player-react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(
+ () => import(/* webpackChunkName: 'reactPlayerMux' */ '@videojs/react/media/mux-video').then((m) => m.MuxVideo),
+ muxSource
+ ),
},
{
key: 'youtube',
- name: 'YouTube',
- canPlay: canPlay.youtube,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerYouTube' */ 'youtube-video-element/react')
- ) as React.LazyExoticComponent>,
+ canPlay: canPlay('youtube'),
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerYouTube' */ '@videojs/react/media/youtube-video').then(
+ (m) => m.YouTubeVideo
+ )
+ ),
},
{
key: 'vimeo',
- name: 'Vimeo',
- canPlay: canPlay.vimeo,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerVimeo' */ 'vimeo-video-element/react')
- ) as React.LazyExoticComponent>,
+ canPlay: canPlay('vimeo'),
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerVimeo' */ '@videojs/react/media/vimeo-video').then((m) => m.VimeoVideo)
+ ),
},
{
key: 'wistia',
- name: 'Wistia',
- canPlay: canPlay.wistia,
+ canPlay: canPlay('wistia'),
canEnablePIP: () => true,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerWistia' */ 'wistia-video-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(
+ () =>
+ import(/* webpackChunkName: 'reactPlayerWistia' */ '@videojs/react/media/wistia-video').then(
+ (m) => m.WistiaVideo
+ ),
+ wistiaSource
+ ),
},
{
key: 'spotify',
- name: 'Spotify',
- canPlay: canPlay.spotify,
+ canPlay: canPlay('spotify'),
canEnablePIP: () => false,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerSpotify' */ 'spotify-audio-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerSpotify' */ '@videojs/react/media/spotify-audio').then(
+ (m) => m.SpotifyAudio
+ )
+ ),
},
{
key: 'twitch',
- name: 'Twitch',
- canPlay: canPlay.twitch,
+ canPlay: canPlay('twitch'),
canEnablePIP: () => false,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerTwitch' */ 'twitch-video-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerTwitch' */ '@videojs/react/media/twitch-video').then((m) => m.TwitchVideo)
+ ),
},
{
key: 'tiktok',
- name: 'TikTok',
- canPlay: canPlay.tiktok,
+ canPlay: canPlay('tiktok'),
canEnablePIP: () => false,
- player: lazy(
- () => import(/* webpackChunkName: 'reactPlayerTiktok' */ 'tiktok-video-element/react')
- ) as React.LazyExoticComponent>,
+ player: lazyMedia(() =>
+ import(/* webpackChunkName: 'reactPlayerTiktok' */ '@videojs/react/media/tiktok-video').then((m) => m.TikTokVideo)
+ ),
},
{
key: 'html',
- name: 'html',
- canPlay: canPlay.html,
+ canPlay: canPlay('html'),
canEnablePIP: () => true,
player: HtmlPlayer,
},
diff --git a/src/sources.ts b/src/sources.ts
new file mode 100644
index 000000000..616bbc08d
--- /dev/null
+++ b/src/sources.ts
@@ -0,0 +1,48 @@
+import type { Config } from './types.js';
+
+/**
+ * Helpers that translate ReactPlayer's `src` + `config` into the props of a `@videojs/react` media
+ * component. Video.js v10 media take a structured `source` whose `engine` key carries engine
+ * options namespaced by engine, so every player can take the same `config` and read its own key.
+ */
+export type ToMediaProps = (src: string | undefined, config: Config | undefined) => Record;
+
+const splitConfig = ({ mux, wistia, ...engine }: Config = {}) => ({ mux, wistia, engine });
+
+export const engineSource: ToMediaProps = (src, config) => ({
+ source: { src, engine: splitConfig(config).engine },
+});
+
+const MUX_BARE_URL = /^https?:\/\/stream\.mux\.com\/(\w+)\/?(?:\?([^#]*))?(?:#.*)?$/;
+
+const camelCase = (key: string) => key.replace(/_(\w)/g, (_, char: string) => char.toUpperCase());
+
+/**
+ * ReactPlayer matches Mux URLs without the `.m3u8` extension (`https://stream.mux.com/?...`).
+ * Turn those into a structured Mux source so the adapter derives the stream URL, poster and
+ * storyboard itself, and layer `config.mux` on top. Mux plays through hls.js, so it also reads
+ * `config.hlsJs` and `config.nativeHls`.
+ */
+export const muxSource: ToMediaProps = (src, config) => {
+ const { mux, engine } = splitConfig(config);
+ const options = { engine, ...mux };
+ const match = src?.match(MUX_BARE_URL);
+ if (!match) return { source: { src, ...options } };
+
+ const [, playbackId, query] = match;
+ const playback: Record = { ...options.playback };
+ for (const [key, value] of new URLSearchParams(query)) {
+ playback[camelCase(key)] ??= value;
+ }
+
+ return {
+ source: {
+ playbackId,
+ ...options,
+ ...(Object.keys(playback).length ? { playback } : {}),
+ },
+ };
+};
+
+/** Wistia reads the media id from `src` and takes its own player options as `source`. */
+export const wistiaSource: ToMediaProps = (src, config) => ({ src, source: config?.wistia });
diff --git a/src/types.ts b/src/types.ts
index 411f4f2b7..5c29cf649 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,31 +1,34 @@
-import type { MediaHTMLAttributes, SyntheticEvent } from 'react';
-import type HlsVideoElement from 'hls-video-element';
-import type SpotifyAudioElement from 'spotify-audio-element';
-import type YouTubeVideoElement from 'youtube-video-element';
-import type VimeoVideoElement from 'vimeo-video-element';
-import type TwitchVideoElement from 'twitch-video-element';
-import type TikTokVideoElement from 'tiktok-video-element';
+import type { DashEngineConfig } from '@videojs/dash-video';
+import type { HlsEngineConfig } from '@videojs/hlsjs-video';
+import type { MuxSource } from '@videojs/mux-video';
+import type { SpotifySourceEngineConfig } from '@videojs/spotify-audio';
+import type { TikTokSourceEngineConfig } from '@videojs/tiktok-video';
+import type { TwitchSourceEngineConfig } from '@videojs/twitch-video';
+import type { VimeoSourceEngineConfig } from '@videojs/vimeo-video';
+import type { WistiaSource } from '@videojs/wistia-video';
+import type { YouTubeSourceEngineConfig } from '@videojs/youtube-video';
+import type { SyntheticEvent } from 'react';
-interface VideoHTMLAttributes extends MediaHTMLAttributes {
- height?: number | string | undefined;
- playsInline?: boolean | undefined;
- poster?: string | undefined;
- width?: number | string | undefined;
- disablePictureInPicture?: boolean | undefined;
- disableRemotePlayback?: boolean | undefined;
+interface VideoHTMLAttributes extends React.VideoHTMLAttributes {
onEnterPictureInPicture?: ((this: HTMLVideoElement, ev: Event) => void) | undefined;
onLeavePictureInPicture?: ((this: HTMLVideoElement, ev: Event) => void) | undefined;
}
-export interface VideoElementProps
- extends React.DetailedHTMLProps, HTMLVideoElement> {
+/**
+ * Props of a player component. Like the Video.js v10 media components, `ref` receives the rendered
+ * DOM element and `mediaRef` the object that plays the media: the element itself for `` and
+ * ``, or the playback adapter for a stream or an embed.
+ */
+export interface VideoElementProps extends VideoHTMLAttributes {
+ mediaRef?: React.Ref;
playbackRate?: number;
volume?: number;
config?: Config;
}
+export type PlayerComponent = React.ComponentType>;
+
export interface ReactPlayerProps extends PreviewProps, VideoElementProps {
- config?: Config;
fallback?: React.ReactNode;
onReady?: () => void;
onStart?: (event: SyntheticEvent) => void;
@@ -44,15 +47,20 @@ export interface PreviewProps {
previewTabIndex?: number;
}
-export interface Config {
- dash?: Record;
- hls?: HlsVideoElement['config'];
- html?: Record;
- mux?: Record;
- spotify?: SpotifyAudioElement['config'];
- tiktok?: TikTokVideoElement['config'];
- twitch?: TwitchVideoElement['config'];
- vimeo?: VimeoVideoElement['config'];
- wistia?: Record;
- youtube?: YouTubeVideoElement['config'];
+/**
+ * Settings for every player, in one object. Engine options are keyed by engine name, exactly like
+ * Video.js v10's `source.engine`, and each player reads only its own key. `mux` and `wistia` take
+ * the rest of those players' source options.
+ */
+export interface Config
+ extends
+ HlsEngineConfig,
+ DashEngineConfig,
+ YouTubeSourceEngineConfig,
+ VimeoSourceEngineConfig,
+ SpotifySourceEngineConfig,
+ TwitchSourceEngineConfig,
+ TikTokSourceEngineConfig {
+ mux?: Omit;
+ wistia?: Omit;
}
diff --git a/test/Player.tsx b/test/HtmlPlayer.test.tsx
similarity index 70%
rename from test/Player.tsx
rename to test/HtmlPlayer.test.tsx
index 48201759d..26b544c50 100644
--- a/test/Player.tsx
+++ b/test/HtmlPlayer.test.tsx
@@ -1,28 +1,28 @@
import './helpers/server-safe-globals.js';
-import { test } from 'zora';
-import sinon from 'sinon';
-import { act } from 'react-test-renderer';
import React from 'react';
-import Player from '../src/Player';
+import { act } from 'react-test-renderer';
+import { expect, test, vi } from 'vite-plus/test';
+
import HtmlPlayer from '../src/HtmlPlayer';
+import Player from '../src/Player';
import { render } from './helpers/helpers';
-test('video.load()', async (t) => {
+test('video.load()', async () => {
const videoRef: React.Ref = React.createRef();
render( );
- const loadstart = sinon.fake();
+ const loadstart = vi.fn();
videoRef.current?.addEventListener('loadstart', loadstart);
await Promise.resolve();
- t.ok(loadstart.calledOnce);
+ expect(loadstart).toHaveBeenCalledOnce();
});
-test('video.play()', async (t) => {
+test('video.play()', async () => {
const videoRef: React.Ref = React.createRef();
const wrapper = render( );
- const play = sinon.fake();
+ const play = vi.fn();
videoRef.current?.addEventListener('play', play);
act(() => {
@@ -30,15 +30,15 @@ test('video.play()', async (t) => {
});
await Promise.resolve();
- t.ok(play.calledOnce);
- t.equal(videoRef.current?.paused, false);
+ expect(play).toHaveBeenCalledOnce();
+ expect(videoRef.current?.paused).toBe(false);
});
-test('video.pause()', async (t) => {
+test('video.pause()', async () => {
const videoRef: React.Ref = React.createRef();
const wrapper = render( );
- const pause = sinon.fake();
+ const pause = vi.fn();
videoRef.current?.addEventListener('pause', pause);
act(() => {
@@ -46,11 +46,11 @@ test('video.pause()', async (t) => {
});
await Promise.resolve();
- t.ok(pause.calledOnce);
- t.equal(videoRef.current?.paused, true);
+ expect(pause).toHaveBeenCalledOnce();
+ expect(videoRef.current?.paused).toBe(true);
});
-test('video.volume = 0.5', async (t) => {
+test('video.volume = 0.5', async () => {
const videoRef: React.Ref = React.createRef();
const wrapper = render( );
@@ -59,13 +59,13 @@ test('video.volume = 0.5', async (t) => {
});
await Promise.resolve();
- t.equal(videoRef.current?.volume, 0.5);
+ expect(videoRef.current?.volume).toBe(0.5);
});
-test('video.muted = true', async (t) => {
+test('video.muted = true', async () => {
let videoRef: React.Ref = React.createRef();
const wrapper = render( );
- t.equal(videoRef.current?.muted, false);
+ expect(videoRef.current?.muted).toBe(false);
act(() => {
videoRef = React.createRef();
@@ -73,13 +73,13 @@ test('video.muted = true', async (t) => {
});
await Promise.resolve();
- t.equal(videoRef.current?.muted, true);
+ expect(videoRef.current?.muted).toBe(true);
});
-test('video.muted = false', async (t) => {
+test('video.muted = false', async () => {
let videoRef: React.Ref = React.createRef();
const wrapper = render( );
- t.equal(videoRef.current?.muted, true);
+ expect(videoRef.current?.muted).toBe(true);
act(() => {
videoRef = React.createRef();
@@ -87,10 +87,10 @@ test('video.muted = false', async (t) => {
});
await Promise.resolve();
- t.equal(videoRef.current?.muted, false);
+ expect(videoRef.current?.muted).toBe(false);
});
-test('video.playbackRate = 0.5', async (t) => {
+test('video.playbackRate = 0.5', async () => {
const videoRef: React.Ref = React.createRef();
const wrapper = render( );
@@ -99,16 +99,22 @@ test('video.playbackRate = 0.5', async (t) => {
});
await Promise.resolve();
- t.equal(videoRef.current?.playbackRate, 0.5);
+ expect(videoRef.current?.playbackRate).toBe(0.5);
});
-await test('video.duration', async (t) => {
+test('video.duration', async () => {
const videoRef: React.Ref = React.createRef();
- render( );
+ render(
+
+ );
await new Promise((resolve) => {
videoRef.current?.addEventListener('durationchange', resolve);
});
- t.equal(videoRef.current?.duration, 10);
+ expect(videoRef.current?.duration).toBe(10);
});
diff --git a/test/MediaPlayer.test.tsx b/test/MediaPlayer.test.tsx
new file mode 100644
index 000000000..3c2e60196
--- /dev/null
+++ b/test/MediaPlayer.test.tsx
@@ -0,0 +1,135 @@
+import './helpers/server-safe-globals.js';
+import React from 'react';
+import { act } from 'react-test-renderer';
+import { expect, test, vi } from 'vite-plus/test';
+
+import { createMediaPlayer } from '../src/MediaPlayer';
+import Player from '../src/Player';
+import { render } from './helpers/helpers';
+
+/** Stand-in for the playback adapter an embed hands to `mediaRef`. */
+class FakeAdapter {
+ paused = true;
+ volume = 1;
+ playbackRate = 1;
+ constructor(public target: HTMLElement) {}
+ play() {
+ this.paused = false;
+ }
+ pause() {
+ this.paused = true;
+ }
+}
+
+const assignRef = (ref: unknown, value: T | null) => {
+ if (typeof ref === 'function') ref(value);
+ else if (ref) (ref as React.MutableRefObject).current = value;
+};
+
+/**
+ * Stand-in for a `@videojs/react` embed media component: takes ``-like props plus a
+ * structured `source`, hands the rendered element to `ref` and its adapter to `mediaRef`.
+ */
+const FakeMedia = React.forwardRef>((props, ref) => {
+ const { source, mediaRef, children, ...rest } = props;
+ const adapter = React.useRef(null);
+ return (
+ {
+ if (node) adapter.current ??= new FakeAdapter(node);
+ assignRef(ref, node);
+ assignRef(mediaRef, node && adapter.current);
+ }}
+ >
+ {children as React.ReactNode}
+
+ );
+});
+
+const FakePlayer = createMediaPlayer(FakeMedia);
+
+test('src and config become the media source', () => {
+ const wrapper = render(
+
+ );
+ const video = wrapper.root.findByType('video');
+
+ expect(JSON.parse(video.props['data-source'])).toEqual({
+ src: 'file.m3u8',
+ engine: { hlsJs: { debug: true }, youtube: { color: 'white' } },
+ });
+ expect(video.props.config, 'config is not passed to the media').toBeUndefined();
+});
+
+test('attributes, callbacks and children pass through unchanged', () => {
+ const onPlay = vi.fn();
+ const wrapper = render(
+
+
+
+ );
+ const video = wrapper.root.findByType('video');
+
+ expect(video.props.autoPlay).toBe(true);
+ expect(video.props.muted).toBe(true);
+ expect(video.props.controls).toBe(true);
+ expect(video.props.className).toBe('rp');
+ expect(wrapper.root.findByType('track')).toBeTruthy();
+ expect(video.props.volume, 'volume is applied via the media, not as a prop').toBeUndefined();
+ expect(video.props.playbackRate, 'playbackRate is applied via the media, not as a prop').toBeUndefined();
+
+ act(() => {
+ video.props.onPlay(new Event('play'));
+ });
+ expect(onPlay).toHaveBeenCalledOnce();
+});
+
+test('ref is the rendered element and mediaRef the media', () => {
+ const ref = React.createRef();
+ const mediaRef = React.createRef();
+ render( );
+
+ expect(ref.current, 'ref receives the element').toBeTruthy();
+ expect(mediaRef.current, 'mediaRef receives the adapter').toBeInstanceOf(FakeAdapter);
+ expect((mediaRef.current as unknown as FakeAdapter).target).toBe(ref.current);
+});
+
+test('the media is driven by playing / volume / playbackRate', async () => {
+ const mediaRef = React.createRef();
+ const wrapper = render(
+
+ );
+ await Promise.resolve();
+
+ expect(mediaRef.current?.paused).toBe(false);
+ expect(mediaRef.current?.volume).toBe(0.5);
+ expect(mediaRef.current?.playbackRate).toBe(2);
+
+ act(() => {
+ wrapper.update(
+
+ );
+ });
+ expect(mediaRef.current?.paused).toBe(true);
+});
+
+test('the media, not the element, is driven without a mediaRef', async () => {
+ const ref = React.createRef();
+ render( );
+ await Promise.resolve();
+
+ expect((ref.current as HTMLVideoElement).paused, 'the element is not played').toBe(true);
+});
diff --git a/test/Player.test.tsx b/test/Player.test.tsx
index 9fa853222..3330d9f1b 100644
--- a/test/Player.test.tsx
+++ b/test/Player.test.tsx
@@ -1,7 +1,7 @@
import './helpers/server-safe-globals.js';
-import { test } from 'zora';
-import sinon from 'sinon';
import React from 'react';
+import { expect, test, vi } from 'vite-plus/test';
+
import Player from '../src/Player';
// Mock the activePlayer component
@@ -9,38 +9,33 @@ const MockActivePlayer = React.forwardRef((props, ref) =>
return React.createElement('video', { ...props, ref });
});
-test('filters out ReactPlayer-specific event handlers to prevent React warnings', async (t) => {
- // Mock console.warn to capture warnings
- const originalWarn = console.warn;
- const warnings: string[] = [];
- console.warn = sinon.fake((...args) => {
- warnings.push(args.join(' '));
- });
+test('filters out ReactPlayer-specific event handlers to prevent React warnings', async () => {
+ // Capture warnings
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const props = {
activePlayer: MockActivePlayer,
- onReady: sinon.fake(),
- onStart: sinon.fake(),
- onPlay: sinon.fake(),
- onPause: sinon.fake(),
- onEnded: sinon.fake(),
- onLoadStart: sinon.fake(),
+ onReady: vi.fn(),
+ onStart: vi.fn(),
+ onPlay: vi.fn(),
+ onPause: vi.fn(),
+ onEnded: vi.fn(),
+ onLoadStart: vi.fn(),
// These should be passed through to the underlying video element
- onLoadedMetadata: sinon.fake(),
- onCanPlay: sinon.fake(),
- onError: sinon.fake(),
+ onLoadedMetadata: vi.fn(),
+ onCanPlay: vi.fn(),
+ onError: vi.fn(),
};
// Just verify that the component can be created without errors
// The actual filtering logic is tested by the fact that no warnings are generated
- t.ok(React.createElement(Player, props));
+ expect(React.createElement(Player, props)).toBeTruthy();
// Check that no warnings about unknown event handlers were logged
- const unknownEventHandlerWarnings = warnings.filter(warning =>
- warning.includes('Unknown event handler property')
- );
- t.equal(unknownEventHandlerWarnings.length, 0);
+ const unknownEventHandlerWarnings = warn.mock.calls
+ .map((args) => args.join(' '))
+ .filter((warning) => warning.includes('Unknown event handler property'));
+ expect(unknownEventHandlerWarnings).toHaveLength(0);
- // Restore console.warn
- console.warn = originalWarn;
+ warn.mockRestore();
});
diff --git a/test/ReactPlayer/instanceMethods.js b/test/ReactPlayer/instanceMethods.js
index eddd189f4..4027ae1f1 100644
--- a/test/ReactPlayer/instanceMethods.js
+++ b/test/ReactPlayer/instanceMethods.js
@@ -1,7 +1,8 @@
-import { test } from 'zora';
-import sinon from 'sinon';
import React from 'react';
import { create } from 'react-test-renderer';
+import sinon from 'sinon';
+import { test } from 'zora';
+
import ReactPlayer from '../../src/index';
const COMMON_METHODS = ['getDuration', 'getCurrentTime', 'getSecondsLoaded', 'getInternalPlayer'];
diff --git a/test/ReactPlayer/props.test.tsx b/test/ReactPlayer/props.test.tsx
new file mode 100644
index 000000000..eae224eb0
--- /dev/null
+++ b/test/ReactPlayer/props.test.tsx
@@ -0,0 +1,41 @@
+import '../helpers/server-safe-globals.js';
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { expect, test } from 'vite-plus/test';
+
+import ReactPlayer from '../../src/index';
+import { render } from '../helpers/helpers';
+
+test('className', async () => {
+ const wrapper = render( );
+ expect(wrapper.root.findByType('video').props.className).toBe('react-player');
+});
+
+test('style', () => {
+ const wrapper = render( );
+ expect(wrapper.root.findByType('video').props.style.marginTop).toBe('1rem');
+});
+
+test('config is not passed to a native video', () => {
+ const wrapper = render( );
+ expect(wrapper.root.findByType('video').props.config).toBeUndefined();
+});
+
+test('ref and mediaRef are both the native video element', () => {
+ const ref = React.createRef();
+ const mediaRef = React.createRef();
+ render( );
+ expect(ref.current).toBeTruthy();
+ expect(mediaRef.current).toBe(ref.current);
+});
+
+test('wrapper - string', () => {
+ const wrapper = create( );
+ expect(wrapper.toJSON().type).toBe('span');
+});
+
+test('wrapper - element', () => {
+ const Element = () => null;
+ const wrapper = create( );
+ expect(wrapper.root.findByType(Element)).toBeTruthy();
+});
diff --git a/test/ReactPlayer/props.tsx b/test/ReactPlayer/props.tsx
deleted file mode 100644
index 9f6c058c2..000000000
--- a/test/ReactPlayer/props.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import '../helpers/server-safe-globals.js';
-import { test } from 'zora';
-import React from 'react';
-import { create } from 'react-test-renderer';
-import ReactPlayer from '../../src/index';
-import { render } from '../helpers/helpers';
-
-test('className', async (t) => {
- const wrapper = render( );
- t.equal(wrapper.root.findByType('video').props.className, 'react-player');
-});
-
-test('style', (t) => {
- const wrapper = render( );
- t.equal(wrapper.root.findByType('video').props.style.marginTop, '1rem');
-});
-
-test('wrapper - string', (t) => {
- const wrapper = create( );
- t.equal(wrapper.toJSON().type, 'span');
-});
-
-test('wrapper - element', (t) => {
- const Element = () => null;
- const wrapper = create( );
- t.ok(wrapper.root.findByType(Element));
-});
diff --git a/test/ReactPlayer/render.js b/test/ReactPlayer/render.test.jsx
similarity index 51%
rename from test/ReactPlayer/render.js
rename to test/ReactPlayer/render.test.jsx
index 15a28ebcf..e81fed43a 100644
--- a/test/ReactPlayer/render.js
+++ b/test/ReactPlayer/render.test.jsx
@@ -1,22 +1,21 @@
import React from 'react';
-import { test } from 'zora';
import { create } from 'react-test-renderer';
+import { expect, test } from 'vite-plus/test';
+
+import HtmlPlayer from '../../src/HtmlPlayer';
import ReactPlayer from '../../src/index';
import Player from '../../src/Player';
-import HtmlPlayer from '../../src/HtmlPlayer';
globalThis.window = { MediaStream: Object };
-test.skip('render', (t) => {
+test.skip('render', () => {
const wrapper = create( );
- t.ok(wrapper.equals({null}
));
+ expect(wrapper.equals({null}
)).toBeTruthy();
});
-test.skip('fallback player', (t) => {
+test.skip('fallback player', () => {
const wrapper = create( );
- t.ok(
- wrapper
- .childAt(0)
- .matchesElement( )
- );
+ expect(
+ wrapper.childAt(0).matchesElement( )
+ ).toBeTruthy();
});
diff --git a/test/ReactPlayer/staticMethods.js b/test/ReactPlayer/staticMethods.js
deleted file mode 100644
index e4c7982cf..000000000
--- a/test/ReactPlayer/staticMethods.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { test } from 'zora';
-import React from 'react';
-import { create } from 'react-test-renderer';
-import '../helpers/server-safe-globals';
-import ReactPlayer from '../../src/index';
-import Player from '../../src/Player';
-
-test('canPlay()', (t) => {
- t.ok(ReactPlayer.canPlay('https://www.youtube.com/watch?v=oUFJJNQGwhk'));
- t.ok(ReactPlayer.canPlay('https://youtube.com/shorts/370kwJ-x5TY?feature=share'));
- t.ok(ReactPlayer.canPlay('https://vimeo.com/90509568'));
- t.ok(ReactPlayer.canPlay('https://home.wistia.com/medias/e4a27b971d'));
- t.ok(ReactPlayer.canPlay('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4'));
- t.ok(ReactPlayer.canPlay('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4#t=1'));
- t.notOk(ReactPlayer.canPlay('http://example.com/random/path'));
-});
-
-test('addCustomPlayer()', (t) => {
- const CustomPlayer = React.forwardRef(() => );
- CustomPlayer.displayName = 'CustomPlayer';
- CustomPlayer.canPlay = (src) => /example\.com/.test(src);
-
- ReactPlayer.addCustomPlayer(CustomPlayer);
- const wrapper = create( );
- t.ok(ReactPlayer.canPlay('http://example.com/random/path'));
- t.ok(wrapper.root.findByType(Player));
- t.equal(wrapper.root.findByType(Player).props.activePlayer, CustomPlayer);
- ReactPlayer.removeCustomPlayers();
- t.notOk(ReactPlayer.canPlay('http://example.com/random/path'));
-});
diff --git a/test/ReactPlayer/staticMethods.test.jsx b/test/ReactPlayer/staticMethods.test.jsx
new file mode 100644
index 000000000..c78c43fcf
--- /dev/null
+++ b/test/ReactPlayer/staticMethods.test.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { expect, test } from 'vite-plus/test';
+
+import '../helpers/server-safe-globals';
+import ReactPlayer from '../../src/index';
+import Player from '../../src/Player';
+
+test('canPlay()', () => {
+ expect(ReactPlayer.canPlay('https://www.youtube.com/watch?v=oUFJJNQGwhk')).toBeTruthy();
+ expect(ReactPlayer.canPlay('https://youtube.com/shorts/370kwJ-x5TY?feature=share')).toBeTruthy();
+ expect(ReactPlayer.canPlay('https://vimeo.com/90509568')).toBeTruthy();
+ expect(ReactPlayer.canPlay('https://home.wistia.com/medias/e4a27b971d')).toBeTruthy();
+ expect(ReactPlayer.canPlay('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4')).toBeTruthy();
+ expect(ReactPlayer.canPlay('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4#t=1')).toBeTruthy();
+ expect(ReactPlayer.canPlay('http://example.com/random/path')).toBeFalsy();
+});
+
+test('addCustomPlayer()', () => {
+ const CustomPlayer = React.forwardRef(() => );
+ CustomPlayer.displayName = 'CustomPlayer';
+ CustomPlayer.canPlay = (src) => /example\.com/.test(src);
+
+ ReactPlayer.addCustomPlayer(CustomPlayer);
+ const wrapper = create( );
+ expect(ReactPlayer.canPlay('http://example.com/random/path')).toBeTruthy();
+ expect(wrapper.root.findByType(Player)).toBeTruthy();
+ expect(wrapper.root.findByType(Player).props.activePlayer).toBe(CustomPlayer);
+ ReactPlayer.removeCustomPlayers();
+ expect(ReactPlayer.canPlay('http://example.com/random/path')).toBeFalsy();
+});
diff --git a/test/ReactPlayer/v10-engine.test.tsx b/test/ReactPlayer/v10-engine.test.tsx
new file mode 100644
index 000000000..426076c6c
--- /dev/null
+++ b/test/ReactPlayer/v10-engine.test.tsx
@@ -0,0 +1,37 @@
+import '../helpers/server-safe-globals.js';
+import { createPlayer } from '@videojs/react';
+import React from 'react';
+import { act } from 'react-test-renderer';
+import { expect, test } from 'vite-plus/test';
+
+import ReactPlayer from '../../src/index';
+import { render } from '../helpers/helpers';
+
+test('mediaRef and useMedia() are the hls.js adapter, with its engine, behind a stream', async () => {
+ const { Player, useMedia } = createPlayer({ features: [] });
+ let media: unknown = null;
+
+ const ReadMedia = () => {
+ media = useMedia();
+ return null;
+ };
+
+ const ref = React.createRef();
+ const mediaRef = React.createRef();
+ await act(async () => {
+ render(
+
+
+
+
+ );
+ // Let the lazily loaded HlsJsVideo resolve.
+ await import('@videojs/react/media/hlsjs-video');
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(mediaRef.current).toBeTruthy();
+ expect(media, 'mediaRef is the adapter the player uses').toBe(mediaRef.current);
+ expect('engine' in (mediaRef.current as object)).toBe(true);
+ expect(ref.current, 'ref is the rendered element, not the adapter').not.toBe(mediaRef.current);
+});
diff --git a/test/ReactPlayer/v10-player.test.tsx b/test/ReactPlayer/v10-player.test.tsx
new file mode 100644
index 000000000..93733dcda
--- /dev/null
+++ b/test/ReactPlayer/v10-player.test.tsx
@@ -0,0 +1,28 @@
+import '../helpers/server-safe-globals.js';
+import { createPlayer } from '@videojs/react';
+import React from 'react';
+import { expect, test } from 'vite-plus/test';
+
+import ReactPlayer from '../../src/index';
+import { render } from '../helpers/helpers';
+
+test('attaches its media to a surrounding Video.js player', () => {
+ const { Player, useMedia } = createPlayer({ features: [] });
+ let attached: unknown = null;
+
+ const ReadMedia = () => {
+ attached = useMedia();
+ return null;
+ };
+
+ const mediaRef = React.createRef();
+ render(
+
+
+
+
+ );
+
+ expect(mediaRef.current).toBeTruthy();
+ expect(attached).toBe(mediaRef.current);
+});
diff --git a/test/ReactPlayer/v10-ui-components.test.tsx b/test/ReactPlayer/v10-ui-components.test.tsx
new file mode 100644
index 000000000..b2565644c
--- /dev/null
+++ b/test/ReactPlayer/v10-ui-components.test.tsx
@@ -0,0 +1,26 @@
+import '../helpers/server-safe-globals.js';
+import { PlayButton } from '@videojs/react';
+import { VideoPlayer } from '@videojs/react/video';
+import React from 'react';
+import { act } from 'react-test-renderer';
+import { expect, test } from 'vite-plus/test';
+
+import ReactPlayer from '../../src/index';
+import { render } from '../helpers/helpers';
+
+test('Video.js UI components control the media ReactPlayer renders', async () => {
+ const mediaRef = React.createRef();
+ const wrapper = render(
+
+
+
+
+ );
+ expect(mediaRef.current?.paused).toBe(true);
+
+ await act(async () => {
+ wrapper.root.findByType('button').props.onClick({ preventDefault() {}, currentTarget: {} });
+ });
+
+ expect(mediaRef.current?.paused).toBe(false);
+});
diff --git a/test/codemods/v4.test.ts b/test/codemods/v4.test.ts
new file mode 100644
index 000000000..9a39df4de
--- /dev/null
+++ b/test/codemods/v4.test.ts
@@ -0,0 +1,476 @@
+import jscodeshift from 'jscodeshift';
+import { describe, expect, test } from 'vite-plus/test';
+
+import transform, { parser, toTwitchTime } from '../../codemods/v4';
+
+/** Strips the indentation of a template literal, so sources and expected outputs can be indented with the tests. */
+const dedent = (code: string) => {
+ const lines = code
+ .replace(/^\n/, '')
+ .replace(/\n\s*$/, '')
+ .split('\n');
+ const indent = Math.min(...lines.filter((line) => line.trim()).map((line) => line.match(/^ */)![0].length));
+ return lines.map((line) => line.slice(indent)).join('\n');
+};
+
+const run = (source: string) => {
+ const reports: string[] = [];
+ const j = jscodeshift.withParser(parser);
+ const output = transform(
+ { path: 'App.tsx', source: dedent(source) },
+ { j, jscodeshift: j, stats: () => {}, report: (message: string) => reports.push(message) }
+ );
+ return { output, reports };
+};
+
+const migrate = (source: string) => run(source).output;
+
+describe('ref', () => {
+ test('ref => mediaRef', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ const Player = ({ src }) => {
+ const playerRef = useRef(null);
+ const seek = () => {
+ playerRef.current.currentTime = 30;
+ };
+ return ;
+ };
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ const Player = ({ src }) => {
+ const playerRef = useRef(null);
+ const seek = () => {
+ playerRef.current.currentTime = 30;
+ };
+ return ;
+ };
+ `)
+ );
+ });
+
+ test('callback refs and forwarded refs', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ const A = () => console.log(player)} />;
+ const B = forwardRef((props, ref) => );
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ const A = () => console.log(player)} />;
+ const B = forwardRef((props, ref) => );
+ `)
+ );
+ });
+
+ test('keeps ref when mediaRef is already set', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBeUndefined();
+ });
+
+ test('api => engine', () => {
+ const { output, reports } = run(`
+ import ReactPlayer from 'react-player';
+
+ function Player() {
+ const playerRef = useRef(null);
+ const onReady = () => {
+ playerRef.current?.api.setQuality('hd');
+ const player = playerRef.current;
+ console.log(player.api);
+ };
+ return ;
+ }
+ `);
+ expect(output).toContain('playerRef.current?.engine.setQuality');
+ expect(output).toContain('console.log(player.engine)');
+ expect(output).not.toContain('TODO');
+ expect(reports).toHaveLength(0);
+ });
+
+ test('flags DOM access through the ref', () => {
+ const { output } = run(`
+ import ReactPlayer from 'react-player';
+ import screenfull from 'screenfull';
+
+ class Player extends React.Component {
+ player = React.createRef();
+ fullscreen = () => screenfull.request(this.player.current);
+ box = () => this.player.current.getBoundingClientRect();
+ render() {
+ return ;
+ }
+ }
+ `);
+ expect(output).toMatch(/\/\/ TODO\(react-player v4\): `mediaRef` is the media.*\n\s+fullscreen =/);
+ expect(output).toMatch(/\/\/ TODO\(react-player v4\): `mediaRef` is the media.*\n\s+box =/);
+ expect(output).toContain(' ');
+ });
+});
+
+describe('config', () => {
+ test('renames hls and dash, and removes html', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ );
+ });
+
+ test('shorthand and string keys', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ );
+ });
+
+ test('follows a const, useMemo and the Config type', () => {
+ const output = migrate(`
+ import ReactPlayer from 'react-player';
+ import type { Config } from 'react-player/types';
+
+ const config = { hls: { debug: true } };
+ const shared: Config = { dash: {} };
+ const other = { hls: {} } satisfies Config;
+
+ function Player() {
+ const memoized = useMemo(() => ({ hls: { debug } }), [debug]);
+ return (
+ <>
+
+
+ >
+ );
+ }
+ `);
+ expect(output).toContain('const config = { hlsJs: { debug: true } };');
+ expect(output).toContain('const shared: Config = { dashJs: {} };');
+ expect(output).toContain('const other = { hlsJs: {} } satisfies Config;');
+ expect(output).toContain('useMemo(() => ({ hlsJs: { debug } }), [debug])');
+ });
+
+ test('flags a config it cannot follow', () => {
+ const { output, reports } = run(`
+ import ReactPlayer from 'react-player';
+
+ const Player = (props) => ;
+ `);
+ expect(output).toMatch(/\/\/ TODO\(react-player v4\): config keys are named.*\nconst Player =/);
+ expect(reports).toHaveLength(1);
+
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ const Player = ({ src, config }) => (
+
+ );
+ `)
+ ).toMatch(/\/\* TODO\(react-player v4\): config keys are named.* \*\/\n\s+config=\{config\}/);
+ });
+
+ test('spotify, tiktok and twitch options', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ );
+ });
+
+ test('spotify light theme is removed', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ );
+ });
+
+ test('flags twitch time it cannot convert', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toContain("// TODO(react-player v4): config.twitch.time is a Twitch timestamp string such as '1h30m10s'");
+ });
+
+ test('flags Mux Player options in config.mux', () => {
+ const flagged = migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `);
+ expect(flagged).toContain('// TODO(react-player v4): config.mux now takes MuxSource options');
+
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBeUndefined();
+ });
+});
+
+describe('react-player/patterns', () => {
+ test('canPlay.key => canPlay(key)', () => {
+ expect(
+ migrate(`
+ import { canPlay } from 'react-player/patterns';
+
+ const isYouTube = canPlay.youtube(url);
+ const isVimeo = canPlay['vimeo'](url);
+ const check = canPlay[key];
+ const players = [{ key: 'hls', canPlay: canPlay.hls }];
+ `)
+ ).toBe(
+ dedent(`
+ import { canPlay } from 'react-player/patterns';
+
+ const isYouTube = canPlay('youtube')(url);
+ const isVimeo = canPlay('vimeo')(url);
+ const check = canPlay(key);
+ const players = [{ key: 'hls', canPlay: canPlay('hls') }];
+ `)
+ );
+ });
+
+ test('MATCH_URL_*.test(url) => canPlay(key)(url)', () => {
+ expect(
+ migrate(`
+ import { MATCH_URL_YOUTUBE, HLS_EXTENSIONS, VIDEO_EXTENSIONS } from 'react-player/patterns';
+
+ const isYouTube = MATCH_URL_YOUTUBE.test(url);
+ const isHls = HLS_EXTENSIONS.test(url);
+ const isVideo = VIDEO_EXTENSIONS.test(url);
+ `)
+ ).toBe(
+ dedent(`
+ import { VIDEO_EXTENSIONS, canPlay } from 'react-player/patterns';
+
+ const isYouTube = canPlay('youtube')(url);
+ const isHls = canPlay('hls')(url);
+ const isVideo = VIDEO_EXTENSIONS.test(url);
+ `)
+ );
+ });
+
+ test('copies a removed regex that is used some other way', () => {
+ const { output, reports } = run(`
+ // Header comment
+ import { MATCH_URL_YOUTUBE } from 'react-player/patterns';
+ import other from 'other';
+
+ const id = url.match(MATCH_URL_YOUTUBE)?.[1];
+ `);
+ expect(output).toBe(
+ dedent(`
+ // Header comment
+ import other from 'other';
+
+ // TODO(react-player v4): react-player/patterns no longer exports MATCH_URL_YOUTUBE, so this is a copy of the v3 regex. To check a URL the way ReactPlayer does, use canPlay('youtube')(url).
+ const MATCH_URL_YOUTUBE = /(?:youtu\\.be\\/|youtube(?:-nocookie|education)?\\.com\\/(?:embed\\/|v\\/|watch\\/|watch\\?v=|watch\\?.+&v=|shorts\\/|live\\/))((\\w|-){11})|youtube\\.com\\/playlist\\?list=|youtube\\.com\\/user\\//;
+
+ const id = url.match(MATCH_URL_YOUTUBE)?.[1];
+ `)
+ );
+ expect(reports).toHaveLength(1);
+ });
+
+ test('aliases canPlay when the name is taken', () => {
+ expect(
+ migrate(`
+ import { MATCH_URL_VIMEO } from 'react-player/patterns';
+
+ const canPlay = (url) => MATCH_URL_VIMEO.test(url);
+ `)
+ ).toBe(
+ dedent(`
+ import { canPlay as canPlayPlayer } from 'react-player/patterns';
+
+ const canPlay = (url) => canPlayPlayer('vimeo')(url);
+ `)
+ );
+ });
+});
+
+describe('custom players', () => {
+ test('removes name and flags the new contract', () => {
+ const output = migrate(`
+ import ReactPlayer from 'react-player';
+
+ ReactPlayer.addCustomPlayer({
+ key: 'custom',
+ name: 'Custom',
+ canPlay: (src) => src.startsWith('custom:'),
+ player: CustomPlayer,
+ });
+ `);
+ expect(output).toBe(
+ dedent(`
+ import ReactPlayer from 'react-player';
+
+ // TODO(react-player v4): Custom players follow the Video.js v10 media contract: forward \`ref\` to the element you render, hand the media to the \`mediaRef\` prop, and read options from the whole \`config\` object. See MIGRATING.md.
+ ReactPlayer.addCustomPlayer({
+ key: 'custom',
+ canPlay: (src) => src.startsWith('custom:'),
+ player: CustomPlayer,
+ });
+ `)
+ );
+ });
+
+ test('removes name from PlayerEntry objects', () => {
+ expect(
+ migrate(`
+ import type { PlayerEntry } from 'react-player/players';
+
+ const entries: PlayerEntry[] = [{ key: 'a', name: 'A', canPlay }];
+ `)
+ ).toBe(
+ dedent(`
+ import type { PlayerEntry } from 'react-player/players';
+
+ const entries: PlayerEntry[] = [{
+ key: 'a',
+ canPlay
+ }];
+ `)
+ );
+ });
+});
+
+describe('files', () => {
+ test('next/dynamic and lazy imports', () => {
+ expect(
+ migrate(`
+ const ReactPlayer = dynamic(() => import('react-player'), { ssr: false });
+
+ ;
+ `)
+ ).toBe(
+ dedent(`
+ const ReactPlayer = dynamic(() => import('react-player'), { ssr: false });
+
+ ;
+ `)
+ );
+ });
+
+ test('aliased default import', () => {
+ expect(
+ migrate(`
+ import Video from 'react-player';
+
+ ;
+ `)
+ ).toContain(' ');
+ });
+
+ test('keeps double quotes', () => {
+ expect(
+ migrate(`
+ import ReactPlayer from "react-player";
+
+ ;
+ `)
+ ).toContain(`time: "1m30s"`);
+ });
+
+ test('leaves other files and other elements alone', () => {
+ expect(migrate(`import Other from 'other';\n\n ;`)).toBeUndefined();
+ expect(
+ migrate(`
+ import ReactPlayer from 'react-player';
+
+ ;
+ `)
+ ).toBeUndefined();
+ });
+});
+
+test('toTwitchTime()', () => {
+ expect(toTwitchTime(0)).toBe('0s');
+ expect(toTwitchTime(45)).toBe('45s');
+ expect(toTwitchTime(90)).toBe('1m30s');
+ expect(toTwitchTime(3600)).toBe('1h0m0s');
+ expect(toTwitchTime(5410.7)).toBe('1h30m10s');
+});
diff --git a/test/helpers/helpers.tsx b/test/helpers/helpers.tsx
index 7de2fa70c..3cf838d72 100644
--- a/test/helpers/helpers.tsx
+++ b/test/helpers/helpers.tsx
@@ -1,7 +1,5 @@
-import { test } from 'zora'
-import sinon from 'sinon'
-import React from 'react'
-import { ReactTestRenderer, act, create } from 'react-test-renderer'
+import React from 'react';
+import { ReactTestRenderer, act, create } from 'react-test-renderer';
export function render(comp: React.ReactElement): ReactTestRenderer {
let result;
@@ -20,27 +18,27 @@ export function render(comp: React.ReactElement): ReactTestRenderer {
return result;
}
-export function containsMatchingElement (wrapper, comp) {
- return isObjectContained(create(comp).toJSON(), wrapper.toJSON())
+export function containsMatchingElement(wrapper, comp) {
+ return isObjectContained(create(comp).toJSON(), wrapper.toJSON());
}
-export function isObjectContained (subObject, jsonObject) {
+export function isObjectContained(subObject, jsonObject) {
if (typeof subObject !== 'object' || typeof jsonObject !== 'object') {
- return false
+ return false;
}
for (const key in subObject) {
if (!(key in jsonObject)) {
- return false
+ return false;
}
if (typeof subObject[key] === 'object' && typeof jsonObject[key] === 'object') {
if (!isObjectContained(subObject[key], jsonObject[key])) {
- return false
+ return false;
}
} else if (subObject[key] !== jsonObject[key]) {
- return false
+ return false;
}
}
- return true
+ return true;
}
diff --git a/test/helpers/server-safe-globals.js b/test/helpers/server-safe-globals.js
index 471848c9f..3ea1742f9 100644
--- a/test/helpers/server-safe-globals.js
+++ b/test/helpers/server-safe-globals.js
@@ -1,17 +1,11 @@
-// Prevent from Node.Timeout to hang the process
-const oldSetTimeout = globalThis.setTimeout;
-// @ts-ignore
-globalThis.setTimeout = (callback, delay) => {
- const timeout = oldSetTimeout(callback, delay);
- oldSetTimeout(() => {
- timeout.unref();
- }, 100);
-};
-
class Element extends EventTarget {
style = {};
querySelector = () => new Element();
contains = () => true;
+ setAttribute() {}
+ getAttribute() {
+ return null;
+ }
}
class HTMLVideoElement extends Element {
@@ -99,12 +93,16 @@ const document = {
const globalThisShim = {
location: { origin: 'origin' },
- navigator: {},
- URL: { createObjectURL: () => 'mockObjectURL' },
+ navigator: { userAgent: 'node' },
document,
MediaStream,
};
globalThis.document = document;
-globalThis.window = globalThisShim;
-Object.assign(globalThis, globalThisShim);
+// Keep Node's real `URL`; the test runner relies on it.
+globalThis.window = { ...globalThisShim, URL };
+
+// Node 21+ defines some of these (e.g. `navigator`) as getter-only globals, so assignment would throw.
+for (const [key, value] of Object.entries(globalThisShim)) {
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
+}
diff --git a/test/sources.test.tsx b/test/sources.test.tsx
new file mode 100644
index 000000000..e1da80c1b
--- /dev/null
+++ b/test/sources.test.tsx
@@ -0,0 +1,71 @@
+import { expect, test } from 'vite-plus/test';
+
+import { engineSource, muxSource, wistiaSource } from '../src/sources';
+
+test('engineSource() - src only', () => {
+ expect(engineSource('https://youtu.be/abc', undefined)).toEqual({
+ source: { src: 'https://youtu.be/abc', engine: {} },
+ });
+});
+
+test('engineSource() - config becomes source.engine, without mux and wistia', () => {
+ expect(
+ engineSource('https://example.com/a.m3u8', {
+ hlsJs: { debug: true },
+ youtube: { color: 'white' },
+ mux: { poster: { time: 5 } },
+ wistia: { playerColor: 'ff0000' },
+ })
+ ).toEqual({
+ source: {
+ src: 'https://example.com/a.m3u8',
+ engine: { hlsJs: { debug: true }, youtube: { color: 'white' } },
+ },
+ });
+});
+
+test('muxSource() - bare stream URL becomes a playback id', () => {
+ expect(muxSource('https://stream.mux.com/abc123', undefined)).toEqual({
+ source: { playbackId: 'abc123', engine: {} },
+ });
+});
+
+test('muxSource() - query params become playback params', () => {
+ expect(muxSource('https://stream.mux.com/abc123?max_resolution=720p&token=jwt', undefined)).toEqual({
+ source: { playbackId: 'abc123', engine: {}, playback: { maxResolution: '720p', token: 'jwt' } },
+ });
+});
+
+test('muxSource() - config.mux is layered on top, with the engine options', () => {
+ expect(
+ muxSource('https://stream.mux.com/abc123?max_resolution=720p', {
+ hlsJs: { maxBufferLength: 60 },
+ mux: { playback: { maxResolution: '1080p' }, poster: { time: 5 } },
+ })
+ ).toEqual({
+ source: {
+ playbackId: 'abc123',
+ engine: { hlsJs: { maxBufferLength: 60 } },
+ playback: { maxResolution: '1080p' },
+ poster: { time: 5 },
+ },
+ });
+});
+
+test('muxSource() - non-Mux URL is passed through', () => {
+ expect(muxSource('https://example.com/video.m3u8', { mux: { drm: { token: 'x' } } })).toEqual({
+ source: { src: 'https://example.com/video.m3u8', engine: {}, drm: { token: 'x' } },
+ });
+});
+
+test('wistiaSource()', () => {
+ expect(
+ wistiaSource('https://home.wistia.com/medias/e4a27b971d', {
+ youtube: { color: 'white' },
+ wistia: { playerColor: 'ff0000' },
+ })
+ ).toEqual({
+ src: 'https://home.wistia.com/medias/e4a27b971d',
+ source: { playerColor: 'ff0000' },
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 21b7f6a9c..cf09f5dd5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,5 @@
{
"compilerOptions": {
- "preserveWatchOutput": true,
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
@@ -12,11 +11,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
- "incremental": true,
"declaration": true,
- "emitDeclarationOnly": true,
"rootDir": "src",
- "outDir": "dist",
+ "outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 000000000..2d904212e
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,62 @@
+import { defineConfig } from 'vite-plus';
+
+const ignoredPaths = ['**/coverage/**', '**/demo/**', '**/dist/**', '**/*.md', '**/*.sublime-*', '**/*.code-workspace'];
+
+export default defineConfig({
+ pack: {
+ entry: ['src/**/*.{ts,tsx}'],
+ unbundle: true,
+ format: 'es',
+ platform: 'browser',
+ target: 'es2019',
+ hash: false,
+ sourcemap: false,
+ dts: { generator: 'tsgo' },
+ inputOptions: {
+ // Unbundled output maps each source file to one output file, so `'use client'` stays at the top of it.
+ onLog(level, log, defaultHandler) {
+ if (log.code === 'MODULE_LEVEL_DIRECTIVE') return;
+ defaultHandler(level, log);
+ },
+ },
+ },
+ test: {
+ include: ['test/**/*.test.{js,jsx,ts,tsx}'],
+ coverage: {
+ include: ['src/**'],
+ reporter: ['text', 'json'],
+ },
+ },
+ fmt: {
+ arrowParens: 'always',
+ bracketSpacing: true,
+ ignorePatterns: ignoredPaths,
+ printWidth: 120,
+ semi: true,
+ singleQuote: true,
+ sortImports: true,
+ sortPackageJson: true,
+ tabWidth: 2,
+ trailingComma: 'es5',
+ useTabs: false,
+ overrides: [
+ {
+ files: ['**/*.css'],
+ options: {
+ singleQuote: false,
+ },
+ },
+ ],
+ },
+ lint: {
+ ignorePatterns: ignoredPaths,
+ plugins: ['typescript', 'react'],
+ options: {
+ typeAware: false,
+ typeCheck: false,
+ },
+ },
+ staged: {
+ '*': 'vp check --fix --no-error-on-unmatched-pattern',
+ },
+});