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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user