mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): refactor attach contexts to carry state and setter (#1024)
Co-authored-by: Wesley Luyten <luwes@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { containerAttachContext, playerContext } from '../player/context';
|
||||
import { containerContext, playerContext } from '../player/context';
|
||||
import { createContainerMixin } from '../store/container-mixin';
|
||||
import { MediaElement } from '../ui/media-element';
|
||||
|
||||
const ContainerMixin = createContainerMixin({ playerContext, containerAttachContext });
|
||||
const ContainerMixin = createContainerMixin({ playerContext, containerContext });
|
||||
|
||||
export class MediaContainerElement extends ContainerMixin(MediaElement) {
|
||||
static readonly tagName = 'media-container';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Context, createContext } from '@videojs/element/context';
|
||||
// Player Context
|
||||
// ----------------------------------------
|
||||
|
||||
export const PLAYER_CONTEXT_KEY = Symbol('@videojs/player');
|
||||
export const PLAYER_CONTEXT_KEY = Symbol.for('@videojs/player');
|
||||
|
||||
export type PlayerContextValue<Store extends PlayerStore = AnyPlayerStore> = Store;
|
||||
|
||||
@@ -22,23 +22,33 @@ export type PlayerContext<Store extends PlayerStore = AnyPlayerStore> = Context<
|
||||
export const playerContext = createContext<PlayerContextValue, typeof PLAYER_CONTEXT_KEY>(PLAYER_CONTEXT_KEY);
|
||||
|
||||
// ----------------------------------------
|
||||
// Attach Contexts
|
||||
// Media Context
|
||||
// ----------------------------------------
|
||||
|
||||
export const MEDIA_ATTACH_KEY = Symbol('@videojs/media-attach');
|
||||
export const MEDIA_CONTEXT_KEY = Symbol.for('@videojs/media');
|
||||
|
||||
export type MediaAttachValue = (media: Media | null) => void;
|
||||
export interface MediaContextValue {
|
||||
media: Media | null;
|
||||
setMedia: (media: Media | null) => void;
|
||||
}
|
||||
|
||||
export type MediaAttachContext = Context<typeof MEDIA_ATTACH_KEY, MediaAttachValue>;
|
||||
export type MediaContext = Context<typeof MEDIA_CONTEXT_KEY, MediaContextValue>;
|
||||
|
||||
export const mediaAttachContext = createContext<MediaAttachValue, typeof MEDIA_ATTACH_KEY>(MEDIA_ATTACH_KEY);
|
||||
export const mediaContext = createContext<MediaContextValue, typeof MEDIA_CONTEXT_KEY>(MEDIA_CONTEXT_KEY);
|
||||
|
||||
export const CONTAINER_ATTACH_KEY = Symbol('@videojs/container-attach');
|
||||
// ----------------------------------------
|
||||
// Container Context
|
||||
// ----------------------------------------
|
||||
|
||||
export type ContainerAttachValue = (container: MediaContainer | null) => void;
|
||||
export const CONTAINER_CONTEXT_KEY = Symbol.for('@videojs/container');
|
||||
|
||||
export type ContainerAttachContext = Context<typeof CONTAINER_ATTACH_KEY, ContainerAttachValue>;
|
||||
export interface ContainerContextValue {
|
||||
container: MediaContainer | null;
|
||||
setContainer: (container: MediaContainer | null) => void;
|
||||
}
|
||||
|
||||
export const containerAttachContext = createContext<ContainerAttachValue, typeof CONTAINER_ATTACH_KEY>(
|
||||
CONTAINER_ATTACH_KEY
|
||||
export type ContainerContext = Context<typeof CONTAINER_CONTEXT_KEY, ContainerContextValue>;
|
||||
|
||||
export const containerContext = createContext<ContainerContextValue, typeof CONTAINER_CONTEXT_KEY>(
|
||||
CONTAINER_CONTEXT_KEY
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { combine, createStore } from '@videojs/store';
|
||||
|
||||
import { type ContainerMixin, createContainerMixin } from '../store/container-mixin';
|
||||
import { createProviderMixin, type ProviderMixin } from '../store/provider-mixin';
|
||||
import { containerAttachContext, mediaAttachContext, type PlayerContext, playerContext } from './context';
|
||||
import { containerContext, mediaContext, type PlayerContext, playerContext } from './context';
|
||||
import { PlayerController } from './player-controller';
|
||||
|
||||
export interface CreatePlayerConfig<Features extends AnyPlayerFeature[]> {
|
||||
@@ -89,14 +89,14 @@ export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>): Cr
|
||||
|
||||
const ProviderMixin = createProviderMixin<PlayerStore>({
|
||||
playerContext,
|
||||
mediaAttachContext,
|
||||
containerAttachContext,
|
||||
mediaContext,
|
||||
containerContext,
|
||||
factory: create,
|
||||
});
|
||||
|
||||
const ContainerMixin = createContainerMixin<PlayerStore>({
|
||||
playerContext,
|
||||
containerAttachContext,
|
||||
containerContext,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { MediaContainer, PlayerStore } from '@videojs/core/dom';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
import type { MediaElementConstructor } from '@/ui/media-element';
|
||||
import type { ContainerAttachContext, PlayerContext } from '../player/context';
|
||||
import type { ContainerContext, PlayerContext } from '../player/context';
|
||||
import type { PlayerConsumer, PlayerConsumerConstructor } from './types';
|
||||
|
||||
export interface ContainerMixinConfig<Store extends PlayerStore> {
|
||||
playerContext: PlayerContext<Store>;
|
||||
containerAttachContext: ContainerAttachContext;
|
||||
containerContext: ContainerContext;
|
||||
}
|
||||
|
||||
export type ContainerMixin<Store extends PlayerStore> = <Class extends MediaElementConstructor>(
|
||||
@@ -15,9 +15,9 @@ export type ContainerMixin<Store extends PlayerStore> = <Class extends MediaElem
|
||||
|
||||
/**
|
||||
* Create a mixin that consumes player context and registers itself as the
|
||||
* container element with the provider via `containerAttachContext`.
|
||||
* container element with the provider via `containerContext`.
|
||||
*
|
||||
* @param config - Container configuration with player and attach contexts.
|
||||
* @param config - Container configuration with player and container contexts.
|
||||
*/
|
||||
export function createContainerMixin<Store extends PlayerStore>(
|
||||
config: ContainerMixinConfig<Store>
|
||||
@@ -39,9 +39,9 @@ export function createContainerMixin<Store extends PlayerStore>(
|
||||
});
|
||||
|
||||
new ContextConsumer(this, {
|
||||
context: config.containerAttachContext,
|
||||
context: config.containerContext,
|
||||
callback: (value) => {
|
||||
this.#setContainer = value ?? null;
|
||||
this.#setContainer = value?.setContainer ?? null;
|
||||
if (this.isConnected) this.#setContainer?.(this);
|
||||
},
|
||||
subscribe: true,
|
||||
|
||||
@@ -2,20 +2,20 @@ import type { Media } from '@videojs/core/dom';
|
||||
import { ContextEvent } from '@videojs/element/context';
|
||||
import type { CustomElement } from '@videojs/utils/dom';
|
||||
import type { AnyConstructor, Constructor } from '@videojs/utils/types';
|
||||
import { type MediaAttachContext, mediaAttachContext } from '../player/context';
|
||||
import { type MediaContext, mediaContext } from '../player/context';
|
||||
|
||||
export type MediaAttachMixin = <Class extends AnyConstructor<HTMLElement>>(BaseClass: Class) => Class;
|
||||
|
||||
/**
|
||||
* Create a mixin that consumes `mediaAttachContext` and registers the
|
||||
* Create a mixin that consumes `mediaContext` and registers the
|
||||
* element as the media with the provider.
|
||||
*
|
||||
* Uses the raw context-request protocol so it works with any
|
||||
* `HTMLElement` subclass — no `ReactiveControllerHost` required.
|
||||
*
|
||||
* @param context - The media attach context to consume.
|
||||
* @param context - The media context to consume.
|
||||
*/
|
||||
export function createMediaAttachMixin(context: MediaAttachContext): MediaAttachMixin {
|
||||
export function createMediaAttachMixin(context: MediaContext): MediaAttachMixin {
|
||||
return <Class extends AnyConstructor<HTMLElement>>(BaseClass: Class) => {
|
||||
class MediaAttachElement extends (BaseClass as unknown as Constructor<CustomElement>) {
|
||||
#setMedia: ((media: Media | null) => void) | null = null;
|
||||
@@ -34,7 +34,7 @@ export function createMediaAttachMixin(context: MediaAttachContext): MediaAttach
|
||||
this,
|
||||
(value, unsubscribe) => {
|
||||
if (unsubscribe) this.#unsubscribe = unsubscribe;
|
||||
this.#setMedia = value ?? null;
|
||||
this.#setMedia = value?.setMedia ?? null;
|
||||
if (this.isConnected) {
|
||||
this.#setMedia?.(this.getMediaTarget());
|
||||
}
|
||||
@@ -57,4 +57,4 @@ export function createMediaAttachMixin(context: MediaAttachContext): MediaAttach
|
||||
};
|
||||
}
|
||||
|
||||
export const MediaAttachMixin = createMediaAttachMixin(mediaAttachContext);
|
||||
export const MediaAttachMixin = createMediaAttachMixin(mediaContext);
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { Media, MediaContainer, PlayerStore, PlayerTarget } from '@videojs/
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { isNull } from '@videojs/utils/predicate';
|
||||
import type { MediaElementConstructor } from '@/ui/media-element';
|
||||
import type { ContainerAttachContext, MediaAttachContext, PlayerContext } from '../player/context';
|
||||
import type { ContainerContext, MediaContext, PlayerContext } from '../player/context';
|
||||
import type { PlayerProvider, PlayerProviderConstructor } from './types';
|
||||
|
||||
export interface ProviderMixinConfig<Store extends PlayerStore> {
|
||||
playerContext: PlayerContext<Store>;
|
||||
mediaAttachContext: MediaAttachContext;
|
||||
containerAttachContext: ContainerAttachContext;
|
||||
mediaContext: MediaContext;
|
||||
containerContext: ContainerContext;
|
||||
factory: () => Store;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ export type ProviderMixin<Store extends PlayerStore> = <Class extends MediaEleme
|
||||
* Create a mixin that provides player context to descendant elements and
|
||||
* owns the `store.attach()` lifecycle.
|
||||
*
|
||||
* Media and container elements register themselves via attach contexts —
|
||||
* setter callbacks flowing downward from the provider. When a media element
|
||||
* is available, the provider calls `store.attach({ media, container })`.
|
||||
* Media and container elements register themselves via media/container
|
||||
* contexts that carry both the current value and a setter. When a media
|
||||
* element is available, the provider calls `store.attach({ media, container })`.
|
||||
*
|
||||
* As a fallback for plain `<video>`/`<audio>` that can't consume context,
|
||||
* the provider queries its subtree after a microtask.
|
||||
@@ -43,12 +43,14 @@ export function createProviderMixin<Store extends PlayerStore>(
|
||||
#setMedia = (media: Media | null): void => {
|
||||
if (this.#media === media) return;
|
||||
this.#media = media;
|
||||
this.#mediaProvider.setValue({ media, setMedia: this.#setMedia });
|
||||
this.#tryAttach();
|
||||
};
|
||||
|
||||
#setContainer = (container: MediaContainer | null): void => {
|
||||
if (this.#container === container) return;
|
||||
this.#container = container;
|
||||
this.#containerProvider.setValue({ container, setContainer: this.#setContainer });
|
||||
this.#tryAttach();
|
||||
};
|
||||
|
||||
@@ -57,14 +59,14 @@ export function createProviderMixin<Store extends PlayerStore>(
|
||||
initialValue: this.store,
|
||||
});
|
||||
|
||||
#mediaAttachProvider = new ContextProvider(this, {
|
||||
context: config.mediaAttachContext,
|
||||
initialValue: this.#setMedia,
|
||||
#mediaProvider = new ContextProvider(this, {
|
||||
context: config.mediaContext,
|
||||
initialValue: { media: this.#media, setMedia: this.#setMedia },
|
||||
});
|
||||
|
||||
#containerAttachProvider = new ContextProvider(this, {
|
||||
context: config.containerAttachContext,
|
||||
initialValue: this.#setContainer,
|
||||
#containerProvider = new ContextProvider(this, {
|
||||
context: config.containerContext,
|
||||
initialValue: { container: this.#container, setContainer: this.#setContainer },
|
||||
});
|
||||
|
||||
get store(): Store {
|
||||
@@ -78,8 +80,8 @@ export function createProviderMixin<Store extends PlayerStore>(
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#playerProvider.setValue(this.store);
|
||||
this.#mediaAttachProvider.setValue(this.#setMedia);
|
||||
this.#containerAttachProvider.setValue(this.#setContainer);
|
||||
this.#mediaProvider.setValue({ media: this.#media, setMedia: this.#setMedia });
|
||||
this.#containerProvider.setValue({ container: this.#container, setContainer: this.#setContainer });
|
||||
this.#tryAttach();
|
||||
this.#queueFallbackDiscovery();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
Container,
|
||||
type ContainerProps,
|
||||
type PlayerContextValue,
|
||||
useContainer,
|
||||
useContainerAttach,
|
||||
useMedia,
|
||||
useMediaAttach,
|
||||
|
||||
@@ -62,7 +62,13 @@ describe('Audio', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -72,7 +78,13 @@ describe('Audio', () => {
|
||||
it('calls setMedia with null on unmount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
const { unmount } = render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -85,7 +97,13 @@ describe('Audio', () => {
|
||||
it('forwards ref while also registering media', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
const ref = createRef<HTMLAudioElement>();
|
||||
render(<Audio ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -65,7 +65,13 @@ describe('Video', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -75,7 +81,13 @@ describe('Video', () => {
|
||||
it('calls setMedia with null on unmount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
const { unmount } = render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -88,7 +100,13 @@ describe('Video', () => {
|
||||
it('forwards ref while also registering media', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia,
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
const ref = createRef<HTMLVideoElement>();
|
||||
render(<Video ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { Media } from '@videojs/core/dom';
|
||||
import type { Media, MediaContainer } 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';
|
||||
@@ -12,6 +12,7 @@ export interface PlayerContextValue {
|
||||
store: UnknownStore;
|
||||
media: Media | null;
|
||||
setMedia: Dispatch<SetStateAction<Media | null>>;
|
||||
container: MediaContainer | null;
|
||||
setContainer: Dispatch<SetStateAction<HTMLElement | null>>;
|
||||
}
|
||||
|
||||
@@ -80,6 +81,12 @@ export function useMedia(): Media | null {
|
||||
return media;
|
||||
}
|
||||
|
||||
/** Access the container element from within a Player Provider. */
|
||||
export function useContainer(): MediaContainer | null {
|
||||
const { container } = usePlayerContext();
|
||||
return container;
|
||||
}
|
||||
|
||||
/** Access the media attach setter for connecting a media element to the player. */
|
||||
export function useMediaAttach(): Dispatch<SetStateAction<Media | null>> | undefined {
|
||||
const ctx = useContext(PlayerContext);
|
||||
|
||||
@@ -80,7 +80,11 @@ export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>): Cr
|
||||
return store.attach({ media, container });
|
||||
}, [media, container, store]);
|
||||
|
||||
return <PlayerContextProvider value={{ store, media, setMedia, setContainer }}>{children}</PlayerContextProvider>;
|
||||
return (
|
||||
<PlayerContextProvider value={{ store, media, setMedia, container, setContainer }}>
|
||||
{children}
|
||||
</PlayerContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (__DEV__ && config.displayName) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Container,
|
||||
PlayerContextProvider,
|
||||
type PlayerContextValue,
|
||||
useContainer,
|
||||
useContainerAttach,
|
||||
useMedia,
|
||||
useMediaAttach,
|
||||
@@ -26,6 +27,7 @@ function createContextValue(overrides?: Partial<PlayerContextValue>): PlayerCont
|
||||
store: createMockStore() as any,
|
||||
media: null,
|
||||
setMedia: vi.fn(),
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -73,6 +75,29 @@ describe('useMediaAttach', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContainer', () => {
|
||||
it('returns null when no container', () => {
|
||||
const value = createContextValue();
|
||||
|
||||
const { result } = renderHook(() => useContainer(), {
|
||||
wrapper: createWrapper(value),
|
||||
});
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('returns container from context', () => {
|
||||
const container = document.createElement('div');
|
||||
const value = createContextValue({ container });
|
||||
|
||||
const { result } = renderHook(() => useContainer(), {
|
||||
wrapper: createWrapper(value),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(container);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContainerAttach', () => {
|
||||
it('returns undefined outside Provider', () => {
|
||||
const { result } = renderHook(() => useContainerAttach());
|
||||
|
||||
@@ -34,7 +34,13 @@ export function createMockStore(state: Record<string, unknown> = {}) {
|
||||
*/
|
||||
export function createPlayerWrapper(storeState: Record<string, unknown> = {}) {
|
||||
const store = createMockStore(storeState);
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn(), setContainer: vi.fn() };
|
||||
const value: PlayerContextValue = {
|
||||
store: store as any,
|
||||
media: null,
|
||||
setMedia: vi.fn(),
|
||||
container: null,
|
||||
setContainer: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
|
||||
Reference in New Issue
Block a user