refactor(packages): move store attach lifecycle to provider (#975)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rahim
2026-03-17 15:50:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7bacb1b2ae
commit d535282f03
26 changed files with 378 additions and 265 deletions
+2 -2
View File
@@ -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';
+27 -1
View File
@@ -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
);
+13 -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 { 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,
+26 -115
View File
@@ -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;
}
+106 -12
View File
@@ -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;