mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(packages): move store attach lifecycle to provider (#975)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -708,8 +708,8 @@ export function useMedia(): Media | null {
|
||||
return media;
|
||||
}
|
||||
|
||||
/** Register a media element (for Video/Audio primitives). Returns undefined if outside provider (standalone media). */
|
||||
export function useMediaRegistration(): Dispatch<SetStateAction<Media | null>> | undefined {
|
||||
/** 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);
|
||||
return ctx?.setMedia;
|
||||
}
|
||||
@@ -725,7 +725,7 @@ Factory that creates typed provider and hooks. Update existing Video component t
|
||||
|
||||
```
|
||||
packages/react/src/player/create-player.tsx (new)
|
||||
packages/react/src/media/video.tsx (update - use useMediaRegistration)
|
||||
packages/react/src/media/video.tsx (update - use useMediaAttach)
|
||||
packages/react/src/index.ts
|
||||
packages/react/src/player/tests/create-player.test.tsx (new)
|
||||
```
|
||||
@@ -809,14 +809,14 @@ export function createPlayer<const Features extends AnyFeature<PlayerTarget>[]>(
|
||||
import type { Ref, VideoHTMLAttributes } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
import { useMediaRegistration } from '../player/context';
|
||||
import { useMediaAttach } from '../player/context';
|
||||
|
||||
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
|
||||
ref?: Ref<HTMLVideoElement> | React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
export function Video({ children, ref: refProp, ...props }: VideoProps): React.JSX.Element {
|
||||
const setMedia = useMediaRegistration();
|
||||
const setMedia = useMediaAttach();
|
||||
|
||||
const attachRef = useCallback(
|
||||
(el: HTMLVideoElement): (() => void) | void => {
|
||||
@@ -1303,7 +1303,7 @@ UI primitives (PlayButton, VolumeSlider, etc.) need store access without knowing
|
||||
- `usePlayer()` — returns current state snapshot (untyped `Record<string, unknown>`)
|
||||
- `usePlayer(selector)` — returns selected state via selector
|
||||
- `useMedia()` — returns current media element
|
||||
- `useMediaRegistration()` — for Video/Audio primitives to register
|
||||
- `useMediaAttach()` — for Video/Audio primitives to attach
|
||||
|
||||
`createPlayer()` wraps this base with typed hooks for app code.
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: decided
|
||||
date: 2026-03-13
|
||||
---
|
||||
|
||||
# Provider Owns Media Attachment
|
||||
|
||||
## Decision
|
||||
|
||||
The provider (`<video-player>` / React `Provider`) owns the `store.attach()` lifecycle. The container (`<media-container>` / React `Container`) no longer discovers media or calls `store.attach()` — it registers itself with the provider via context and serves only as a layout reference element.
|
||||
|
||||
Media and container elements register themselves with the provider through attach contexts — setter callbacks that flow downward from provider to descendants. The provider calls `store.attach({ media, container })` when it has a media element. As a fallback for plain `<video>`/`<audio>` elements that can't consume context, the provider queries its subtree.
|
||||
|
||||
## Context
|
||||
|
||||
The [player-container separation](player-container-separation.md) decision established that the provider owns state and the container handles layout. But the container still owned a critical piece of the store lifecycle: media discovery and `store.attach()`.
|
||||
|
||||
The container discovered media via `querySelector('video, audio')`, duck-type checks for custom media elements, `MutationObserver` watching the subtree, and `slotchange` listeners on `<slot name="media">`. When it found media, it called `store.attach({ media, container: this })` and managed the detach lifecycle.
|
||||
|
||||
This split created friction:
|
||||
|
||||
- The provider creates the store and destroys it, but a descendant controls when state flows through it. The lifecycle is split across two elements.
|
||||
- Setups without a container (audio-only, headless, programmatic) couldn't attach — they needed the container present just to wire up the store.
|
||||
- The container's media discovery logic (MutationObserver, slot queries, duck-typing) was brittle and required users to remember `slot="media"`.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep attach in the container** — Leave the current architecture. Rejected because it perpetuates the split lifecycle and forces container presence for attachment.
|
||||
|
||||
- **Move discovery to the provider via its own DOM queries** — The provider watches its subtree for media elements. Rejected as the primary mechanism because the media element is nested deep (provider > skin > container shadow DOM), making reliable DOM queries fragile. Used only as a fallback for plain `<video>`/`<audio>`.
|
||||
|
||||
- **Event-based registration** — Media elements dispatch a bubbling event that the provider catches. Simpler than context but doesn't handle disconnection cleanly and requires the provider to be in the DOM path (shadow DOM boundaries block event bubbling unless composed).
|
||||
|
||||
## Rationale
|
||||
|
||||
**Unified lifecycle.** The provider already creates and destroys the store. Adding attach/detach means one element controls the full store lifecycle: create → attach → detach → destroy. No split ownership.
|
||||
|
||||
**Container becomes truly dumb.** The container is a reference element — the store uses it for fullscreen, PiP, keyboard focus, and gesture tracking. It doesn't need to know about media discovery or store internals. It registers itself with the provider and renders children.
|
||||
|
||||
**No-container setups work.** Audio-only players, headless stores, and programmatic setups can attach media directly through the provider without requiring a container element in the DOM.
|
||||
|
||||
**Context-based registration matches React.** React already uses this pattern — `<Video>` calls `setMedia` via context, `<Container>` calls `setContainer`. The HTML implementation now mirrors this with `mediaAttachContext` and `containerAttachContext`.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **Provider mixin grows in complexity.** It gains attach lifecycle management, fallback media discovery, and two additional context providers. This is manageable since the logic is straightforward and consolidates previously scattered responsibilities.
|
||||
|
||||
- **Fallback query is a pragmatic compromise.** Plain `<video>` elements can't consume context, so the provider falls back to `querySelector`. This means two discovery paths exist, but the fallback is simple and predictable.
|
||||
@@ -1,8 +1,8 @@
|
||||
import { playerContext } from '../player/context';
|
||||
import { containerAttachContext, playerContext } from '../player/context';
|
||||
import { createContainerMixin } from '../store/container-mixin';
|
||||
import { MediaElement } from '../ui/media-element';
|
||||
|
||||
const ContainerMixin = createContainerMixin(playerContext);
|
||||
const ContainerMixin = createContainerMixin({ playerContext, containerAttachContext });
|
||||
|
||||
export class MediaContainerElement extends ContainerMixin(MediaElement) {
|
||||
static readonly tagName = 'media-container';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AnyPlayerStore, PlayerStore } from '@videojs/core/dom';
|
||||
import type { AnyPlayerStore, Media, MediaContainer, PlayerStore } from '@videojs/core/dom';
|
||||
import { type Context, createContext } from '@videojs/element/context';
|
||||
|
||||
// ----------------------------------------
|
||||
// Player Context
|
||||
// ----------------------------------------
|
||||
|
||||
export const PLAYER_CONTEXT_KEY = Symbol('@videojs/player');
|
||||
|
||||
export type PlayerContextValue<Store extends PlayerStore = AnyPlayerStore> = Store;
|
||||
@@ -16,3 +20,25 @@ export type PlayerContext<Store extends PlayerStore = AnyPlayerStore> = Context<
|
||||
* @public
|
||||
*/
|
||||
export const playerContext = createContext<PlayerContextValue, typeof PLAYER_CONTEXT_KEY>(PLAYER_CONTEXT_KEY);
|
||||
|
||||
// ----------------------------------------
|
||||
// Attach Contexts
|
||||
// ----------------------------------------
|
||||
|
||||
export const MEDIA_ATTACH_KEY = Symbol('@videojs/media-attach');
|
||||
|
||||
export type MediaAttachValue = (media: Media | null) => void;
|
||||
|
||||
export type MediaAttachContext = Context<typeof MEDIA_ATTACH_KEY, MediaAttachValue>;
|
||||
|
||||
export const mediaAttachContext = createContext<MediaAttachValue, typeof MEDIA_ATTACH_KEY>(MEDIA_ATTACH_KEY);
|
||||
|
||||
export const CONTAINER_ATTACH_KEY = Symbol('@videojs/container-attach');
|
||||
|
||||
export type ContainerAttachValue = (container: MediaContainer | null) => void;
|
||||
|
||||
export type ContainerAttachContext = Context<typeof CONTAINER_ATTACH_KEY, ContainerAttachValue>;
|
||||
|
||||
export const containerAttachContext = createContext<ContainerAttachValue, typeof CONTAINER_ATTACH_KEY>(
|
||||
CONTAINER_ATTACH_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 { type PlayerContext, playerContext } from './context';
|
||||
import { containerAttachContext, mediaAttachContext, type PlayerContext, playerContext } from './context';
|
||||
import { PlayerController } from './player-controller';
|
||||
|
||||
export interface CreatePlayerConfig<Features extends AnyPlayerFeature[]> {
|
||||
@@ -31,7 +31,7 @@ export interface CreatePlayerResult<Store extends PlayerStore> {
|
||||
/** Mixin that provides player context to descendants. */
|
||||
ProviderMixin: ProviderMixin<Store>;
|
||||
|
||||
/** Mixin that consumes player context and auto-attaches media elements. */
|
||||
/** Mixin that consumes player context and registers as the container element. */
|
||||
ContainerMixin: ContainerMixin<Store>;
|
||||
}
|
||||
|
||||
@@ -87,8 +87,17 @@ export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>): Cr
|
||||
return createStore<PlayerTarget>()(slice);
|
||||
}
|
||||
|
||||
const ProviderMixin = createProviderMixin<PlayerStore>(playerContext, create);
|
||||
const ContainerMixin = createContainerMixin<PlayerStore>(playerContext);
|
||||
const ProviderMixin = createProviderMixin<PlayerStore>({
|
||||
playerContext,
|
||||
mediaAttachContext,
|
||||
containerAttachContext,
|
||||
factory: create,
|
||||
});
|
||||
|
||||
const ContainerMixin = createContainerMixin<PlayerStore>({
|
||||
playerContext,
|
||||
containerAttachContext,
|
||||
});
|
||||
|
||||
return {
|
||||
context: playerContext,
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import type { MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom';
|
||||
import type { MediaContainer, PlayerStore } from '@videojs/core/dom';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
import { noop } from '@videojs/utils/function';
|
||||
import type { MediaElementConstructor } from '@/ui/media-element';
|
||||
import type { PlayerContext } from '../player/context';
|
||||
import type { ContainerAttachContext, PlayerContext } from '../player/context';
|
||||
import type { PlayerConsumer, PlayerConsumerConstructor } from './types';
|
||||
|
||||
export interface ContainerMixinConfig<Store extends PlayerStore> {
|
||||
playerContext: PlayerContext<Store>;
|
||||
containerAttachContext: ContainerAttachContext;
|
||||
}
|
||||
|
||||
export type ContainerMixin<Store extends PlayerStore> = <Class extends MediaElementConstructor>(
|
||||
BaseClass: Class
|
||||
) => Class & PlayerConsumerConstructor<Store>;
|
||||
|
||||
/**
|
||||
* Create a mixin that consumes player context and auto-attaches media elements.
|
||||
* Create a mixin that consumes player context and registers itself as the
|
||||
* container element with the provider via `containerAttachContext`.
|
||||
*
|
||||
* @param context - Player context to consume from an ancestor provider.
|
||||
* @param config - Container configuration with player and attach contexts.
|
||||
*/
|
||||
export function createContainerMixin<Store extends PlayerStore>(context: PlayerContext<Store>): ContainerMixin<Store> {
|
||||
export function createContainerMixin<Store extends PlayerStore>(
|
||||
config: ContainerMixinConfig<Store>
|
||||
): ContainerMixin<Store> {
|
||||
return <Class extends MediaElementConstructor>(BaseClass: Class) => {
|
||||
class PlayerContainerElement extends BaseClass implements PlayerConsumer<Store>, MediaContainer {
|
||||
#detach = noop;
|
||||
#observer: MutationObserver | null = null;
|
||||
#contextStore: Store | null = null;
|
||||
#setContainer: ((container: MediaContainer | null) => void) | null = null;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
// Created in the constructor body (after all field initializers) so
|
||||
// that #contextStore's private slot exists if the callback fires
|
||||
// synchronously — which happens when the element is already connected.
|
||||
// The host's controller list keeps the consumer alive; no field needed.
|
||||
new ContextConsumer(this, {
|
||||
context,
|
||||
context: config.playerContext,
|
||||
callback: (value) => {
|
||||
this.#contextStore = value ?? null;
|
||||
this.#attachMedia();
|
||||
},
|
||||
subscribe: true,
|
||||
});
|
||||
|
||||
new ContextConsumer(this, {
|
||||
context: config.containerAttachContext,
|
||||
callback: (value) => {
|
||||
this.#setContainer = value ?? null;
|
||||
if (this.isConnected) this.#setContainer?.(this);
|
||||
},
|
||||
subscribe: true,
|
||||
});
|
||||
@@ -44,114 +54,15 @@ export function createContainerMixin<Store extends PlayerStore>(context: PlayerC
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#observer = new MutationObserver((records) => {
|
||||
if (records.some(hasMediaElement)) this.#attachMedia();
|
||||
});
|
||||
|
||||
this.#observer.observe(this, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['name'],
|
||||
});
|
||||
|
||||
// Slotted media elements don't appear in the container's subtree,
|
||||
// so listen for slot reassignments to pick them up.
|
||||
this.addEventListener('slotchange', this.#onSlotChange);
|
||||
|
||||
this.#attachMedia();
|
||||
this.#setContainer?.(this);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = null;
|
||||
this.removeEventListener('slotchange', this.#onSlotChange);
|
||||
this.#detach();
|
||||
}
|
||||
|
||||
#onSlotChange = () => {
|
||||
this.#attachMedia();
|
||||
};
|
||||
|
||||
#getSlottedMedia(): HTMLMediaElement | null {
|
||||
const slot = this.querySelector<HTMLSlotElement>('slot[name="media"]');
|
||||
if (!slot) return null;
|
||||
|
||||
for (const el of slot.assignedElements({ flatten: true })) {
|
||||
if (isMediaElement(el)) return el as HTMLMediaElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#findMediaElement(): HTMLMediaElement | null {
|
||||
const media = Array.from(this.children).find(isMediaElement);
|
||||
if (media) return media as HTMLMediaElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
#attachMedia() {
|
||||
// Prefer the cached context value; fall back to `this.store` which
|
||||
// ProviderMixin overrides when both mixins are applied to one element.
|
||||
const store = this.#contextStore ?? this.store;
|
||||
if (!store) return;
|
||||
|
||||
const media =
|
||||
this.querySelector<HTMLMediaElement>('video, audio') ?? this.#findMediaElement() ?? this.#getSlottedMedia();
|
||||
|
||||
if (!media) {
|
||||
this.#detach();
|
||||
this.#detach = noop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustomMediaElement(media)) {
|
||||
globalThis.customElements?.upgrade?.(media);
|
||||
}
|
||||
|
||||
const target: PlayerTarget = {
|
||||
media,
|
||||
container: this,
|
||||
};
|
||||
|
||||
const hasMediaChanged = store.target?.media !== target.media,
|
||||
hasContainerChanged = store.target?.container !== target.container;
|
||||
|
||||
if (hasMediaChanged || hasContainerChanged) {
|
||||
this.#detach();
|
||||
this.#detach = store.attach(target);
|
||||
}
|
||||
this.#setContainer?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
return PlayerContainerElement;
|
||||
};
|
||||
}
|
||||
|
||||
function isMediaElement(node: Node): boolean {
|
||||
return node instanceof HTMLMediaElement || isCustomMediaElement(node);
|
||||
}
|
||||
|
||||
function isCustomMediaElement(node: Node): boolean {
|
||||
return node instanceof HTMLElement && (node.localName.endsWith('-audio') || node.localName.endsWith('-video'));
|
||||
}
|
||||
|
||||
function isMediaSlotElement(node: Node): boolean {
|
||||
return node instanceof HTMLSlotElement && node.name === 'media';
|
||||
}
|
||||
|
||||
function hasMediaElement(record: MutationRecord): boolean {
|
||||
if (isMediaSlotElement(record.target)) return true;
|
||||
|
||||
for (const node of record.addedNodes) {
|
||||
if (isMediaElement(node) || isMediaSlotElement(node)) return true;
|
||||
}
|
||||
|
||||
for (const node of record.removedNodes) {
|
||||
if (isMediaElement(node) || isMediaSlotElement(node)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,75 @@
|
||||
import type { PlayerStore } from '@videojs/core/dom';
|
||||
import type { Media, MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { isNull } from '@videojs/utils/predicate';
|
||||
import type { MediaElementConstructor } from '@/ui/media-element';
|
||||
import type { PlayerContext } from '../player/context';
|
||||
import type { ContainerAttachContext, MediaAttachContext, PlayerContext } from '../player/context';
|
||||
import type { PlayerProvider, PlayerProviderConstructor } from './types';
|
||||
|
||||
export interface ProviderMixinConfig<Store extends PlayerStore> {
|
||||
playerContext: PlayerContext<Store>;
|
||||
mediaAttachContext: MediaAttachContext;
|
||||
containerAttachContext: ContainerAttachContext;
|
||||
factory: () => Store;
|
||||
}
|
||||
|
||||
export type ProviderMixin<Store extends PlayerStore> = <Class extends MediaElementConstructor>(
|
||||
BaseClass: Class
|
||||
) => Class & PlayerProviderConstructor<Store>;
|
||||
|
||||
/**
|
||||
* Create a mixin that provides player context to descendant elements.
|
||||
* Create a mixin that provides player context to descendant elements and
|
||||
* owns the `store.attach()` lifecycle.
|
||||
*
|
||||
* @param context - Player context to provide to descendants.
|
||||
* @param factory - Factory function that creates a store instance.
|
||||
* 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 })`.
|
||||
*
|
||||
* As a fallback for plain `<video>`/`<audio>` that can't consume context,
|
||||
* the provider queries its subtree after a microtask.
|
||||
*
|
||||
* @param config - Provider configuration with contexts and store factory.
|
||||
*/
|
||||
export function createProviderMixin<Store extends PlayerStore>(
|
||||
context: PlayerContext<Store>,
|
||||
factory: () => Store
|
||||
config: ProviderMixinConfig<Store>
|
||||
): ProviderMixin<Store> {
|
||||
return <Class extends MediaElementConstructor>(BaseClass: Class) => {
|
||||
class PlayerProviderElement extends BaseClass implements PlayerProvider<Store> {
|
||||
#store: Store | null = factory();
|
||||
#store: Store | null = config.factory();
|
||||
#detach: (() => void) | null = null;
|
||||
#media: Media | null = null;
|
||||
#container: MediaContainer | null = null;
|
||||
#fallbackQueued = false;
|
||||
|
||||
#provider = new ContextProvider(this, {
|
||||
context,
|
||||
#setMedia = (media: Media | null): void => {
|
||||
if (this.#media === media) return;
|
||||
this.#media = media;
|
||||
this.#tryAttach();
|
||||
};
|
||||
|
||||
#setContainer = (container: MediaContainer | null): void => {
|
||||
if (this.#container === container) return;
|
||||
this.#container = container;
|
||||
this.#tryAttach();
|
||||
};
|
||||
|
||||
#playerProvider = new ContextProvider(this, {
|
||||
context: config.playerContext,
|
||||
initialValue: this.store,
|
||||
});
|
||||
|
||||
#mediaAttachProvider = new ContextProvider(this, {
|
||||
context: config.mediaAttachContext,
|
||||
initialValue: this.#setMedia,
|
||||
});
|
||||
|
||||
#containerAttachProvider = new ContextProvider(this, {
|
||||
context: config.containerAttachContext,
|
||||
initialValue: this.#setContainer,
|
||||
});
|
||||
|
||||
get store(): Store {
|
||||
if (isNull(this.#store)) {
|
||||
this.#store = factory();
|
||||
this.#store = config.factory();
|
||||
}
|
||||
|
||||
return this.#store;
|
||||
@@ -38,14 +77,69 @@ export function createProviderMixin<Store extends PlayerStore>(
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#provider.setValue(this.store);
|
||||
this.#playerProvider.setValue(this.store);
|
||||
this.#mediaAttachProvider.setValue(this.#setMedia);
|
||||
this.#containerAttachProvider.setValue(this.#setContainer);
|
||||
this.#tryAttach();
|
||||
this.#queueFallbackDiscovery();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.#detachStore();
|
||||
}
|
||||
|
||||
override destroyCallback() {
|
||||
this.#detachStore();
|
||||
this.#store?.destroy();
|
||||
this.#store = null;
|
||||
super.destroyCallback();
|
||||
}
|
||||
|
||||
#tryAttach(): void {
|
||||
const store = this.#store;
|
||||
if (!store) return;
|
||||
|
||||
if (!this.#media) {
|
||||
this.#detachStore();
|
||||
return;
|
||||
}
|
||||
|
||||
const target: PlayerTarget = {
|
||||
media: this.#media,
|
||||
container: this.#container,
|
||||
};
|
||||
|
||||
const hasMediaChanged = store.target?.media !== target.media;
|
||||
const hasContainerChanged = store.target?.container !== target.container;
|
||||
|
||||
if (hasMediaChanged || hasContainerChanged) {
|
||||
this.#detachStore();
|
||||
this.#detach = store.attach(target);
|
||||
}
|
||||
}
|
||||
|
||||
#detachStore(): void {
|
||||
this.#detach?.();
|
||||
this.#detach = null;
|
||||
}
|
||||
|
||||
#queueFallbackDiscovery(): void {
|
||||
if (this.#media || this.#fallbackQueued) return;
|
||||
this.#fallbackQueued = true;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.#fallbackQueued = false;
|
||||
|
||||
// Context already registered media — skip fallback.
|
||||
if (this.#media) return;
|
||||
|
||||
const media = this.querySelector<HTMLMediaElement>('video, audio');
|
||||
if (media) {
|
||||
this.#setMedia(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return PlayerProviderElement;
|
||||
|
||||
@@ -13,8 +13,9 @@ export {
|
||||
Container,
|
||||
type ContainerProps,
|
||||
type PlayerContextValue,
|
||||
useContainerAttach,
|
||||
useMedia,
|
||||
useMediaRegistration,
|
||||
useMediaAttach,
|
||||
usePlayer,
|
||||
usePlayerContext,
|
||||
} from './player/context';
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import type { AudioHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { useMediaRegistration } from '../player/context';
|
||||
import { useMediaAttach } 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);
|
||||
const setMedia = useMediaAttach();
|
||||
const composedRef = useComposedRefs(ref, setMedia);
|
||||
|
||||
return (
|
||||
<audio ref={composedRef} {...props}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
|
||||
import { useMediaRegistration } from '../../player/context';
|
||||
import { useMediaAttach } from '../../player/context';
|
||||
import { useComposedRefs } from '../../utils/use-composed-refs';
|
||||
|
||||
export interface BackgroundVideoProps extends VideoHTMLAttributes<HTMLVideoElement> {}
|
||||
@@ -12,7 +12,7 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
|
||||
{ children, ...props },
|
||||
ref
|
||||
) {
|
||||
const setMedia = useMediaRegistration();
|
||||
const setMedia = useMediaAttach();
|
||||
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DashMedia } from '@videojs/core/dom/media/dash';
|
||||
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { useMediaRegistration } from '../../player/context';
|
||||
import { useMediaAttach } from '../../player/context';
|
||||
import { attachMediaElement } from '../../utils/attach-media-element';
|
||||
import { mediaProps } from '../../utils/media-props';
|
||||
import { useComposedRefs } from '../../utils/use-composed-refs';
|
||||
@@ -11,13 +11,14 @@ export type DashVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoElem
|
||||
|
||||
export const DashVideo = forwardRef<HTMLVideoElement, DashVideoProps>(({ children, ...props }, ref) => {
|
||||
const mediaApi = useMemo(() => new DashMedia(), []);
|
||||
const setMedia = useMediaRegistration();
|
||||
const setMedia = useMediaAttach();
|
||||
|
||||
useDestroy(mediaApi, () => {
|
||||
setMedia?.(mediaApi);
|
||||
});
|
||||
|
||||
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
|
||||
|
||||
return (
|
||||
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HlsMedia } from '@videojs/core/dom/media/hls';
|
||||
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { useMediaRegistration } from '../../player/context';
|
||||
import { useMediaAttach } from '../../player/context';
|
||||
import { attachMediaElement } from '../../utils/attach-media-element';
|
||||
import { mediaProps } from '../../utils/media-props';
|
||||
import { useComposedRefs } from '../../utils/use-composed-refs';
|
||||
@@ -11,13 +11,14 @@ export type HlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoEleme
|
||||
|
||||
export const HlsVideo = forwardRef<HTMLVideoElement, HlsVideoProps>(({ children, ...props }, ref) => {
|
||||
const mediaApi = useMemo(() => new HlsMedia(), []);
|
||||
const setMedia = useMediaRegistration();
|
||||
const setMedia = useMediaAttach();
|
||||
|
||||
useDestroy(mediaApi, () => {
|
||||
setMedia?.(mediaApi);
|
||||
});
|
||||
|
||||
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
|
||||
|
||||
return (
|
||||
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SimpleHlsMedia } from '@videojs/core/dom/media/simple-hls';
|
||||
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useEffect, useMemo } from 'react';
|
||||
import { useMediaRegistration } from '../../player/context';
|
||||
import { useMediaAttach } from '../../player/context';
|
||||
import { attachMediaElement } from '../../utils/attach-media-element';
|
||||
import { mediaProps } from '../../utils/media-props';
|
||||
import { useComposedRefs } from '../../utils/use-composed-refs';
|
||||
@@ -12,13 +12,14 @@ export type SimpleHlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVide
|
||||
|
||||
export const SimpleHlsVideo = forwardRef<HTMLVideoElement, SimpleHlsVideoProps>(({ children, ...props }, ref) => {
|
||||
const mediaApi = useMemo(() => new SimpleHlsMedia(), []);
|
||||
const setMedia = useMediaRegistration();
|
||||
const setMedia = useMediaAttach();
|
||||
|
||||
useEffect(() => {
|
||||
setMedia?.(mediaApi);
|
||||
}, [mediaApi, setMedia]);
|
||||
|
||||
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
|
||||
|
||||
return (
|
||||
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
|
||||
{children}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('Audio', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -72,7 +72,7 @@ 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 };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
const { unmount } = render(<Audio />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -85,7 +85,7 @@ 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 };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
const ref = createRef<HTMLAudioElement>();
|
||||
render(<Audio ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('Video', () => {
|
||||
it('calls setMedia on mount', () => {
|
||||
const setMedia = vi.fn();
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -75,7 +75,7 @@ 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 };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
const { unmount } = render(<Video />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -88,7 +88,7 @@ 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 };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
|
||||
|
||||
const ref = createRef<HTMLVideoElement>();
|
||||
render(<Video ref={ref} />, { wrapper: createWrapper(value) });
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import type { VideoHTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { useMediaRegistration } from '../player/context';
|
||||
import { useMediaAttach } from '../player/context';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {}
|
||||
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(function Video({ children, ...props }, ref) {
|
||||
const setMedia = useMediaRegistration();
|
||||
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
setMedia?.(el);
|
||||
},
|
||||
[setMedia]
|
||||
);
|
||||
|
||||
const composedRef = useComposedRefs(ref, mediaRef);
|
||||
const setMedia = useMediaAttach();
|
||||
const composedRef = useComposedRefs(ref, setMedia);
|
||||
|
||||
return (
|
||||
<video ref={composedRef} {...props}>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface PlayerContextValue {
|
||||
store: UnknownStore;
|
||||
media: Media | null;
|
||||
setMedia: Dispatch<SetStateAction<Media | null>>;
|
||||
setContainer: Dispatch<SetStateAction<HTMLElement | null>>;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextValue | null>(null);
|
||||
@@ -79,25 +80,31 @@ export function useMedia(): Media | null {
|
||||
return media;
|
||||
}
|
||||
|
||||
/** Access the media registration setter for connecting a media element to the player. */
|
||||
export function useMediaRegistration(): Dispatch<SetStateAction<Media | null>> | undefined {
|
||||
/** 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);
|
||||
return ctx?.setMedia;
|
||||
}
|
||||
|
||||
/** Access the container attach setter for connecting a container element to the player. */
|
||||
export function useContainerAttach(): Dispatch<SetStateAction<HTMLElement | null>> | undefined {
|
||||
const ctx = useContext(PlayerContext);
|
||||
return ctx?.setContainer;
|
||||
}
|
||||
|
||||
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Container = forwardRef<HTMLDivElement, ContainerProps>(function Container({ children, ...props }, ref) {
|
||||
const { store, media } = usePlayerContext();
|
||||
const setContainer = useContainerAttach();
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const composedRef = useComposedRefs(ref, internalRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return;
|
||||
return store.attach({ media, container: internalRef.current });
|
||||
}, [media, store]);
|
||||
setContainer?.(internalRef.current);
|
||||
return () => setContainer?.(null);
|
||||
}, [setContainer]);
|
||||
|
||||
return (
|
||||
<div ref={composedRef} {...props}>
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { InferStoreState } from '@videojs/store';
|
||||
import { combine, createStore } from '@videojs/store';
|
||||
import { useStore } from '@videojs/store/react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useDestroy } from '../utils/use-destroy';
|
||||
import { Container, PlayerContextProvider, useMedia, usePlayerContext } from './context';
|
||||
@@ -71,10 +71,16 @@ export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>): Cr
|
||||
function Provider({ children }: ProviderProps): ReactNode {
|
||||
const [store] = useState(() => createStore<PlayerTarget>()(combine(...config.features)));
|
||||
const [media, setMedia] = useState<Media | null>(null);
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
useDestroy(store);
|
||||
|
||||
return <PlayerContextProvider value={{ store, media, setMedia }}>{children}</PlayerContextProvider>;
|
||||
useEffect(() => {
|
||||
if (!media) return;
|
||||
return store.attach({ media, container });
|
||||
}, [media, container, store]);
|
||||
|
||||
return <PlayerContextProvider value={{ store, media, setMedia, setContainer }}>{children}</PlayerContextProvider>;
|
||||
}
|
||||
|
||||
if (__DEV__ && config.displayName) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
Container,
|
||||
PlayerContextProvider,
|
||||
type PlayerContextValue,
|
||||
useContainerAttach,
|
||||
useMedia,
|
||||
useMediaRegistration,
|
||||
useMediaAttach,
|
||||
useOptionalPlayer,
|
||||
usePlayer,
|
||||
usePlayerContext,
|
||||
@@ -20,6 +21,16 @@ function createWrapper(value: PlayerContextValue) {
|
||||
};
|
||||
}
|
||||
|
||||
function createContextValue(overrides?: Partial<PlayerContextValue>): PlayerContextValue {
|
||||
return {
|
||||
store: createMockStore() as any,
|
||||
media: null,
|
||||
setMedia: vi.fn(),
|
||||
setContainer: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePlayerContext', () => {
|
||||
it('throws outside Provider', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@@ -33,7 +44,7 @@ describe('usePlayerContext', () => {
|
||||
|
||||
it('returns context value inside Provider', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue({ store: store as any });
|
||||
|
||||
const { result } = renderHook(() => usePlayerContext(), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -44,18 +55,17 @@ describe('usePlayerContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMediaRegistration', () => {
|
||||
describe('useMediaAttach', () => {
|
||||
it('returns undefined outside Provider', () => {
|
||||
const { result } = renderHook(() => useMediaRegistration());
|
||||
const { result } = renderHook(() => useMediaAttach());
|
||||
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 value = createContextValue({ setMedia });
|
||||
|
||||
const { result } = renderHook(() => useMediaRegistration(), {
|
||||
const { result } = renderHook(() => useMediaAttach(), {
|
||||
wrapper: createWrapper(value),
|
||||
});
|
||||
|
||||
@@ -63,10 +73,28 @@ describe('useMediaRegistration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContainerAttach', () => {
|
||||
it('returns undefined outside Provider', () => {
|
||||
const { result } = renderHook(() => useContainerAttach());
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns setContainer inside Provider', () => {
|
||||
const setContainer = vi.fn();
|
||||
const value = createContextValue({ setContainer });
|
||||
|
||||
const { result } = renderHook(() => useContainerAttach(), {
|
||||
wrapper: createWrapper(value),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(setContainer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePlayer', () => {
|
||||
it('returns store without selector', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue({ store: store as any });
|
||||
|
||||
const { result } = renderHook(() => usePlayer(), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -96,7 +124,7 @@ describe('useOptionalPlayer', () => {
|
||||
|
||||
it('returns store inside Provider', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue({ store: store as any });
|
||||
|
||||
const { result } = renderHook(() => useOptionalPlayer(), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -107,7 +135,7 @@ describe('useOptionalPlayer', () => {
|
||||
|
||||
it('returns selected state inside Provider', () => {
|
||||
const store = createMockStore({ paused: true });
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue({ store: store as any });
|
||||
|
||||
const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -119,9 +147,8 @@ describe('useOptionalPlayer', () => {
|
||||
|
||||
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 value = createContextValue({ media });
|
||||
|
||||
const { result } = renderHook(() => useMedia(), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -131,8 +158,7 @@ describe('useMedia', () => {
|
||||
});
|
||||
|
||||
it('returns null when no media', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue();
|
||||
|
||||
const { result } = renderHook(() => useMedia(), {
|
||||
wrapper: createWrapper(value),
|
||||
@@ -144,8 +170,7 @@ describe('useMedia', () => {
|
||||
|
||||
describe('Container', () => {
|
||||
it('renders children', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const value = createContextValue();
|
||||
|
||||
const { container } = render(
|
||||
<PlayerContextProvider value={value}>
|
||||
@@ -158,10 +183,9 @@ describe('Container', () => {
|
||||
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() };
|
||||
it('registers container element via setContainer', () => {
|
||||
const setContainer = vi.fn();
|
||||
const value = createContextValue({ setContainer });
|
||||
|
||||
render(
|
||||
<PlayerContextProvider value={value}>
|
||||
@@ -169,15 +193,29 @@ describe('Container', () => {
|
||||
</PlayerContextProvider>
|
||||
);
|
||||
|
||||
expect(store.attach).toHaveBeenCalledWith({
|
||||
media,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
expect(setContainer).toHaveBeenCalledWith(expect.any(HTMLDivElement));
|
||||
});
|
||||
|
||||
it('does not attach when media is null', () => {
|
||||
it('deregisters container on unmount', () => {
|
||||
const setContainer = vi.fn();
|
||||
const value = createContextValue({ setContainer });
|
||||
|
||||
const { unmount } = render(
|
||||
<PlayerContextProvider value={value}>
|
||||
<Container />
|
||||
</PlayerContextProvider>
|
||||
);
|
||||
|
||||
setContainer.mockClear();
|
||||
unmount();
|
||||
|
||||
expect(setContainer).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('does not call store.attach directly', () => {
|
||||
const store = createMockStore();
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
const media = document.createElement('video');
|
||||
const value = createContextValue({ store: store as any, media });
|
||||
|
||||
render(
|
||||
<PlayerContextProvider value={value}>
|
||||
|
||||
@@ -34,7 +34,7 @@ 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() };
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn(), setContainer: vi.fn() };
|
||||
|
||||
return {
|
||||
store,
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@app/styles.css';
|
||||
//
|
||||
// React equivalent of the simple-hls-html sandbox: SimpleHlsVideo inside a VJS
|
||||
// player with play/mute controls. SimpleHlsVideo registers itself via
|
||||
// useMediaRegistration so the store discovers it without any querySelector.
|
||||
// useMediaAttach so the store discovers it without any querySelector.
|
||||
|
||||
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeOffIcon } from '@videojs/icons/react';
|
||||
import { Container, createPlayer, MuteButton, PlayButton } from '@videojs/react';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: useMediaAttach
|
||||
description: Hook to register a custom media element with the player context
|
||||
---
|
||||
|
||||
import UtilReference from "@/components/docs/api-reference/UtilReference.astro";
|
||||
|
||||
`useMediaAttach` returns a setter function for attaching a media element to the player context. The built-in `<Video>` and `<Audio>` components use this internally -- you only need it when building a custom media element.
|
||||
|
||||
```tsx title="CustomMedia.tsx"
|
||||
import { useMediaAttach } from "@videojs/react";
|
||||
|
||||
function CustomMedia({ src }: { src: string }) {
|
||||
const setMedia = useMediaAttach();
|
||||
return <video ref={setMedia} src={src} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Who needs this
|
||||
|
||||
You only need `useMediaAttach` if you're replacing the built-in `<Video>` or `<Audio>` components with a custom element. For example, if you're...
|
||||
|
||||
- Wrapping a third-party video player
|
||||
- Using a `<canvas>` or WebGL-based renderer
|
||||
- Building a custom `<audio>` element with additional markup
|
||||
|
||||
For standard `<video>` and `<audio>` playback, use the built-in components.
|
||||
|
||||
## Safe outside Provider
|
||||
|
||||
Returns `undefined` when called outside a Player `Provider`. Check the return value before using it -- this avoids crashes in components that may render outside the player tree.
|
||||
|
||||
<UtilReference util="useMediaAttach" />
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
title: useMediaRegistration
|
||||
description: Hook to register a custom media element with the player context
|
||||
---
|
||||
|
||||
import UtilReference from "@/components/docs/api-reference/UtilReference.astro";
|
||||
|
||||
`useMediaRegistration` returns a setter function for registering a media element with the player context. The built-in `<Video>` and `<Audio>` components use this internally -- you only need it when building a custom media element.
|
||||
|
||||
```tsx title="CustomMedia.tsx"
|
||||
import { useMediaRegistration } from "@videojs/react";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
function CustomMedia({ src }: { src: string }) {
|
||||
const setMedia = useMediaRegistration();
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && setMedia) {
|
||||
setMedia(ref.current);
|
||||
return () => setMedia(null);
|
||||
}
|
||||
}, [setMedia]);
|
||||
|
||||
return <video ref={ref} src={src} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Who needs this
|
||||
|
||||
You only need `useMediaRegistration` if you're replacing the built-in `<Video>` or `<Audio>` components with a custom element. For example, if you're...
|
||||
|
||||
- Wrapping a third-party video player
|
||||
- Using a `<canvas>` or WebGL-based renderer
|
||||
- Building a custom `<audio>` element with additional markup
|
||||
|
||||
For standard `<video>` and `<audio>` playback, use the built-in components.
|
||||
|
||||
## Cleanup pattern
|
||||
|
||||
Always return a cleanup function that passes `null` to the setter. This detaches the media element when the component unmounts, preventing stale references in the store.
|
||||
|
||||
## Safe outside Provider
|
||||
|
||||
Returns `undefined` when called outside a Player `Provider`. Check the return value before using it -- this avoids crashes in components that may render outside the player tree.
|
||||
|
||||
<UtilReference util="useMediaRegistration" />
|
||||
@@ -12,7 +12,7 @@ import BasicUsageDemoReact from "@/components/docs/demos/use-media/react/css/Bas
|
||||
import basicUsageReactTsx from "@/components/docs/demos/use-media/react/css/BasicUsage.tsx?raw";
|
||||
import basicUsageReactCss from "@/components/docs/demos/use-media/react/css/BasicUsage.css?raw";
|
||||
|
||||
`Player.useMedia` returns the current `HTMLMediaElement` (or `null` if no media element has been registered yet). Use it to interact directly with the native media element when needed. It must be called within a `Player.Provider`. The media element becomes available after a `<Video>` or `<Audio>` component mounts inside the provider tree. Also available as a standalone import (`import { useMedia } from '@videojs/react'`) — identical behavior, no typing difference. To register a custom media element instead of the built-in components, see <DocsLink slug="reference/use-media-registration">`useMediaRegistration`</DocsLink>.
|
||||
`Player.useMedia` returns the current `HTMLMediaElement` (or `null` if no media element has been registered yet). Use it to interact directly with the native media element when needed. It must be called within a `Player.Provider`. The media element becomes available after a `<Video>` or `<Audio>` component mounts inside the provider tree. Also available as a standalone import (`import { useMedia } from '@videojs/react'`) — identical behavior, no typing difference. To attach a custom media element instead of the built-in components, see <DocsLink slug="reference/use-media-attach">`useMediaAttach`</DocsLink>.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ For most use cases, use the focused hooks instead:
|
||||
| -------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| Store access with selector | <DocsLink slug="reference/use-player">`usePlayer`</DocsLink> |
|
||||
| Current media element | <DocsLink slug="reference/use-media">`useMedia`</DocsLink> |
|
||||
| Register custom media | <DocsLink slug="reference/use-media-registration">`useMediaRegistration`</DocsLink> |
|
||||
| Attach custom media | <DocsLink slug="reference/use-media-attach">`useMediaAttach`</DocsLink> |
|
||||
|
||||
These hooks read from the same context internally. `usePlayerContext` exposes the raw context value -- use it when you need multiple context fields in one call or when building a custom abstraction over the player context.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export const sidebar: Sidebar = [
|
||||
contents: [
|
||||
{ slug: 'reference/render-element' },
|
||||
{ slug: 'reference/use-button' },
|
||||
{ slug: 'reference/use-media-registration' },
|
||||
{ slug: 'reference/use-media-attach' },
|
||||
{ slug: 'reference/use-player-context' },
|
||||
{ slug: 'reference/use-selector' },
|
||||
{ slug: 'reference/use-snapshot' },
|
||||
|
||||
Reference in New Issue
Block a user