Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ export type { VolumeIndicatorValueProps } from './ui/volume-indicator/volume-ind
export { VolumePopover } from './ui/volume-popover';
export { VolumeSlider } from './ui/volume-slider';
// Utilities
export {
createMediaComponent,
type MediaAdapterConstructor,
type MediaAdapterProps,
type MediaAdapterTarget,
type MediaComponent,
type MediaComponentOptions,
type MediaComponentProps,
type MediaRenderArgs,
type MediaRenderFunction,
type MediaTargetAttributes,
} from './media/create-media-component';
export { mergeProps } from './utils/merge-props';
export type { HTMLProps, RenderFunction, RenderProp, UIComponentProps } from './utils/types';
export { useAttachMedia } from './utils/use-attach-media';
Expand Down
91 changes: 38 additions & 53 deletions packages/react/src/media/cloudflare-video/adapter.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,47 @@
'use client';

import { buildCloudflareIframeSrc, CloudflareAdapter, type CloudflareAdapterProps } from '@videojs/cloudflare-video';
import { forwardRef, type ReactNode, useState } from 'react';
import { buildCloudflareIframeSrc, CloudflareAdapter } from '@videojs/cloudflare-video';

import { useAttachIframe } from '../../utils/use-attach-iframe';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { useMediaInstance } from '../../utils/use-media-instance';
import { useSyncProps } from '../../utils/use-sync-props';
import { createMediaComponent, type MediaComponentProps } from '../create-media-component';

export interface CloudflareVideoProps extends Partial<CloudflareAdapterProps> {
children?: ReactNode;
}
export type CloudflareVideoProps = MediaComponentProps<typeof CloudflareAdapter>;

export const CloudflareVideo = forwardRef<HTMLIFrameElement, CloudflareVideoProps>(function CloudflareVideo(
{ children, ...rawProps },
ref
) {
const media = useMediaInstance(CloudflareAdapter);
const props: Partial<CloudflareAdapterProps> & Record<string, unknown> = { ...rawProps };
const attachRef = useAttachIframe(media);
const composedRef = useComposedRefs(attachRef, ref);
const [initialSrc] = useState(() =>
// `source.src` is the only other way to name a video, so honor it when `src` is absent.
buildCloudflareIframeSrc(props.src || props.source?.src || '', {
...CloudflareAdapter.defaultProps,
...props,
// The embed reads mute once, out of the URL it is rendered with, and either
// prop says to start muted. Leaving `muted` out of the URL would leave a
// muted autoplay blocked by the browser, since the embed comes up audible.
defaultMuted: !!(props.defaultMuted || props.muted),
})
);
const iframeProps = useSyncProps<CloudflareAdapterProps, Record<string, unknown>>(
media,
props,
CloudflareAdapter.defaultProps
);
export const CloudflareVideo = createMediaComponent(
CloudflareAdapter,
({ adapter, props, children, ref, initialProps }) => {
const initialSrc =
// `source.src` is the only other way to name a video, so honor it when `src` is absent.
buildCloudflareIframeSrc(initialProps.src || initialProps.source?.src || '', {
...CloudflareAdapter.defaultProps,
...initialProps,
// The embed reads mute once, out of the URL it is rendered with, and either
// prop says to start muted. Leaving `muted` out of the URL would leave a
// muted autoplay blocked by the browser, since the embed comes up audible.
defaultMuted: !!(initialProps.defaultMuted || initialProps.muted),
});

return (
<iframe
title="Cloudflare Stream video player"
// Empty means there is no embed to point at yet; React warns about `src=""`,
// and the media builds the URL itself once a source resolves.
src={initialSrc || undefined}
data-cross-origin-frame
allow="accelerometer; fullscreen; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
frameBorder={0}
width="100%"
height="100%"
referrerPolicy={props.source?.engine?.cloudflare?.referrerPolicy}
{...iframeProps}
ref={composedRef}
>
{children}
</iframe>
);
});
return (
<iframe
title="Cloudflare Stream video player"
// Empty means there is no embed to point at yet; React warns about `src=""`,
// and the media builds the URL itself once a source resolves.
src={initialSrc || undefined}
data-cross-origin-frame
allow="accelerometer; fullscreen; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
frameBorder={0}
width="100%"
height="100%"
referrerPolicy={adapter.source?.engine?.cloudflare?.referrerPolicy}
{...props}
ref={ref}
>
{children}
</iframe>
);
},
{ displayName: 'CloudflareVideo' }
);

export namespace CloudflareVideo {
export type Props = CloudflareVideoProps;
Expand Down
133 changes: 133 additions & 0 deletions packages/react/src/media/create-media-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client';

import type { EngineAdapter, Media } from '@videojs/media';
import type { AdapterHost } from '@videojs/media/dom';
import type {
AudioHTMLAttributes,
ForwardRefExoticComponent,
IframeHTMLAttributes,
PropsWithoutRef,
ReactNode,
RefAttributes,
RefCallback,
VideoHTMLAttributes,
} from 'react';
import { forwardRef, useState } from 'react';

import { useAttachMedia } from '../utils/use-attach-media';
import { useComposedRefs } from '../utils/use-composed-refs';
import { useMediaInstance } from '../utils/use-media-instance';
import { useSyncProps } from '../utils/use-sync-props';

/** An adapter class: constructible without arguments, declaring the element it drives, with its defaults as a static. */
export interface MediaAdapterConstructor {
new (): Media & EngineAdapter;
readonly host: AdapterHost;
readonly defaultProps: object;
}

/** The element an adapter renders into, read off its static `host`. */
export type MediaAdapterTarget<Adapter extends MediaAdapterConstructor> = Adapter['host'] extends 'iframe'
? HTMLIFrameElement
: Adapter['host'] extends 'audio'
? HTMLAudioElement
: HTMLVideoElement;

/** The adapter's props: the keys of its static `defaultProps`. */
export type MediaAdapterProps<Adapter extends MediaAdapterConstructor> = Adapter['defaultProps'];

/** The native attributes of the element an adapter renders into. */
export type MediaTargetAttributes<Target> = Target extends HTMLIFrameElement
? IframeHTMLAttributes<HTMLIFrameElement>
: Target extends HTMLVideoElement
? VideoHTMLAttributes<HTMLVideoElement>
: AudioHTMLAttributes<HTMLAudioElement>;

/** Props of a component built by `createMediaComponent`: the target's attributes plus the adapter's props. */
export type MediaComponentProps<Adapter extends MediaAdapterConstructor> = Omit<
MediaTargetAttributes<MediaAdapterTarget<Adapter>>,
keyof MediaAdapterProps<Adapter>
> &
Partial<MediaAdapterProps<Adapter>> & {
children?: ReactNode;
};

/** What the render callback receives on every render. */
export interface MediaRenderArgs<Adapter extends MediaAdapterConstructor> {
/** The adapter instance, already synced with this render's props. */
adapter: InstanceType<Adapter>;
/** Props left after the adapter's were synced onto it: the native attributes to spread on the target. */
props: Omit<MediaTargetAttributes<MediaAdapterTarget<Adapter>>, keyof MediaAdapterProps<Adapter>> &
Record<string, unknown>;
children: ReactNode;
/** Attaches the adapter to the rendered target and forwards the consumer's ref. Put it on the target. */
ref: RefCallback<MediaAdapterTarget<Adapter>>;
/**
* The adapter props from the first render, frozen. For values that must not change once rendered, such as an embed's
* initial `src`, derive them from these rather than from `adapter`.
*/
initialProps: Readonly<Partial<MediaAdapterProps<Adapter>>>;
}

export type MediaRenderFunction<Adapter extends MediaAdapterConstructor> = (
args: MediaRenderArgs<Adapter>
) => ReactNode;

/** A component built by `createMediaComponent`, forwarding its ref to the rendered target. */
export type MediaComponent<Adapter extends MediaAdapterConstructor> = ForwardRefExoticComponent<
PropsWithoutRef<MediaComponentProps<Adapter>> & RefAttributes<MediaAdapterTarget<Adapter>>
>;

export interface MediaComponentOptions {
/** Name shown in React DevTools. Defaults to the adapter's class name. */
displayName?: string;
}

/**
* Build a React component that drives a rendered target through an adapter and registers with the surrounding Player.
*
* The component creates one adapter instance, syncs the adapter's props from the React props of the same name (falling
* back to `Adapter.defaultProps` when a prop is omitted), and calls `render` with the adapter, the remaining native
* props, and a ref that attaches the adapter to whatever element it lands on. The target element is whatever `render`
* returns: a `<video>`, an `<audio>`, or an embed's `<iframe>`. It is what the built-in `HlsJsVideo`, `MuxVideo`, and
* `VimeoVideo` components are made of.
*
* @param Adapter - Adapter class with static `host` and `defaultProps`, for example `HlsJsAdapter` from
* `@videojs/hlsjs-video`.
* @param render - Renders the target element from the adapter, the native props, the children, and the attach ref.
* @param options - `displayName` for React DevTools. Defaults to the adapter's class name.
*/
export function createMediaComponent<Adapter extends MediaAdapterConstructor>(
Adapter: Adapter,
render: MediaRenderFunction<Adapter>,
options: MediaComponentOptions = {}
): MediaComponent<Adapter> {
type Props = MediaAdapterProps<Adapter>;
type Target = MediaAdapterTarget<Adapter>;

const Component = forwardRef<Target, MediaComponentProps<Adapter>>(function MediaComponent(allProps, ref) {
// The conditional attribute type keeps TypeScript from destructuring `children` off the generic props directly.
const { children, ...props } = allProps as { children?: ReactNode } & Record<string, unknown>;
const media = useMediaInstance(Adapter) as InstanceType<Adapter>;
const attachRef = useAttachMedia<Target>(media);
const composedRef = useComposedRefs(attachRef, ref);
const [initialProps] = useState(() => Object.freeze({ ...props }) as Readonly<Partial<Props>>);
const htmlProps = useSyncProps<Props, Record<string, unknown>>(
media as Props,
props as Partial<Props> & Record<string, unknown>,
Adapter.defaultProps as Props
);

return render({
adapter: media,
props: htmlProps as MediaRenderArgs<Adapter>['props'],
children,
ref: composedRef,
initialProps,
});
});

if (__DEV__) Component.displayName = options.displayName ?? Adapter.name;

return Component;
}
30 changes: 10 additions & 20 deletions packages/react/src/media/dash-video/adapter.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
'use client';

import { DashAdapter, type DashAdapterProps } from '@videojs/dash-video';
import { forwardRef, type ReactNode, type VideoHTMLAttributes } from 'react';
import { DashAdapter } from '@videojs/dash-video';

import { useAttachMedia } from '../../utils/use-attach-media';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { useMediaInstance } from '../../utils/use-media-instance';
import { useSyncProps } from '../../utils/use-sync-props';
import { createMediaComponent, type MediaComponentProps } from '../create-media-component';

export interface DashVideoProps
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof DashAdapterProps>, Partial<DashAdapterProps> {
children?: ReactNode;
}

export const DashVideo = forwardRef<HTMLVideoElement, DashVideoProps>(function DashVideo({ children, ...props }, ref) {
const media = useMediaInstance(DashAdapter);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, DashAdapter.defaultProps);
export type DashVideoProps = MediaComponentProps<typeof DashAdapter>;

return (
<video ref={composedRef} {...htmlProps}>
export const DashVideo = createMediaComponent(
DashAdapter,
({ props, children, ref }) => (
<video {...props} ref={ref}>
{children}
</video>
);
});
),
{ displayName: 'DashVideo' }
);

export namespace DashVideo {
export type Props = DashVideoProps;
Expand Down
30 changes: 10 additions & 20 deletions packages/react/src/media/hls-audio/adapter.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
'use client';

import { HlsAudioAdapter, type HlsAudioAdapterProps } from '@videojs/spf/hls-audio';
import { type AudioHTMLAttributes, forwardRef, type ReactNode } from 'react';
import { HlsAudioAdapter } from '@videojs/spf/hls-audio';

import { useAttachMedia } from '../../utils/use-attach-media';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { useMediaInstance } from '../../utils/use-media-instance';
import { useSyncProps } from '../../utils/use-sync-props';
import { createMediaComponent, type MediaComponentProps } from '../create-media-component';

export interface HlsAudioProps
extends Omit<AudioHTMLAttributes<HTMLAudioElement>, keyof HlsAudioAdapterProps>, Partial<HlsAudioAdapterProps> {
children?: ReactNode;
}

export const HlsAudio = forwardRef<HTMLAudioElement, HlsAudioProps>(function HlsAudio({ children, ...props }, ref) {
const media = useMediaInstance(HlsAudioAdapter);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, HlsAudioAdapter.defaultProps);
export type HlsAudioProps = MediaComponentProps<typeof HlsAudioAdapter>;

return (
<audio ref={composedRef} {...htmlProps}>
export const HlsAudio = createMediaComponent(
HlsAudioAdapter,
({ props, children, ref }) => (
<audio {...props} ref={ref}>
{children}
</audio>
);
});
),
{ displayName: 'HlsAudio' }
);

export namespace HlsAudio {
export type Props = HlsAudioProps;
Expand Down
30 changes: 10 additions & 20 deletions packages/react/src/media/hls-video/adapter.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
'use client';

import { HlsVideoAdapter, type HlsVideoAdapterProps } from '@videojs/spf/hls-video';
import { forwardRef, type ReactNode, type VideoHTMLAttributes } from 'react';
import { HlsVideoAdapter } from '@videojs/spf/hls-video';

import { useAttachMedia } from '../../utils/use-attach-media';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { useMediaInstance } from '../../utils/use-media-instance';
import { useSyncProps } from '../../utils/use-sync-props';
import { createMediaComponent, type MediaComponentProps } from '../create-media-component';

export interface HlsVideoProps
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof HlsVideoAdapterProps>, Partial<HlsVideoAdapterProps> {
children?: ReactNode;
}

export const HlsVideo = forwardRef<HTMLVideoElement, HlsVideoProps>(function HlsVideo({ children, ...props }, ref) {
const media = useMediaInstance(HlsVideoAdapter);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, HlsVideoAdapter.defaultProps);
export type HlsVideoProps = MediaComponentProps<typeof HlsVideoAdapter>;

return (
<video ref={composedRef} {...htmlProps}>
export const HlsVideo = createMediaComponent(
HlsVideoAdapter,
({ props, children, ref }) => (
<video {...props} ref={ref}>
{children}
</video>
);
});
),
{ displayName: 'HlsVideo' }
);

export namespace HlsVideo {
export type Props = HlsVideoProps;
Expand Down
Loading
Loading