mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages)!: support media components as markup (#1883)
This commit is contained in:
@@ -158,5 +158,6 @@ export { useAttachMedia } from './utils/use-attach-media';
|
||||
export { composeRefs, useComposedRefs } from './utils/use-composed-refs';
|
||||
export { useDestroy } from './utils/use-destroy';
|
||||
export { useLatestRef } from './utils/use-latest-ref';
|
||||
export { useMediaComponent } from './utils/use-media-component';
|
||||
export { useMediaInstance } from './utils/use-media-instance';
|
||||
export { renderElement } from './utils/use-render';
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import type { DashMediaProps } from '@videojs/media/dom/dash';
|
||||
import { DashMedia, dashMediaDefaultProps } from '@videojs/media/dom/dash';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import type { ReactNode, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useAttachMedia } from '../../utils/use-attach-media';
|
||||
@@ -18,9 +16,7 @@ export interface DashVideoProps
|
||||
}
|
||||
|
||||
export const DashVideo = forwardRef<HTMLVideoElement, DashVideoProps>(function DashVideo({ children, ...props }, ref) {
|
||||
const media = useMediaInstance(DashMedia, (media) => {
|
||||
addMediaComponent(media, new GoogleCast());
|
||||
});
|
||||
const media = useMediaInstance(DashMedia);
|
||||
const attachRef = useAttachMedia(media);
|
||||
const composedRef = useComposedRefs(attachRef, ref);
|
||||
const htmlProps = useSyncProps(media, props, dashMediaDefaultProps);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import type { GoogleCastProps as GoogleCastComponentProps } from '@videojs/media/dom/google-cast';
|
||||
import { GoogleCast as GoogleCastComponent, googleCastDefaultProps } from '@videojs/media/dom/google-cast';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useMediaComponent } from '../../utils/use-media-component';
|
||||
import { useSyncProps } from '../../utils/use-sync-props';
|
||||
|
||||
export type GoogleCastProps = Partial<GoogleCastComponentProps>;
|
||||
|
||||
/**
|
||||
* Adds Google Cast support to the surrounding player's media.
|
||||
*
|
||||
* Renders nothing — place it inside the player provider as a sibling of the
|
||||
* media component (e.g. `<HlsJsVideo />`) and it registers a `GoogleCast`
|
||||
* media component with the active media.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Player.Provider>
|
||||
* <HlsJsVideo src="https://example.com/stream.m3u8" />
|
||||
* <GoogleCast receiver="YOUR_APP_ID" />
|
||||
* </Player.Provider>
|
||||
* ```
|
||||
*/
|
||||
export function GoogleCast(props: GoogleCastProps): ReactNode {
|
||||
const component = useMediaComponent(GoogleCastComponent);
|
||||
|
||||
useSyncProps(component, props, googleCastDefaultProps);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export namespace GoogleCast {
|
||||
export type Props = GoogleCastProps;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './google-cast';
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
|
||||
import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/media/dom/hls-js';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import type { ReactNode, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useAttachMedia } from '../../utils/use-attach-media';
|
||||
@@ -21,9 +19,7 @@ export const HlsJsVideo = forwardRef<HTMLVideoElement, HlsJsVideoProps>(function
|
||||
{ children, ...props },
|
||||
ref
|
||||
) {
|
||||
const media = useMediaInstance(HlsJsMedia, (media) => {
|
||||
addMediaComponent(media, new GoogleCast());
|
||||
});
|
||||
const media = useMediaInstance(HlsJsMedia);
|
||||
const attachRef = useAttachMedia(media);
|
||||
const composedRef = useComposedRefs(attachRef, ref);
|
||||
const htmlProps = useSyncProps(media, props, hlsMediaDefaultProps);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
|
||||
import { hlsMediaDefaultProps } from '@videojs/media/dom/hls-js';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import type { MuxMediaProps } from '@videojs/media/dom/mux';
|
||||
import { MuxData, MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
|
||||
import { MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
|
||||
import type { AudioHTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useAttachMedia } from '../../utils/use-attach-media';
|
||||
@@ -23,10 +21,7 @@ export interface MuxAudioProps
|
||||
const muxAudioDefaultProps: HlsMediaProps & MuxMediaProps = { ...hlsMediaDefaultProps, ...muxMediaDefaultProps };
|
||||
|
||||
export const MuxAudio = forwardRef<HTMLAudioElement, MuxAudioProps>(function MuxAudio({ children, ...props }, ref) {
|
||||
const media = useMediaInstance(MuxMedia, (media) => {
|
||||
addMediaComponent(media, new MuxData({ playerSoftwareName: 'mux-audio' }));
|
||||
addMediaComponent(media, new GoogleCast());
|
||||
});
|
||||
const media = useMediaInstance(MuxMedia);
|
||||
const attachRef = useAttachMedia(media);
|
||||
const composedRef = useComposedRefs(attachRef, ref);
|
||||
const htmlProps = useSyncProps(media, props, muxAudioDefaultProps);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mux-data';
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import type { MuxDataProps as MuxDataComponentProps } from '@videojs/media/dom/mux';
|
||||
import { MuxData as MuxDataComponent, muxDataDefaultProps } from '@videojs/media/dom/mux';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useMediaComponent } from '../../utils/use-media-component';
|
||||
import { useSyncProps } from '../../utils/use-sync-props';
|
||||
|
||||
export type MuxDataProps = Partial<MuxDataComponentProps>;
|
||||
|
||||
/**
|
||||
* Adds [Mux Data](https://www.mux.com/data) monitoring to the surrounding
|
||||
* player's media.
|
||||
*
|
||||
* Renders nothing — place it inside the player provider as a sibling of the
|
||||
* media component (e.g. `<MuxVideo />`) and it registers a `MuxData` media
|
||||
* component with the active media.
|
||||
*
|
||||
* Mux-hosted playback needs no `envKey`: the view reports the Mux playback ID
|
||||
* as its `video_id`, which Mux attributes to the owning environment. Set
|
||||
* `envKey` to monitor sources Mux doesn't host.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Player.Provider>
|
||||
* <MuxVideo source={{ playbackId: 'abc123' }} />
|
||||
* <MuxData playerSoftwareName="mux-video" />
|
||||
* </Player.Provider>
|
||||
* ```
|
||||
*/
|
||||
export function MuxData(props: MuxDataProps): ReactNode {
|
||||
const component = useMediaComponent(MuxDataComponent);
|
||||
const { MuxDataSdk, ...rest } = props;
|
||||
|
||||
// `useSyncProps` treats an `undefined` prop as "reset to the default", but
|
||||
// `MuxDataSdk={undefined}` is how consumers disable monitoring. Sync it here
|
||||
// instead: passing the prop wins even when its value is `undefined`, and only
|
||||
// omitting it falls back to the default SDK.
|
||||
const sdk = 'MuxDataSdk' in props ? MuxDataSdk : muxDataDefaultProps.MuxDataSdk;
|
||||
if (component.MuxDataSdk !== sdk) component.MuxDataSdk = sdk;
|
||||
|
||||
useSyncProps(component, rest, muxDataDefaultProps);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export namespace MuxData {
|
||||
export type Props = MuxDataProps;
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
|
||||
import { hlsMediaDefaultProps, StreamTypes } from '@videojs/media/dom/hls-js';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import type { MuxMediaProps } from '@videojs/media/dom/mux';
|
||||
import { MuxData, MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
|
||||
import { MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
|
||||
import type { ReactNode, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useSyncExternalStore } from 'react';
|
||||
import { useAttachMedia } from '../../utils/use-attach-media';
|
||||
@@ -23,10 +21,7 @@ export interface MuxVideoProps
|
||||
const muxVideoDefaultProps: HlsMediaProps & MuxMediaProps = { ...hlsMediaDefaultProps, ...muxMediaDefaultProps };
|
||||
|
||||
export const MuxVideo = forwardRef<HTMLVideoElement, MuxVideoProps>(function MuxVideo({ children, ...props }, ref) {
|
||||
const media = useMediaInstance(MuxMedia, (media) => {
|
||||
addMediaComponent(media, new MuxData({ playerSoftwareName: 'mux-video' }));
|
||||
addMediaComponent(media, new GoogleCast());
|
||||
});
|
||||
const media = useMediaInstance(MuxMedia);
|
||||
const attachRef = useAttachMedia(media);
|
||||
const composedRef = useComposedRefs(attachRef, ref);
|
||||
const htmlProps = useSyncProps(media, props, muxVideoDefaultProps);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import type { NativeHlsMediaProps } from '@videojs/media/dom/native-hls';
|
||||
import { NativeHlsMedia, nativeHlsMediaDefaultProps } from '@videojs/media/dom/native-hls';
|
||||
import type { ReactNode, VideoHTMLAttributes } from 'react';
|
||||
@@ -21,9 +19,7 @@ export const NativeHlsVideo = forwardRef<HTMLVideoElement, NativeHlsVideoProps>(
|
||||
{ children, ...props },
|
||||
ref
|
||||
) {
|
||||
const media = useMediaInstance(NativeHlsMedia, (media) => {
|
||||
addMediaComponent(media, new GoogleCast());
|
||||
});
|
||||
const media = useMediaInstance(NativeHlsMedia);
|
||||
const attachRef = useAttachMedia(media);
|
||||
const composedRef = useComposedRefs(attachRef, ref);
|
||||
const htmlProps = useSyncProps(media, props, nativeHlsMediaDefaultProps);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import type { Media } from '@videojs/media';
|
||||
import { GoogleCast as GoogleCastComponent } from '@videojs/media/dom/google-cast';
|
||||
import { HlsJsMedia } from '@videojs/media/dom/hls-js';
|
||||
import { getMediaComponents } from '@videojs/media/dom/media-host';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPlayerWrapper } from '../../testing/mocks';
|
||||
import { GoogleCast } from '../google-cast';
|
||||
|
||||
function setup(media: Media | null = new HlsJsMedia()) {
|
||||
const { value, Wrapper } = createPlayerWrapper();
|
||||
value.media = media;
|
||||
return { media, Wrapper };
|
||||
}
|
||||
|
||||
describe('GoogleCast', () => {
|
||||
it('registers a GoogleCast component with the media from context', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
render(<GoogleCast />, { wrapper: Wrapper });
|
||||
|
||||
expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)).toBeInstanceOf(GoogleCastComponent);
|
||||
});
|
||||
|
||||
it('syncs props to the component', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
render(<GoogleCast receiver="APP_ID" contentType="application/x-mpegURL" streamType="live" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const component = getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)!;
|
||||
expect(component.receiver).toBe('APP_ID');
|
||||
expect(component.contentType).toBe('application/x-mpegURL');
|
||||
expect(component.streamType).toBe('live');
|
||||
});
|
||||
|
||||
it('resets a removed prop to its default', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
const { rerender } = render(<GoogleCast receiver="APP_ID" />, { wrapper: Wrapper });
|
||||
rerender(<GoogleCast />);
|
||||
|
||||
expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)!.receiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes the component on unmount', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
const { unmount } = render(<GoogleCast />, { wrapper: Wrapper });
|
||||
unmount();
|
||||
|
||||
expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores media that is not a media host', () => {
|
||||
const video = document.createElement('video') as unknown as Media;
|
||||
const { Wrapper } = setup(video);
|
||||
|
||||
render(<GoogleCast />, { wrapper: Wrapper });
|
||||
|
||||
expect(getMediaComponents(video as any).get(GoogleCastComponent)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import type { Media } from '@videojs/media';
|
||||
import { addMediaComponent, getMediaComponents } from '@videojs/media/dom/media-host';
|
||||
import { MuxData as MuxDataComponent, MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createPlayerWrapper } from '../../testing/mocks';
|
||||
import { MuxData } from '../mux-data';
|
||||
|
||||
function setup() {
|
||||
const media = new MuxMedia();
|
||||
const { value, Wrapper } = createPlayerWrapper();
|
||||
value.media = media as unknown as Media;
|
||||
return { media, Wrapper };
|
||||
}
|
||||
|
||||
describe('MuxData', () => {
|
||||
it('registers a MuxData component with the media from context', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
render(<MuxData />, { wrapper: Wrapper });
|
||||
|
||||
expect(getMediaComponents(media).get(MuxDataComponent)).toBeInstanceOf(MuxDataComponent);
|
||||
});
|
||||
|
||||
it('syncs props to the component', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
render(<MuxData envKey="test-key" playerSoftwareName="mux-video" disableCookies />, { wrapper: Wrapper });
|
||||
|
||||
const component = getMediaComponents(media).get(MuxDataComponent)!;
|
||||
expect(component.envKey).toBe('test-key');
|
||||
expect(component.playerSoftwareName).toBe('mux-video');
|
||||
expect(component.disableCookies).toBe(true);
|
||||
});
|
||||
|
||||
it('disables monitoring when MuxDataSdk is explicitly undefined', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
const MuxDataSdk = {
|
||||
monitor: vi.fn(),
|
||||
utils: { now: () => 0 },
|
||||
} as unknown as NonNullable<MuxDataComponent['MuxDataSdk']>;
|
||||
|
||||
const { rerender } = render(<MuxData MuxDataSdk={MuxDataSdk} />, { wrapper: Wrapper });
|
||||
const component = getMediaComponents(media).get(MuxDataComponent)!;
|
||||
expect(component.MuxDataSdk).toBe(MuxDataSdk);
|
||||
|
||||
rerender(<MuxData MuxDataSdk={undefined} />);
|
||||
expect(component.MuxDataSdk).toBeUndefined();
|
||||
|
||||
rerender(<MuxData />);
|
||||
expect(component.MuxDataSdk).toBeDefined();
|
||||
});
|
||||
|
||||
it('resets a removed prop to its default', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
const { rerender } = render(<MuxData disableCookies />, { wrapper: Wrapper });
|
||||
rerender(<MuxData />);
|
||||
|
||||
expect(getMediaComponents(media).get(MuxDataComponent)!.disableCookies).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the component alive when the media host is destroyed while mounted', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
const destroy = vi.spyOn(MuxDataComponent.prototype, 'destroy');
|
||||
|
||||
render(<MuxData />, { wrapper: Wrapper });
|
||||
const component = getMediaComponents(media).get(MuxDataComponent)!;
|
||||
|
||||
media.destroy();
|
||||
|
||||
// The host detaches and unregisters components it doesn't own; this one is
|
||||
// owned by the still-mounted `MuxData` and follows the next media.
|
||||
expect(destroy).not.toHaveBeenCalled();
|
||||
expect(getMediaComponents(media).get(MuxDataComponent)).toBeUndefined();
|
||||
|
||||
const next = new MuxMedia();
|
||||
addMediaComponent(next, component);
|
||||
expect(getMediaComponents(next).get(MuxDataComponent)).toBe(component);
|
||||
|
||||
destroy.mockRestore();
|
||||
});
|
||||
|
||||
it('removes the component on unmount', () => {
|
||||
const { media, Wrapper } = setup();
|
||||
|
||||
const { unmount } = render(<MuxData />, { wrapper: Wrapper });
|
||||
unmount();
|
||||
|
||||
expect(getMediaComponents(media).get(MuxDataComponent)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,15 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { HlsJsMedia } from '@videojs/media/dom/hls-js';
|
||||
import { MuxData, MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MuxVideo } from '../mux-video';
|
||||
|
||||
describe('MuxVideo', () => {
|
||||
it('routes component config to the MuxData component', () => {
|
||||
const envKey = vi.spyOn(MuxData.prototype, 'envKey', 'set');
|
||||
it('does not spread the config prop onto the element', () => {
|
||||
const { container } = render(<MuxVideo config={{ preferPlayback: 'native' }} />);
|
||||
|
||||
// `useSyncProps` writes `media.config` during render, before the mount
|
||||
// effect registers the components — `addMediaComponent` adopts the early value.
|
||||
const { container } = render(<MuxVideo config={{ muxData: { envKey: 'test-key' } }} />);
|
||||
|
||||
expect(envKey).toHaveBeenCalledWith('test-key');
|
||||
// The config prop is consumed by the media, not spread onto the element.
|
||||
expect(container.querySelector('video')!.hasAttribute('config')).toBe(false);
|
||||
|
||||
envKey.mockRestore();
|
||||
});
|
||||
|
||||
it('does not reinitialize mux data when the same config is re-rendered', () => {
|
||||
const reinit = vi.spyOn(MuxData.prototype, 'envKey', 'set');
|
||||
|
||||
const { rerender } = render(<MuxVideo config={{ muxData: { envKey: 'test-key' } }} />);
|
||||
rerender(<MuxVideo config={{ muxData: { envKey: 'test-key' } }} />);
|
||||
|
||||
// The setter runs per assignment but dedupes same values internally;
|
||||
// assert it was only handed the same value.
|
||||
expect(reinit).toHaveBeenCalledWith('test-key');
|
||||
expect(reinit.mock.calls.every(([value]) => value === 'test-key')).toBe(true);
|
||||
|
||||
reinit.mockRestore();
|
||||
});
|
||||
|
||||
it('derives the media src from the source prop', () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLMediaTargetLike, MediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { addMediaComponent, HTMLMediaElementHost } from '@videojs/media/dom/media-host';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useMedia } from '../player/context';
|
||||
import { useDestroy } from './use-destroy';
|
||||
|
||||
/**
|
||||
* Create a media component (e.g. `GoogleCast`, `MuxData`) and register it
|
||||
* with the media provided by the surrounding player context.
|
||||
*
|
||||
* Instantiates the component class once, registers it when a media host is
|
||||
* available, follows the media when it changes, and destroys the component
|
||||
* on unmount. Media that is not a media host (e.g. a plain `<video>`
|
||||
* element) cannot carry media components and is ignored.
|
||||
*/
|
||||
export function useMediaComponent<Component extends MediaComponent & { destroy(): void }>(
|
||||
ComponentClass: new () => Component
|
||||
): Component {
|
||||
const media = useMedia();
|
||||
const [component] = useState(() => new ComponentClass());
|
||||
|
||||
useDestroy(component);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(media instanceof HTMLMediaElementHost)) return;
|
||||
return addMediaComponent(media as HTMLMediaElementHost<HTMLMediaTargetLike, any>, component);
|
||||
}, [media, component]);
|
||||
|
||||
return component;
|
||||
}
|
||||
Reference in New Issue
Block a user