mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): setup react player api (#372)
This commit is contained in:
@@ -1,8 +1,50 @@
|
||||
'use client';
|
||||
|
||||
// Features (re-export for convenience)
|
||||
export { media } from '@videojs/core/dom';
|
||||
// Store
|
||||
export * from '@videojs/store/react';
|
||||
// Media
|
||||
// Re-exports from core/dom
|
||||
export {
|
||||
type BufferState,
|
||||
type FeatureAvailability,
|
||||
features,
|
||||
type Media,
|
||||
type MediaContainer,
|
||||
type PlaybackState,
|
||||
type PlayerTarget,
|
||||
type SourceState,
|
||||
selectBuffer,
|
||||
selectPlayback,
|
||||
selectSource,
|
||||
selectTime,
|
||||
selectVolume,
|
||||
type TimeState,
|
||||
type VolumeState,
|
||||
} from '@videojs/core/dom';
|
||||
|
||||
export type { AnyFeature, Feature, InferFeatureState } from '@videojs/store';
|
||||
|
||||
// Re-exports (for custom features)
|
||||
export { createFeatureSelector, defineFeature } from '@videojs/store';
|
||||
export type { Comparator, Selector } from '@videojs/store/react';
|
||||
|
||||
// Re-exports (for advanced store access)
|
||||
export { useSelector, useStore } from '@videojs/store/react';
|
||||
|
||||
// Media primitives
|
||||
export { Audio, type AudioProps } from './media/audio';
|
||||
export { Video, type VideoProps } from './media/video';
|
||||
export {
|
||||
Container,
|
||||
type ContainerProps,
|
||||
type PlayerContextValue,
|
||||
useMedia,
|
||||
useMediaRegistration,
|
||||
usePlayer,
|
||||
usePlayerContext,
|
||||
} from './player/context';
|
||||
|
||||
// Player API
|
||||
export {
|
||||
type CreatePlayerConfig,
|
||||
type CreatePlayerResult,
|
||||
createPlayer,
|
||||
type ProviderProps,
|
||||
} from './player/create-player';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import type { AudioHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
|
||||
import { useMediaRegistration } from '../player/context';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
export interface AudioProps extends AudioHTMLAttributes<HTMLAudioElement> {}
|
||||
|
||||
export const Audio = forwardRef<HTMLAudioElement, AudioProps>(function Audio({ children, ...props }, ref) {
|
||||
const setMedia = useMediaRegistration();
|
||||
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLAudioElement | null) => {
|
||||
setMedia?.(el);
|
||||
},
|
||||
[setMedia]
|
||||
);
|
||||
|
||||
const composedRef = useComposedRefs(ref, mediaRef);
|
||||
|
||||
return (
|
||||
<audio ref={composedRef} {...props}>
|
||||
{children}
|
||||
</audio>
|
||||
);
|
||||
});
|
||||
|
||||
export namespace Audio {
|
||||
export type Props = AudioProps;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PlayerContextProvider, type PlayerContextValue } from '../../player/context';
|
||||
import { Audio } from '../audio';
|
||||
|
||||
describe('Audio', () => {
|
||||
function createMockStore() {
|
||||
return {
|
||||
state: { volume: 1, muted: false },
|
||||
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('standalone (without Provider)', () => {
|
||||
it('renders without error', () => {
|
||||
const { container } = render(<Audio data-testid="audio" />);
|
||||
const audio = container.querySelector('audio');
|
||||
expect(audio).toBeTruthy();
|
||||
expect(audio?.getAttribute('data-testid')).toBe('audio');
|
||||
});
|
||||
|
||||
it('passes props to audio element', () => {
|
||||
const { container } = render(<Audio src="test.mp3" controls autoPlay />);
|
||||
|
||||
const audio = container.querySelector('audio') as HTMLAudioElement;
|
||||
expect(audio?.getAttribute('src')).toBe('test.mp3');
|
||||
expect(audio?.hasAttribute('controls')).toBe(true);
|
||||
expect(audio?.hasAttribute('autoplay')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { container } = render(
|
||||
<Audio>
|
||||
<source src="test.mp3" type="audio/mpeg" />
|
||||
</Audio>
|
||||
);
|
||||
|
||||
const audio = container.querySelector('audio');
|
||||
expect(audio?.querySelector('source')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = createRef<HTMLAudioElement>();
|
||||
render(<Audio ref={ref} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLAudioElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with Provider', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
expect(setMedia).toHaveBeenCalledWith(expect.any(HTMLAudioElement));
|
||||
});
|
||||
|
||||
it('calls setMedia with null on unmount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
const { unmount } = render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
setMedia.mockClear();
|
||||
unmount();
|
||||
|
||||
expect(setMedia).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('forwards ref while also registering media', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
const ref = createRef<HTMLAudioElement>();
|
||||
render(<Audio ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLAudioElement);
|
||||
expect(setMedia).toHaveBeenCalledWith(ref.current);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,140 +1,100 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { defineFeature } from '@videojs/store';
|
||||
import { createStore, useStoreContext } from '@videojs/store/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PlayerContextProvider, type PlayerContextValue } from '../../player/context';
|
||||
import { Video } from '../video';
|
||||
|
||||
describe('video', () => {
|
||||
class MockMedia extends EventTarget {
|
||||
volume = 1;
|
||||
muted = false;
|
||||
describe('Video', () => {
|
||||
function createMockStore() {
|
||||
return {
|
||||
state: { volume: 1, muted: false },
|
||||
attach: vi.fn(() => vi.fn()),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const mockFeature = defineFeature<MockMedia>()({
|
||||
state: () => ({
|
||||
volume: 1,
|
||||
muted: false,
|
||||
}),
|
||||
getSnapshot: ({ target }) => ({
|
||||
volume: target.volume,
|
||||
muted: target.muted,
|
||||
}),
|
||||
subscribe: () => {},
|
||||
});
|
||||
|
||||
function createTestStore() {
|
||||
return createStore({ features: [mockFeature] });
|
||||
function createWrapper(value: PlayerContextValue) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
it('renders a video element', () => {
|
||||
const { Provider } = createTestStore();
|
||||
describe('standalone (without Provider)', () => {
|
||||
it('renders without error', () => {
|
||||
const { container } = render(<Video data-testid="video" />);
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.getAttribute('data-testid')).toBe('video');
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Provider>
|
||||
<Video data-testid="test-video" />
|
||||
</Provider>
|
||||
);
|
||||
it('passes props to video element', () => {
|
||||
const { container } = render(<Video src="test.mp4" controls autoPlay playsInline />);
|
||||
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.getAttribute('data-testid')).toBe('test-video');
|
||||
});
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
expect(video?.getAttribute('src')).toBe('test.mp4');
|
||||
expect(video?.hasAttribute('controls')).toBe(true);
|
||||
expect(video?.hasAttribute('autoplay')).toBe(true);
|
||||
expect(video?.hasAttribute('playsinline')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes props to video element', () => {
|
||||
const { Provider } = createTestStore();
|
||||
|
||||
const { container } = render(
|
||||
<Provider>
|
||||
<Video src="test.mp4" controls autoPlay playsInline />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
expect(video?.getAttribute('src')).toBe('test.mp4');
|
||||
expect(video?.hasAttribute('controls')).toBe(true);
|
||||
expect(video?.hasAttribute('autoplay')).toBe(true);
|
||||
// playsInline becomes playsinline attribute
|
||||
expect(video?.hasAttribute('playsinline')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { Provider } = createTestStore();
|
||||
|
||||
const { container } = render(
|
||||
<Provider>
|
||||
it('renders children', () => {
|
||||
const { container } = render(
|
||||
<Video>
|
||||
<source src="test.mp4" type="video/mp4" />
|
||||
<track kind="captions" src="captions.vtt" />
|
||||
</Video>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const video = container.querySelector('video');
|
||||
expect(video?.querySelector('source')).toBeTruthy();
|
||||
expect(video?.querySelector('track')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('attaches video to store on mount', () => {
|
||||
const { Provider } = createTestStore();
|
||||
|
||||
let attachCalled = false;
|
||||
|
||||
function TestComponent() {
|
||||
const store = useStoreContext();
|
||||
|
||||
// Spy on attach
|
||||
const originalAttach = store.attach.bind(store);
|
||||
|
||||
store.attach = (target) => {
|
||||
attachCalled = true;
|
||||
return originalAttach(target);
|
||||
};
|
||||
|
||||
return <Video />;
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider>
|
||||
<TestComponent />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(attachCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('works with external ref', () => {
|
||||
const { Provider } = createTestStore();
|
||||
let capturedElement: HTMLVideoElement | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
return (
|
||||
<Video
|
||||
ref={(el) => {
|
||||
capturedElement = el;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider>
|
||||
<TestComponent />
|
||||
</Provider>
|
||||
);
|
||||
const video = container.querySelector('video');
|
||||
expect(video?.querySelector('source')).toBeTruthy();
|
||||
expect(video?.querySelector('track')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(capturedElement).toBeInstanceOf(HTMLVideoElement);
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = createRef<HTMLVideoElement>();
|
||||
render(<Video ref={ref} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLVideoElement);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when used outside Provider', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
describe('with Provider', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
expect(() => {
|
||||
render(<Video />);
|
||||
}).toThrow('useStoreContext must be used within a Provider');
|
||||
render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
expect(setMedia).toHaveBeenCalledWith(expect.any(HTMLVideoElement));
|
||||
});
|
||||
|
||||
it('calls setMedia with null on unmount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
const { unmount } = render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
setMedia.mockClear();
|
||||
unmount();
|
||||
|
||||
expect(setMedia).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('forwards ref while also registering media', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
|
||||
const ref = createRef<HTMLVideoElement>();
|
||||
render(<Video ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLVideoElement);
|
||||
expect(setMedia).toHaveBeenCalledWith(ref.current);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useStoreContext } from '@videojs/store/react';
|
||||
import type { Ref, VideoHTMLAttributes } from 'react';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
|
||||
import { useMediaRegistration } from '../player/context';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
|
||||
ref?: Ref<HTMLVideoElement> | React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {}
|
||||
|
||||
/**
|
||||
* Video element that automatically attaches to the nearest store context.
|
||||
*
|
||||
* Must be used within a Provider created by `createStore()`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { createStore, media } from '@videojs/react';
|
||||
*
|
||||
* const { Provider } = createStore({
|
||||
* features: media.all
|
||||
* });
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Provider>
|
||||
* <Video src="video.mp4" controls />
|
||||
* </Provider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function Video({ children, ref: refProp, ...props }: VideoProps): React.JSX.Element {
|
||||
const store = useStoreContext();
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(function Video({ children, ...props }, ref) {
|
||||
const setMedia = useMediaRegistration();
|
||||
|
||||
const attachRef = useCallback(
|
||||
(el: HTMLVideoElement): (() => void) | void => {
|
||||
if (!el) return;
|
||||
return store.attach(el);
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
setMedia?.(el);
|
||||
},
|
||||
[store]
|
||||
[setMedia]
|
||||
);
|
||||
|
||||
const ref = useComposedRefs(refProp, attachRef);
|
||||
const composedRef = useComposedRefs(ref, mediaRef);
|
||||
|
||||
return (
|
||||
<video ref={ref} {...props}>
|
||||
<video ref={composedRef} {...props}>
|
||||
{children}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export namespace Video {
|
||||
export type Props = VideoProps;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user