feat(react): setup react player api (#372)

This commit is contained in:
rahim
2026-02-02 14:08:31 +11:00
committed by GitHub
parent 1bde6e950a
commit d28dda114c
15 changed files with 728 additions and 509 deletions
+75
View File
@@ -0,0 +1,75 @@
'use client';
import type { Media } from '@videojs/core/dom';
import type { UnknownState, UnknownStore } from '@videojs/store';
import { useStore } from '@videojs/store/react';
import type { Dispatch, HTMLAttributes, ReactNode, SetStateAction } from 'react';
import { createContext, forwardRef, useContext, useEffect, useRef } from 'react';
import { useComposedRefs } from '../utils/use-composed-refs';
export interface PlayerContextValue {
store: UnknownStore;
media: Media | null;
setMedia: Dispatch<SetStateAction<Media | null>>;
}
const PlayerContext = createContext<PlayerContextValue | null>(null);
export function PlayerContextProvider({
value,
children,
}: {
value: PlayerContextValue;
children: ReactNode;
}): ReactNode {
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>;
}
export function usePlayerContext(): PlayerContextValue {
const ctx = useContext(PlayerContext);
if (!ctx) throw new Error('usePlayerContext must be used within a Player Provider');
return ctx;
}
export function usePlayer(): UnknownStore;
export function usePlayer<R>(selector: (state: UnknownState) => R): R;
export function usePlayer<R>(selector?: (state: UnknownState) => R) {
const { store } = usePlayerContext();
return useStore(store, selector as any);
}
export function useMedia(): Media | null {
const { media } = usePlayerContext();
return media;
}
export function useMediaRegistration(): Dispatch<SetStateAction<Media | null>> | undefined {
const ctx = useContext(PlayerContext);
return ctx?.setMedia;
}
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export const Container = forwardRef<HTMLDivElement, ContainerProps>(function Container({ children, ...props }, ref) {
const { store, media } = usePlayerContext();
const internalRef = useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, internalRef);
useEffect(() => {
if (!media) return;
return store.attach({ media, container: internalRef.current });
}, [media, store]);
return (
<div ref={composedRef} {...props}>
{children}
</div>
);
});
export namespace Container {
export type Props = ContainerProps;
}
@@ -0,0 +1,65 @@
'use client';
import type { Media, PlayerTarget } from '@videojs/core/dom';
import type { AnyFeature, FeatureStore, UnionFeatureState } from '@videojs/store';
import { createStore } from '@videojs/store';
import { useStore } from '@videojs/store/react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Container, PlayerContextProvider, useMedia, usePlayerContext } from './context';
export interface CreatePlayerConfig<Features extends AnyFeature[]> {
features: Features;
displayName?: string;
}
export interface ProviderProps {
children: ReactNode;
}
export interface CreatePlayerResult<Features extends AnyFeature[]> {
Provider: (props: ProviderProps) => ReactNode;
Container: typeof Container;
usePlayer: UsePlayerHook<Features>;
useMedia: () => Media | null;
}
type UsePlayerHook<Features extends AnyFeature[]> = {
(): FeatureStore<Features>;
<R>(selector: (state: UnionFeatureState<Features>) => R): R;
};
export function createPlayer<const Features extends AnyFeature<PlayerTarget>[]>(
config: CreatePlayerConfig<Features>
): CreatePlayerResult<Features> {
type Store = FeatureStore<Features>;
type State = UnionFeatureState<Features>;
function Provider({ children }: ProviderProps): ReactNode {
const [store] = useState(() => createStore({ features: config.features }));
const [media, setMedia] = useState<Media | null>(null);
useEffect(() => () => store.destroy(), [store]);
return <PlayerContextProvider value={{ store, media, setMedia }}>{children}</PlayerContextProvider>;
}
if (config.displayName) {
Provider.displayName = `${config.displayName}.Provider`;
}
function usePlayer(): Store;
function usePlayer<R>(selector: (state: State) => R): R;
function usePlayer<R>(selector?: (state: State) => R): Store | R {
const { store } = usePlayerContext();
return useStore(store, selector as any);
}
return {
Provider,
Container,
usePlayer: usePlayer as UsePlayerHook<Features>,
useMedia,
};
}
@@ -0,0 +1,156 @@
import { render, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
Container,
PlayerContextProvider,
type PlayerContextValue,
useMedia,
useMediaRegistration,
usePlayer,
usePlayerContext,
} from '../context';
function createMockStore() {
return {
state: { paused: true, volume: 1 },
attach: vi.fn(() => vi.fn()),
subscribe: vi.fn(() => vi.fn()),
destroy: vi.fn(),
};
}
function createWrapper(value: PlayerContextValue) {
return function Wrapper({ children }: { children: ReactNode }) {
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
};
}
describe('usePlayerContext', () => {
it('throws outside Provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => usePlayerContext());
}).toThrow('usePlayerContext must be used within a Player Provider');
consoleSpy.mockRestore();
});
it('returns context value inside Provider', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { result } = renderHook(() => usePlayerContext(), {
wrapper: createWrapper(value),
});
expect(result.current.store).toBe(store);
expect(result.current.media).toBe(null);
});
});
describe('useMediaRegistration', () => {
it('returns undefined outside Provider', () => {
const { result } = renderHook(() => useMediaRegistration());
expect(result.current).toBeUndefined();
});
it('returns setMedia inside Provider', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const { result } = renderHook(() => useMediaRegistration(), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(setMedia);
});
});
describe('usePlayer', () => {
it('returns store without selector', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { result } = renderHook(() => usePlayer(), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(store);
});
});
describe('useMedia', () => {
it('returns media from context', () => {
const store = createMockStore();
const media = document.createElement('video');
const value: PlayerContextValue = { store: store as any, media, setMedia: vi.fn() };
const { result } = renderHook(() => useMedia(), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(media);
});
it('returns null when no media', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { result } = renderHook(() => useMedia(), {
wrapper: createWrapper(value),
});
expect(result.current).toBeNull();
});
});
describe('Container', () => {
it('renders children', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { container } = render(
<PlayerContextProvider value={value}>
<Container>
<span>test</span>
</Container>
</PlayerContextProvider>
);
expect(container.querySelector('span')).toBeTruthy();
});
it('attaches media to store when media is set', () => {
const store = createMockStore();
const media = document.createElement('video');
const value: PlayerContextValue = { store: store as any, media, setMedia: vi.fn() };
render(
<PlayerContextProvider value={value}>
<Container />
</PlayerContextProvider>
);
expect(store.attach).toHaveBeenCalledWith({
media,
container: expect.any(HTMLDivElement),
});
});
it('does not attach when media is null', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
render(
<PlayerContextProvider value={value}>
<Container />
</PlayerContextProvider>
);
expect(store.attach).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,163 @@
import { render, renderHook } from '@testing-library/react';
import { defineFeature } from '@videojs/store';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { createPlayer } from '../create-player';
describe('createPlayer', () => {
// Create a mock feature that works with any target
const mockFeature = defineFeature<any>()({
state: () => ({
volume: 1,
muted: false,
paused: true,
}),
});
describe('Provider', () => {
it('creates store on mount', () => {
const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any });
let store: unknown;
function TestComponent() {
store = usePlayer();
return null;
}
render(
<Provider>
<TestComponent />
</Provider>
);
expect(store).toBeDefined();
expect(typeof (store as any).subscribe).toBe('function');
expect(typeof (store as any).attach).toBe('function');
expect(typeof (store as any).destroy).toBe('function');
});
it('destroys store on unmount', () => {
const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any });
let store: any;
function TestComponent() {
store = usePlayer();
return null;
}
const { unmount } = render(
<Provider>
<TestComponent />
</Provider>
);
const destroySpy = vi.spyOn(store, 'destroy');
unmount();
expect(destroySpy).toHaveBeenCalled();
});
it('uses displayName when provided', () => {
const { Provider } = createPlayer({
features: [mockFeature] as any,
displayName: 'VideoPlayer',
});
expect((Provider as any).displayName).toBe('VideoPlayer.Provider');
});
it('renders children', () => {
const { Provider } = createPlayer({ features: [mockFeature] as any });
const { container } = render(
<Provider>
<span data-testid="child">test</span>
</Provider>
);
expect(container.querySelector('[data-testid="child"]')).toBeTruthy();
});
});
describe('usePlayer', () => {
it('returns store without selector', () => {
const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any });
const wrapper = ({ children }: { children: ReactNode }) => <Provider>{children}</Provider>;
const { result } = renderHook(() => usePlayer(), { wrapper });
expect(result.current).toBeDefined();
expect(typeof result.current.subscribe).toBe('function');
expect(typeof result.current.attach).toBe('function');
});
it('returns selected state with selector', () => {
const { Provider, usePlayer } = createPlayer({ features: [mockFeature] as any });
const wrapper = ({ children }: { children: ReactNode }) => <Provider>{children}</Provider>;
const { result } = renderHook(() => usePlayer((state: any) => state.volume), { wrapper });
expect(result.current).toBe(1);
});
it('throws outside Provider', () => {
const { usePlayer } = createPlayer({ features: [mockFeature] as any });
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => usePlayer());
}).toThrow('usePlayerContext must be used within a Player Provider');
consoleSpy.mockRestore();
});
});
describe('Container', () => {
it('is exported from createPlayer result', () => {
const { Container } = createPlayer({ features: [mockFeature] as any });
expect(Container).toBeDefined();
});
});
describe('full integration', () => {
it('Provider → Container → media attach flow', () => {
const { Provider, Container, usePlayer } = createPlayer({ features: [mockFeature] as any });
let store: any;
function TestComponent() {
store = usePlayer();
return (
<Container data-testid="container">
<video data-testid="video">
<track kind="captions" />
</video>
</Container>
);
}
const { container } = render(
<Provider>
<TestComponent />
</Provider>
);
// Store should exist
expect(store).toBeDefined();
// Container should render
const containerEl = container.querySelector('[data-testid="container"]');
expect(containerEl).toBeTruthy();
// Video should render inside container
const videoEl = container.querySelector('[data-testid="video"]');
expect(videoEl).toBeTruthy();
});
});
});