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:
rahim
2026-03-19 00:49:40 -07:00
committed by GitHub
co-authored by Wesley Luyten
parent eabd06550d
commit c07da27ead
13 changed files with 143 additions and 52 deletions
+2 -2
View File
@@ -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';
+21 -11
View File
@@ -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
);
+4 -4
View File
@@ -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 {
+6 -6
View File
@@ -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);
+16 -14
View File
@@ -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();
}
+1
View File
@@ -13,6 +13,7 @@ export {
Container,
type ContainerProps,
type PlayerContextValue,
useContainer,
useContainerAttach,
useMedia,
useMediaAttach,
+21 -3
View File
@@ -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) });
+21 -3
View File
@@ -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) });
+8 -1
View File
@@ -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);
+5 -1
View File
@@ -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());
+7 -1
View File
@@ -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,