feat(monorepo): migrate prototype code to organized package structure

Migrate all prototype code from vjs10-prototype repository into the
monorepo structure following established dependency hierarchy and
package organization patterns.

Core packages migrated:
- @vjs-10/media-store: Complete media store with nanostores integration
- @vjs-10/playback-engine: HLS.js playback engine implementation
- @vjs-10/media: Core media state owner and interfaces

HTML packages migrated:
- @vjs-10/html-icons: Web component icons with proper base classes
- @vjs-10/html-media-elements: Context provider integration
- @vjs-10/html: Main HTML UI components, skins, and utilities

React packages migrated:
- @vjs-10/react-icons: React icon components
- @vjs-10/react-media-elements: Video component with state integration
- @vjs-10/react-media-store: MediaProvider and React hooks
- @vjs-10/react: Main React UI components and skins

Changes made:
- Updated all imports to use monorepo package names (@vjs-10/*)
- Distributed dependencies to specific packages that use them
- Preserved all prototype functionality and component structure
- Created proper index files for all packages
- Fixed workspace protocol usage for npm compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2025-07-29 13:17:46 -07:00
co-authored by Claude
parent fc5a0e46fd
commit 4b472ec49c
61 changed files with 1967 additions and 995 deletions
+3
View File
@@ -20,6 +20,9 @@
"player"
],
"license": "Apache-2.0",
"dependencies": {
"nanostores": "^1.0.1"
},
"devDependencies": {
"typescript": "^5.3.0"
},
+134
View File
@@ -0,0 +1,134 @@
import { map, subscribeKeys } from 'nanostores';
export type StateOwners = {
media?: any;
};
export type EventOrAction<D = undefined> = {
type: string;
detail?: D;
target?: EventTarget;
};
export type FacadeGetter<T, D = T> = (
stateOwners: StateOwners,
event?: EventOrAction<D>,
) => T;
export type FacadeSetter<T> = (value: T, stateOwners: StateOwners) => void;
export type StateOwnerUpdateHandler<T> = (
handler: (value: T) => void,
stateOwners: StateOwners,
) => void;
export type ReadonlyFacadeProp<T, D = T> = {
get: FacadeGetter<T, D>;
mediaEvents?: string[];
};
export type FacadeProp<T, S = T, D = T> = ReadonlyFacadeProp<T, D> & {
set: FacadeSetter<S>;
/** @TODO We probably need to refactor this for more complex cases where we can't simply translate to a setter */
actions: { [k: string]: (val: CustomEvent<any>) => ReturnType<FacadeGetter<T, D>> };
};
export type StateMediator = {
mediaPaused: FacadeProp<HTMLMediaElement['paused']>;
mediaMuted: FacadeProp<HTMLMediaElement['muted']>;
mediaVolume: FacadeProp<HTMLMediaElement['volume']>;
mediaVolumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>;
};
export function createMediaStore({
// media,
stateMediator,
}: {
media?: any;
stateMediator: Partial<StateMediator> & Pick<StateMediator, 'mediaPaused'>;
}) {
const stateOwners: any = {};
const store = map<any>({});
const stateUpdateHandlers: Record<string, () => void> = {};
const keys = Object.keys(stateMediator);
function updateStateOwners(nextStateOwners: any) {
if (nextStateOwners.media === stateOwners.media) {
return;
}
let media = stateOwners.media;
if (media) {
for (const { mediaEvents = [] } of Object.values(stateMediator)) {
for (const mediaEvent of mediaEvents) {
media.removeEventListener(
mediaEvent,
stateUpdateHandlers[mediaEvent],
);
delete stateUpdateHandlers[mediaEvent];
}
}
}
Object.assign(stateOwners, nextStateOwners);
media = stateOwners.media;
store.set(getInitialState(stateMediator, stateOwners));
if (media) {
for (const [stateName, stateObject] of Object.entries(stateMediator)) {
const { get, mediaEvents = [] } = stateObject;
for (const mediaEvent of mediaEvents) {
stateUpdateHandlers[mediaEvent] = () =>
store.setKey(stateName, get(stateOwners));
media.addEventListener(mediaEvent, stateUpdateHandlers[mediaEvent]);
}
}
}
}
return {
dispatch(action: any) {
const { type, detail } = action;
if (type === 'mediaelementchangerequest') {
updateStateOwners({ media: detail });
} else {
for (const stateObject of Object.values(stateMediator).filter(
(stateMediator) => 'set' in stateMediator,
)) {
const { set, actions } = stateObject;
if (type in actions) {
/** @TODO FIXME ASAP!!! (CJP) */
// @ts-ignore
set(actions[type as keyof typeof actions](), stateOwners);
}
}
}
},
getState() {
return store.get();
},
subscribeKeys(keys: string[], callback: (state: any) => void) {
subscribeKeys(store, keys, callback);
},
// NOTE: In the POC architecture using nano-stores, subscribe is simply subscribeKeys across all keys. (CJP)
subscribe(callback: (state: any) => void) {
subscribeKeys(store, keys, callback);
}
};
}
function getInitialState(
stateMediator: Partial<StateMediator> & Pick<StateMediator, 'mediaPaused'>,
stateOwners: any,
) {
let initialState: any = {};
for (const [stateName, { get }] of Object.entries(stateMediator)) {
initialState[stateName] = get(stateOwners);
}
return initialState;
}
+4 -46
View File
@@ -1,46 +1,4 @@
export interface MediaState {
currentTime: number;
duration: number;
paused: boolean;
volume: number;
muted: boolean;
}
export interface MediaStateOwner {
getState(): MediaState;
setState(state: Partial<MediaState>): void;
}
export class MediaStore {
private owners: Set<MediaStateOwner> = new Set();
private state: MediaState = {
currentTime: 0,
duration: 0,
paused: true,
volume: 1,
muted: false,
};
addOwner(owner: MediaStateOwner): void {
this.owners.add(owner);
}
removeOwner(owner: MediaStateOwner): void {
this.owners.delete(owner);
}
getState(): MediaState {
return { ...this.state };
}
updateState(updates: Partial<MediaState>): void {
this.state = { ...this.state, ...updates };
this.notifyOwners();
}
private notifyOwners(): void {
this.owners.forEach(owner => {
owner.setState(this.state);
});
}
}
export * from './factory';
export * from './media-store';
export * from './state-mediators/playable';
export * from './state-mediators/audible';
@@ -0,0 +1,9 @@
import { playable } from './state-mediators/playable';
import { audible } from './state-mediators/audible';
import { createMediaStore as factory } from './factory';
// Example of default media store with default state mediator definitions. (CJP)
// NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP)
const stateMediator = { ...playable, ...audible };
type Params = Partial<Parameters<typeof factory>[0]>;
export const createMediaStore = (params: Params = {}) =>
factory({ stateMediator, ...params });
@@ -0,0 +1,48 @@
export const audible = {
mediaMuted: {
get(stateOwners: any) {
const { media } = stateOwners;
return media?.muted ?? false;
},
set(value: boolean, stateOwners: any) {
const { media } = stateOwners;
if (!media) return;
media.muted = value;
},
mediaEvents: ['volumechange'],
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API or aren't simple 1:1 with getter vs. setter (CJP) */
mediamuterequest: () => true,
mediaunmuterequest: () => false,
},
},
mediaVolume: {
get(stateOwners: any) {
const { media } = stateOwners;
return media?.volume ?? 1.0;
},
set(value: number, stateOwners: any) {
const { media } = stateOwners;
if (!media) return;
if (!Number.isFinite(+value)) return;
media.volume = +value;
},
mediaEvents: ['volumechange'],
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
mediavolumerequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }) => +detail,
},
},
// NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP)
mediaVolumeLevel: {
get(stateOwners: any) {
const { media } = stateOwners;
if (typeof media?.volume == 'undefined') return 'high';
if (media.muted || media.volume === 0) return 'off';
if (media.volume < 0.5) return 'low';
if (media.volume < 0.75) return 'medium';
return 'high';
},
mediaEvents: ['volumechange'],
},
};
@@ -0,0 +1,18 @@
export const playable = {
mediaPaused: {
get(stateOwners: any) {
const { media } = stateOwners;
return media?.paused ?? true;
},
set(value: boolean, stateOwners: any) {
const { media } = stateOwners;
media?.[value ? 'pause' : 'play']();
},
mediaEvents: ['play', 'playing', 'pause', 'emptied'],
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
mediaplayrequest: () => false,
mediapauserequest: () => true,
},
},
};
+1 -69
View File
@@ -1,69 +1 @@
export type MediaReadyState = 0 | 1 | 2 | 3 | 4;
export const READY_STATE = {
HAVE_NOTHING: 0 as MediaReadyState,
HAVE_METADATA: 1 as MediaReadyState,
HAVE_CURRENT_DATA: 2 as MediaReadyState,
HAVE_FUTURE_DATA: 3 as MediaReadyState,
HAVE_ENOUGH_DATA: 4 as MediaReadyState,
} as const;
export type MediaNetworkState = 0 | 1 | 2 | 3;
export const NETWORK_STATE = {
EMPTY: 0 as MediaNetworkState,
IDLE: 1 as MediaNetworkState,
LOADING: 2 as MediaNetworkState,
NO_SOURCE: 3 as MediaNetworkState,
} as const;
export interface MediaElementLike {
currentTime: number;
duration: number;
paused: boolean;
ended: boolean;
volume: number;
muted: boolean;
playbackRate: number;
readyState: MediaReadyState;
networkState: MediaNetworkState;
play(): Promise<void>;
pause(): void;
load(): void;
}
export function isMediaElement(element: any): element is HTMLMediaElement {
return element &&
typeof element.play === 'function' &&
typeof element.pause === 'function' &&
typeof element.load === 'function' &&
typeof element.currentTime === 'number';
}
export function createMediaElementAdapter(element: HTMLMediaElement): MediaElementLike {
return {
get currentTime() { return element.currentTime; },
set currentTime(value: number) { element.currentTime = value; },
get duration() { return element.duration; },
get paused() { return element.paused; },
get ended() { return element.ended; },
get volume() { return element.volume; },
set volume(value: number) { element.volume = value; },
get muted() { return element.muted; },
set muted(value: boolean) { element.muted = value; },
get playbackRate() { return element.playbackRate; },
set playbackRate(value: number) { element.playbackRate = value; },
get readyState() { return element.readyState as MediaReadyState; },
get networkState() { return element.networkState as MediaNetworkState; },
play: () => element.play(),
pause: () => element.pause(),
load: () => element.load(),
};
}
export * from './playable';
+112
View File
@@ -0,0 +1,112 @@
import type { IBasePlaybackEngine } from '@vjs-10/playback-engine';
import { createPlaybackEngine } from '@vjs-10/playback-engine';
/** @TODO Split out "playable" vs. "audible" and compose via factory (current mixin pattern or spreadable mixin pattern) (CJP) */
export const Events = ['volumechange', 'pause', 'play', 'playing'] as const;
export interface IBaseMediaStateOwner<
T extends Pick<HTMLMediaElement, 'src'> = Pick<HTMLMediaElement, 'src'>,
> extends EventTarget,
Pick<HTMLMediaElement, 'src'> {
mediaElement?: T;
}
export interface IPlayableMediaStateOwner
extends EventTarget,
IBaseMediaStateOwner,
Pick<HTMLMediaElement, 'play' | 'pause' | 'paused'> {}
export interface IAudibleMediaStateOwner
extends EventTarget,
IBaseMediaStateOwner,
Pick<HTMLMediaElement, 'muted' | 'volume'> {}
export class PlayableMediaStateOwner
extends EventTarget
implements IPlayableMediaStateOwner, IAudibleMediaStateOwner
{
protected _playbackEngine: IBasePlaybackEngine;
constructor() {
super();
this._playbackEngine = createPlaybackEngine();
}
get mediaElement() {
return this._playbackEngine.mediaElement;
}
set mediaElement(value) {
if (this.mediaElement === value) return;
if (this.mediaElement != null) {
Events.forEach((eventType) => {
this.mediaElement?.removeEventListener(eventType, this);
});
}
this._playbackEngine.mediaElement = value;
if (this.mediaElement) {
Events.forEach((eventType) => {
this.mediaElement?.addEventListener(eventType, this);
});
}
}
get paused() {
return this.mediaElement?.paused ?? false;
}
pause() {
/** @TODO implement deferred state etc. for cases where media has yet to be set */
if (!this.mediaElement) return;
return this.mediaElement.pause();
}
play() {
/** @TODO implement deferred state etc. for cases where media has yet to be set */
if (!this.mediaElement) return Promise.reject();
return this.mediaElement.play();
}
get muted() {
return this.mediaElement?.muted ?? false;
}
set muted(value) {
if (value === this.muted) return;
/** @TODO implement deferred state etc. for cases where media has yet to be set */
if (!this.mediaElement) return;
this.mediaElement.muted = value;
}
get volume() {
return this.mediaElement?.volume ?? 0;
}
set volume(value) {
if (value === this.volume) return;
/** @TODO implement deferred state etc. for cases where media has yet to be set */
if (!this.mediaElement) return;
this.mediaElement.volume = value;
}
get src() {
return this._playbackEngine.src ?? '';
}
set src(value) {
this._playbackEngine.src = value;
}
handleEvent(event: Event): void {
if (event.target === this.mediaElement) {
// @ts-ignore
const clonedEvent = new event.constructor(event.type, event);
this.dispatchEvent(clonedEvent);
}
}
destroy() {
this.mediaElement = undefined;
this._playbackEngine.destroy();
}
}
export const createMediaStateOwner = () => new PlayableMediaStateOwner();
@@ -21,6 +21,9 @@
"streaming"
],
"license": "Apache-2.0",
"dependencies": {
"hls.js": "^1.6.7"
},
"devDependencies": {
"typescript": "^5.3.0"
},
@@ -0,0 +1,76 @@
import Hls from 'hls.js';
/** @TODO Move interface/type defs (CJP) */
/** @TODO Narrow and/or parameterize EventTarget via generics/inference (CJP) */
export interface IBasePlaybackEngine extends EventTarget {
src: HTMLMediaElement['src'] | undefined;
// NOTE: Unlike other APIs, this *must* be an HTMLMediaElement based on internal and/or external API assumptions (CJP)
mediaElement: HTMLMediaElement | undefined;
/** @TODO Widen/extend and/or parameterize EventTarget via generics/inference (CJP) */
element: HTMLElement | undefined;
destroy: () => void;
}
export class HlsJSPlaybackEngine extends EventTarget {
constructor() {
super();
}
protected _hlsInstance: Hls | undefined;
get src() {
return this._hlsInstance?.url ?? undefined;
}
set src(val) {
if (this.src === val) return;
if (!this._hlsInstance) {
this._createHlsInstance();
}
if (!this.src && val) {
this._hlsInstance?.loadSource(val);
}
}
// NOTE: Some playback engines may not rely on an underlying HTMLMediaElement but still have
// an HTMLElement (e.g. HTMLCanvasElement, HTMLImageElement, etc.). This API provides a more
// generic interface for cases where we want more minimal assumptions (e.g. fullscreen behaviors for most playtforms but not iOS)
get element(): HTMLElement | undefined {
return this.mediaElement;
}
set element(val) {
if (this.element === val) return;
/** @TODO programmatic type checking? (CJP) */
this.mediaElement = val as HTMLMediaElement;
}
get mediaElement() {
return this._hlsInstance?.media ?? undefined;
}
set mediaElement(val) {
if (this.mediaElement === val) return;
if (!this._hlsInstance) {
this._createHlsInstance();
}
if (!this.mediaElement && val) {
this._hlsInstance?.attachMedia(val);
}
}
protected _createHlsInstance() {
this._hlsInstance = new Hls();
}
protected _destroyHlsInstance() {
this._hlsInstance?.destroy();
this._hlsInstance = undefined;
}
destroy() {
this._destroyHlsInstance();
}
}
export const createPlaybackEngine = () => new HlsJSPlaybackEngine();
+1 -83
View File
@@ -1,83 +1 @@
export interface MediaSource {
src: string;
type: string;
}
export interface PlaybackEngineOptions {
autoplay?: boolean;
preload?: 'none' | 'metadata' | 'auto';
}
export abstract class PlaybackEngine {
protected element: HTMLMediaElement | null = null;
protected options: PlaybackEngineOptions;
constructor(options: PlaybackEngineOptions = {}) {
this.options = options;
}
abstract attach(element: HTMLMediaElement): void;
abstract detach(): void;
abstract load(source: MediaSource): Promise<void>;
abstract play(): Promise<void>;
abstract pause(): void;
abstract seekTo(time: number): void;
getElement(): HTMLMediaElement | null {
return this.element;
}
}
export class NativePlaybackEngine extends PlaybackEngine {
attach(element: HTMLMediaElement): void {
this.element = element;
}
detach(): void {
this.element = null;
}
async load(source: MediaSource): Promise<void> {
if (!this.element) {
throw new Error('No media element attached');
}
this.element.src = source.src;
await new Promise((resolve, reject) => {
const onLoad = () => {
this.element!.removeEventListener('loadedmetadata', onLoad);
this.element!.removeEventListener('error', onError);
resolve(void 0);
};
const onError = () => {
this.element!.removeEventListener('loadedmetadata', onLoad);
this.element!.removeEventListener('error', onError);
reject(new Error('Failed to load media'));
};
this.element.addEventListener('loadedmetadata', onLoad);
this.element.addEventListener('error', onError);
});
}
async play(): Promise<void> {
if (!this.element) {
throw new Error('No media element attached');
}
await this.element.play();
}
pause(): void {
if (!this.element) {
throw new Error('No media element attached');
}
this.element.pause();
}
seekTo(time: number): void {
if (!this.element) {
throw new Error('No media element attached');
}
this.element.currentTime = time;
}
}
export * from './HlsJSPlaybackEngine';