feat(html): setup player api (#374)

This commit is contained in:
rahim
2026-02-02 21:11:44 +11:00
committed by GitHub
parent a1d0383d41
commit a419f5ef19
40 changed files with 678 additions and 1078 deletions
@@ -0,0 +1 @@
// TODO: register video features here when registrations are added
@@ -0,0 +1 @@
// TODO: Register HLS video element
+17
View File
@@ -0,0 +1,17 @@
import { features } from '@videojs/core/dom';
import { MediaElement } from '@/ui/media-element';
import { createPlayer } from '../../player/create-player';
const { PlayerMixin } = createPlayer({
features: [...features.video],
});
export class VideoPlayer extends PlayerMixin(MediaElement) {}
customElements.define('video-player', VideoPlayer);
declare global {
interface HTMLElementTagNameMap {
'video-player': VideoPlayer;
}
}
+1
View File
@@ -0,0 +1 @@
// TODO: Implement VideoSkinElement and then register it here
@@ -0,0 +1 @@
// TODO: Implement PlayButtonElement then register it here
+15 -23
View File
@@ -1,26 +1,18 @@
// Re-exports from core/dom
export {
type AnyPlayerFeature,
type BufferState,
definePlayerFeature,
type FeatureAvailability,
features,
type Media,
type MediaContainer,
type PlaybackState,
type PlayerFeature,
type PlayerStore,
type PlayerTarget,
type SourceState,
selectBuffer,
selectPlayback,
selectSource,
selectTime,
selectVolume,
type TimeState,
type VolumeState,
} from '@videojs/core/dom';
// Core
export * from '@videojs/core/dom';
// Re-exports from store (utilities)
// Store
export type { Comparator, Selector } from '@videojs/store';
export { createSelector, shallowEqual } from '@videojs/store';
// Player
export * from './player/context';
export * from './player/create-player';
export * from './player/player-controller';
export * from './player/player-mixin';
export * from './store/container-mixin';
export * from './store/provider-mixin';
// Store bindings
export * from './store/types';
// Primitives
export { MediaElement } from './ui/media-element';
View File
+16
View File
@@ -0,0 +1,16 @@
import { type Context, createContext } from '@lit/context';
import type { AnyPlayerStore, Media, PlayerStore } from '@videojs/core/dom';
export const PLAYER_CONTEXT_KEY = Symbol('@videojs/player');
export interface PlayerContextValue<Store extends PlayerStore = AnyPlayerStore> {
store: Store;
media: Media | null;
}
export type PlayerContext<Store extends PlayerStore = AnyPlayerStore> = Context<
typeof PLAYER_CONTEXT_KEY,
PlayerContextValue<Store>
>;
export const playerContext = createContext<PlayerContextValue, typeof PLAYER_CONTEXT_KEY>(PLAYER_CONTEXT_KEY);
+91
View File
@@ -0,0 +1,91 @@
import type { AnyPlayerFeature, PlayerStore, PlayerTarget } from '@videojs/core/dom';
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 { PlayerController } from './player-controller';
import { createPlayerMixin, type PlayerMixin } from './player-mixin';
export interface CreatePlayerConfig<Features extends AnyPlayerFeature[]> {
features: Features;
}
export interface CreatePlayerResult<Store extends PlayerStore> {
/** Context for consuming player in controllers. */
context: PlayerContext<Store>;
/** Creates a store instance for imperative access. */
create: () => Store;
/** Player controller bound to this player's context. */
PlayerController: PlayerController.Constructor<Store>;
/** Mixin for a complete player element (provider + container). */
PlayerMixin: PlayerMixin<Store>;
/** Mixin that provides player context to descendants. */
ProviderMixin: ProviderMixin<Store>;
/** Mixin that consumes player context and auto-attaches media elements. */
ContainerMixin: ContainerMixin<Store>;
}
/**
* Creates a player factory with typed store, mixins, and controller.
*
* @example
* ```ts
* import { features } from '@videojs/core/dom';
*
* const {
* context,
* create,
* PlayerController,
* PlayerMixin,
* ProviderMixin,
* ContainerMixin,
* } = createPlayer({
* features: [...features.video],
* });
*
* // Complete player element
* class VideoPlayer extends PlayerMixin(ReactiveElement) {}
*
* // Or separate provider/container
* class MyPlayer extends ProviderMixin(ReactiveElement) {}
* class MyContainer extends ContainerMixin(ReactiveElement) {}
*
* // Control element
* class PlayButton extends ReactiveElement {
* #playback = new PlayerController(this, selectPlayback);
* }
* ```
*/
export function createPlayer<const Features extends AnyPlayerFeature[]>(
config: CreatePlayerConfig<Features>
): CreatePlayerResult<PlayerStore<Features>> {
type Store = PlayerStore<Features>;
const slice = combine<PlayerTarget, Features>(...config.features);
function create(): Store {
return createStore<PlayerTarget>()(slice);
}
const ctx = playerContext as PlayerContext<Store>;
const PlayerMixin = createPlayerMixin<Store>(ctx, create);
const ProviderMixin = createProviderMixin<Store>(ctx, create);
const ContainerMixin = createContainerMixin<Store>(ctx);
const BoundPlayerController = PlayerController as unknown as PlayerController.Constructor<Store>;
return {
context: ctx,
create,
PlayerController: BoundPlayerController,
PlayerMixin,
ProviderMixin,
ContainerMixin,
};
}
@@ -0,0 +1,101 @@
import { ContextConsumer } from '@lit/context';
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { PlayerStore } from '@videojs/core/dom';
import type { InferStoreState, Selector } from '@videojs/store';
import { StoreController } from '@videojs/store/lit';
import type { Constructor as Ctor } from '@videojs/utils/types';
import type { PlayerContext, PlayerContextValue } from './context';
export type PlayerControllerHost = ReactiveControllerHost & HTMLElement;
/**
* Reactive controller for accessing player store state.
*
* Without selector: Returns the store, does NOT subscribe to changes.
* With selector: Returns selected state, subscribes with shallowEqual comparison.
*
* @example
* ```ts
* // Store access (no subscription)
* class Controls extends ReactiveElement {
* #player = new PlayerController(this, playerContext);
*
* handleClick() {
* this.#player.value.setVolume(0.5);
* }
* }
*
* // Selector-based subscription
* class PlayButton extends ReactiveElement {
* #playback = new PlayerController(this, playerContext, selectPlayback);
* }
* ```
*/
export class PlayerController<Store extends PlayerStore, Result = Store> implements ReactiveController {
readonly #host: PlayerControllerHost;
readonly #selector: Selector<InferStoreState<Store>, Result> | undefined;
#consumer: ContextConsumer<PlayerContext<Store>, PlayerControllerHost>;
#storeCtrl: StoreController<Store, Result> | null = null;
constructor(host: PlayerControllerHost, context: PlayerContext<Store>);
constructor(
host: PlayerControllerHost,
context: PlayerContext<Store>,
selector: Selector<InferStoreState<Store>, Result>
);
constructor(
host: PlayerControllerHost,
context: PlayerContext<Store>,
selector?: Selector<InferStoreState<Store>, Result>
) {
this.#host = host;
this.#selector = selector;
this.#consumer = new ContextConsumer(host, {
context,
callback: (ctx) => this.#connect(ctx),
subscribe: true,
});
host.addController(this);
}
get value(): Result {
const ctx = this.#consumer.value;
if (!ctx) throw new Error('Player context not available');
// Without selector: return store directly
if (!this.#selector) return ctx.store as unknown as Result;
// With selector: use StoreController
return this.#storeCtrl!.value;
}
hostConnected(): void {
const ctx = this.#consumer.value;
if (ctx) this.#connect(ctx);
}
hostDisconnected(): void {
this.#storeCtrl = null;
}
#connect(ctx: PlayerContextValue<Store> | undefined): void {
if (!ctx) return;
// Create StoreController with the store directly
if (!this.#storeCtrl && this.#selector) {
this.#storeCtrl = new StoreController(this.#host, ctx.store, this.#selector);
}
}
}
export namespace PlayerController {
export type Host = PlayerControllerHost;
export type Constructor<Store extends PlayerStore = PlayerStore, Result = Store> = Ctor<
typeof PlayerController<Store, Result>
>;
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactiveElement } from '@lit/reactive-element';
import type { PlayerStore } from '@videojs/core/dom';
import type { Constructor } from '@videojs/utils/types';
import { createContainerMixin } from '../store/container-mixin';
import { createProviderMixin } from '../store/provider-mixin';
import type { PlayerProvider } from '../store/types';
import type { PlayerContext } from './context';
export interface PlayerElement<Store extends PlayerStore> extends PlayerProvider<Store> {}
type Base = Constructor<ReactiveElement>;
type Result<Class extends Base, Store extends PlayerStore> = Class & PlayerElement<Store>;
export type PlayerMixin<Store extends PlayerStore> = <Class extends Base>(BaseClass: Class) => Result<Class, Store>;
/**
* Creates a mixin that combines provider and container functionality.
*
* Use for a complete player element that owns the store and attaches media.
*/
export function createPlayerMixin<Store extends PlayerStore>(
context: PlayerContext<Store>,
factory: () => Store
): PlayerMixin<Store> {
const ProviderMixin = createProviderMixin<Store>(context, factory);
const ContainerMixin = createContainerMixin<Store>(context);
return <Class extends Base>(BaseClass: Class) => {
return ContainerMixin(ProviderMixin(BaseClass)) as unknown as Result<Class, Store>;
};
}
@@ -0,0 +1,73 @@
import { ContextConsumer } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom';
import { listen, querySlot } from '@videojs/utils/dom';
import { Disposer } from '@videojs/utils/events';
import { noop } from '@videojs/utils/function';
import type { Constructor } from '@videojs/utils/types';
import type { PlayerContext } from '../player/context';
import type { PlayerConsumer } from './types';
type Base = Constructor<ReactiveElement>;
type Result<Class extends Base, Store extends PlayerStore> = Class & Constructor<PlayerConsumer<Store>>;
export type ContainerMixin<Store extends PlayerStore> = <Class extends Base>(BaseClass: Class) => Result<Class, Store>;
export function createContainerMixin<Store extends PlayerStore>(context: PlayerContext<Store>): ContainerMixin<Store> {
return <Class extends Base>(BaseClass: Class) => {
class PlayerContainerElement extends BaseClass implements PlayerConsumer<Store>, MediaContainer {
#detach = noop;
#disposer = new Disposer();
#consumer = new ContextConsumer(this, {
context,
callback: () => this.#attachMedia(),
subscribe: true,
});
get store(): Store | null {
return (this.#consumer.value?.store as Store) ?? null;
}
override connectedCallback() {
super.connectedCallback();
if (this.shadowRoot) {
const slot = querySlot(this.shadowRoot, '');
if (slot) this.#disposer.add(listen(slot, 'slotchange', () => this.#attachMedia()));
}
this.#attachMedia();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.#disposer.dispose();
this.#detach();
}
#attachMedia() {
const ctx = this.#consumer.value;
if (!ctx) return;
const { store, media } = ctx;
if (!media) return;
const target: PlayerTarget = {
media,
container: this,
};
if (store.target?.media !== target.media || store.target?.container !== target.container) {
this.#detach();
this.#detach = store.attach(target);
}
}
}
return PlayerContainerElement;
};
}
+57
View File
@@ -0,0 +1,57 @@
import { ContextProvider } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { Media, PlayerStore } from '@videojs/core/dom';
import { isNull } from '@videojs/utils/predicate';
import type { Constructor } from '@videojs/utils/types';
import type { PlayerContext } from '../player/context';
import type { PlayerProvider } from './types';
type Base = Constructor<ReactiveElement>;
type Result<Class extends Base, Store extends PlayerStore> = Class & Constructor<PlayerProvider<Store>>;
export type ProviderMixin<Store extends PlayerStore> = <Class extends Base>(BaseClass: Class) => Result<Class, Store>;
export function createProviderMixin<Store extends PlayerStore>(
context: PlayerContext<Store>,
factory: () => Store
): ProviderMixin<Store> {
return <Class extends Base>(BaseClass: Class) => {
class PlayerProviderElement extends BaseClass implements PlayerProvider<Store> {
#store: Store | null = null;
#media: Media | null = null;
#provider = new ContextProvider(this, {
context,
initialValue: { store: this.store, media: null },
});
get store(): Store {
if (isNull(this.#store)) {
this.#store = factory();
}
return this.#store;
}
get media(): Media | null {
return this.#media;
}
set media(value: Media | null) {
this.#media = value;
this.#provider.setValue({ store: this.store, media: value });
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.#store) {
this.#store.destroy();
this.#store = null;
}
}
}
return PlayerProviderElement;
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { Media, PlayerStore } from '@videojs/core/dom';
export interface PlayerProvider<Store extends PlayerStore> {
readonly store: Store;
media: Media | null;
}
export interface PlayerConsumer<Store extends PlayerStore> {
readonly store: Store | null;
}
+3
View File
@@ -0,0 +1,3 @@
import { ReactiveElement } from '@lit/reactive-element';
export class MediaElement extends ReactiveElement {}