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
@@ -0,0 +1,11 @@
import { toConnectedMediaMuteButton } from "../connected/media-mute-button";
import { MediaMuteButton as BaseMediaMuteButton } from "../ui/media-mute-button";
const MediaMuteButton = toConnectedMediaMuteButton(BaseMediaMuteButton);
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-mute-button')) {
globalThis.customElements.define('media-mute-button', MediaMuteButton);
}
export { MediaMuteButton };
export default MediaMuteButton;
@@ -0,0 +1,11 @@
import { toConnectedMediaPlayButton } from "../connected/media-play-button";
import { MediaPlayButton as BaseMediaPlayButton } from "../ui/media-play-button";
const MediaPlayButton = toConnectedMediaPlayButton(BaseMediaPlayButton);
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-play-button')) {
globalThis.customElements.define('media-play-button', MediaPlayButton);
}
export { MediaPlayButton };
export default MediaPlayButton;
@@ -0,0 +1,58 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
// NOTE: This should be fairly generic code that could itself be abstracted into configuration for a more generic factory implementation. (CJP)
export const toConnectedMediaMuteButton = (BaseClass = HTMLElement) => {
return class MediaMuteButton extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(super.observedAttributes ?? []),
'mediapaused',
];
}
#mediaStore: any;
contexts = {
mediaStore: (mediaStore: any) => {
this.#mediaStore = mediaStore;
this.#mediaStore.subscribeKeys(
['mediaVolumeLevel', 'mediaMuted'],
({ mediaVolumeLevel, mediaMuted }: any) => {
/** @ts-ignore */
this.mediaVolumeLevel = mediaVolumeLevel;
/** @ts-ignore */
this.mediaMuted = mediaMuted;
this.setAttribute('data-volume-level', mediaVolumeLevel);
this.toggleAttribute('data-muted', mediaMuted);
},
);
},
};
connectedCallback(): void {
super.connectedCallback?.();
this.addEventListener('mediamuterequest', this);
this.addEventListener('mediaunmuterequest', this);
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
this.removeEventListener('mediamuterequest', this);
this.removeEventListener('mediaunmuterequest', this);
}
handleEvent(event: CustomEvent) {
/** @ts-ignore */
super.handleEvent?.(event);
if (
this.#mediaStore &&
['mediamuterequest', 'mediaunmuterequest'].includes(event.type)
) {
const { type, detail } = event;
this.#mediaStore.dispatch({ type, detail });
}
}
};
};
@@ -0,0 +1,58 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
// NOTE: This should be fairly generic code that could itself be abstracted into configuration for a more generic factory implementation. (CJP)
export const toConnectedMediaPlayButton = (BaseClass = HTMLElement) => {
return class MediaPlayButton extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(super.observedAttributes ?? []),
'mediapaused',
];
}
#mediaStore: any;
contexts = {
mediaStore: (mediaStore: any) => {
this.#mediaStore = mediaStore;
this.#mediaStore.subscribeKeys(
['mediaPaused'],
({ mediaPaused }: any) => {
// NOTE: We may want to assume setting properties instead of attributes here to leave things generic for
// complex values. That allows implementors to decide what to do if/when the property is set.
// Using `this.toggleAttribute('mediapaused', mediaPaused);` should also work if that's preferred.
/** @ts-ignore */
this.mediaPaused = mediaPaused;
this.toggleAttribute('data-paused', mediaPaused);
},
);
},
};
connectedCallback(): void {
super.connectedCallback?.();
this.addEventListener('mediaplayrequest', this);
this.addEventListener('mediapauserequest', this);
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
this.removeEventListener('mediaplayrequest', this);
this.removeEventListener('mediapauserequest', this);
}
handleEvent(event: CustomEvent) {
/** @ts-ignore */
super.handleEvent?.(event);
if (
this.#mediaStore &&
['mediaplayrequest', 'mediapauserequest'].includes(event.type)
) {
const { type, detail } = event;
this.#mediaStore.dispatch({ type, detail });
}
}
};
};
@@ -0,0 +1,47 @@
import { namedNodeMapToObject } from '../../utils/element-utils.js';
export function getTemplateHTML(
this: typeof MediaChromeButton,
_attrs: Record<string, string>,
_props: Record<string, any> = {}
) {
return /* html */ `
<style>
/*
NOTE: Even though primitives should aim to be "unstyled" in their core definitions, we should
still add pointer-events, as this defines functionality. (CJP)
*/
:host {
pointer-events: auto;
}
</style>
<slot>
</slot>
`;
}
export class MediaChromeButton extends HTMLElement {
static shadowRootOptions = {
mode: 'open' as ShadowRootMode,
};
static getTemplateHTML = getTemplateHTML;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow((this.constructor as typeof MediaChromeButton).shadowRootOptions);
const attrs = namedNodeMapToObject(this.attributes);
const html = (this.constructor as typeof MediaChromeButton).getTemplateHTML(attrs);
// From MDN: setHTMLUnsafe should be used instead of ShadowRoot.innerHTML
// when a string of HTML may contain declarative shadow roots.
const shadowRoot = this.shadowRoot as unknown as ShadowRoot;
shadowRoot.setHTMLUnsafe ? shadowRoot.setHTMLUnsafe(html) : (shadowRoot.innerHTML = html);
}
this.addEventListener('click', this.handleClick);
}
handleClick() {}
}
@@ -0,0 +1,34 @@
import { MediaChromeButton } from './media-chrome-button';
export class MediaMuteButton extends MediaChromeButton {
static get observedAttributes() {
return ['mediavolumelevel', 'mediamuted'];
}
handleClick() {
const type = this.mediaMuted ? 'mediaunmuterequest' : 'mediamuterequest';
this.dispatchEvent(new CustomEvent(type));
}
get mediaMuted() {
return this.hasAttribute('mediamuted');
}
set mediaMuted(value: boolean) {
this.toggleAttribute('mediamuted', !!value);
}
get mediaVolumeLevel() {
return this.getAttribute('mediavolumelevel');
}
set mediaVolumeLevel(value: string | null | undefined) {
if (value == null) {
this.removeAttribute('mediavolumelevel');
} else {
this.setAttribute('mediavolumelevel', value);
}
}
}
@@ -0,0 +1,22 @@
import { MediaChromeButton } from './media-chrome-button';
export class MediaPlayButton extends MediaChromeButton {
static get observedAttributes() {
return ['mediapaused'];
}
handleClick() {
const type = this.mediaPaused ? 'mediaplayrequest' : 'mediapauserequest';
this.dispatchEvent(new CustomEvent(type));
}
get mediaPaused() {
return this.hasAttribute('mediapaused');
}
set mediaPaused(value: boolean) {
this.toggleAttribute('mediapaused', !!value);
}
}
+7 -214
View File
@@ -1,216 +1,9 @@
export * from '@vjs-10/html-icons';
export * from '@vjs-10/html-media-elements';
export * from '@vjs-10/html-media-store';
export * as MediaProvider from './media-provider.js';
export * as MediaThemeDefault from './skins/media-skin-default.js';
export interface PlayerOptions {
controls?: boolean;
autoplay?: boolean;
preload?: 'none' | 'metadata' | 'auto';
width?: number;
height?: number;
}
export class VjsPlayer extends HTMLElement {
private mediaElement: HTMLElement;
private controlBar: HTMLElement;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.mediaElement = document.createElement('vjs-media');
this.controlBar = this.createControlBar();
}
static get observedAttributes() {
return ['src', 'controls', 'autoplay', 'preload', 'width', 'height'];
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
switch (name) {
case 'src':
case 'controls':
case 'autoplay':
case 'preload':
this.mediaElement.setAttribute(name, newValue);
break;
case 'width':
case 'height':
this.updateDimensions();
break;
}
}
private render() {
if (!this.shadowRoot) return;
const styles = `
<style>
:host {
display: block;
position: relative;
background: #000;
font-family: Arial, sans-serif;
}
.vjs-player-container {
position: relative;
width: 100%;
height: 100%;
}
.vjs-control-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s ease;
}
:host(:hover) .vjs-control-bar {
opacity: 1;
}
.vjs-play-button {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 5px;
}
.vjs-progress-bar {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.3);
border-radius: 2px;
position: relative;
cursor: pointer;
}
.vjs-progress-fill {
height: 100%;
background: #ff0000;
border-radius: 2px;
width: 0%;
transition: width 0.1s ease;
}
.vjs-volume-button {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 5px;
}
</style>
`;
const template = `
<div class="vjs-player-container">
${this.mediaElement.outerHTML}
${this.getAttribute('controls') !== null ? this.controlBar.outerHTML : ''}
</div>
`;
this.shadowRoot.innerHTML = styles + template;
const mediaEl = this.shadowRoot.querySelector('vjs-media');
const controlBarEl = this.shadowRoot.querySelector('.vjs-control-bar');
if (mediaEl) {
this.mediaElement = mediaEl as HTMLElement;
}
if (controlBarEl) {
this.controlBar = controlBarEl as HTMLElement;
}
}
private createControlBar(): HTMLElement {
const controlBar = document.createElement('div');
controlBar.className = 'vjs-control-bar';
controlBar.innerHTML = `
<button class="vjs-play-button">
<vjs-icon name="play" size="20"></vjs-icon>
</button>
<div class="vjs-progress-bar">
<div class="vjs-progress-fill"></div>
</div>
<button class="vjs-volume-button">
<vjs-icon name="volumeUp" size="20"></vjs-icon>
</button>
`;
return controlBar;
}
private setupEventListeners() {
const playButton = this.shadowRoot?.querySelector('.vjs-play-button');
const progressBar = this.shadowRoot?.querySelector('.vjs-progress-bar');
const volumeButton = this.shadowRoot?.querySelector('.vjs-volume-button');
playButton?.addEventListener('click', () => {
if (this.mediaElement && 'paused' in this.mediaElement) {
if ((this.mediaElement as any).paused) {
(this.mediaElement as any).play();
} else {
(this.mediaElement as any).pause();
}
}
});
this.mediaElement?.addEventListener('vjs-play', () => {
const icon = playButton?.querySelector('vjs-icon');
if (icon) {
(icon as any).name = 'pause';
}
});
this.mediaElement?.addEventListener('vjs-pause', () => {
const icon = playButton?.querySelector('vjs-icon');
if (icon) {
(icon as any).name = 'play';
}
});
this.mediaElement?.addEventListener('vjs-timeupdate', (event: CustomEvent) => {
const progressFill = this.shadowRoot?.querySelector('.vjs-progress-fill') as HTMLElement;
if (progressFill && this.mediaElement && 'duration' in this.mediaElement) {
const duration = (this.mediaElement as any).duration;
const currentTime = event.detail.currentTime;
const percentage = duration ? (currentTime / duration) * 100 : 0;
progressFill.style.width = `${percentage}%`;
}
});
}
private updateDimensions() {
const width = this.getAttribute('width');
const height = this.getAttribute('height');
if (width) {
this.style.width = width.includes('px') ? width : `${width}px`;
}
if (height) {
this.style.height = height.includes('px') ? height : `${height}px`;
}
}
}
if (!customElements.get('vjs-player')) {
customElements.define('vjs-player', VjsPlayer);
export function defineVjsPlayer() {
/** @TODO - Reimplement me (at least as a POC) (CJP) */
// defineVideoProvider();
// defineVideoDefaultSkin();
// <video> is native, no need to define
}
+42
View File
@@ -0,0 +1,42 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
export function getTemplateHTML() {
return /* html */`
<slot name="media"></slot>
<slot></slot>
`;
}
export class MediaContainer extends ConsumerMixin(HTMLElement) {
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
static getTemplateHTML = getTemplateHTML;
#mediaStore: any;
#mediaSlot: HTMLSlotElement;
contexts = {
mediaStore: (mediaStore: any) => {
this.#mediaStore = mediaStore;
this.#handleMediaSlotChange();
},
};
constructor() {
super();
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof MediaContainer).shadowRootOptions);
this.shadowRoot!.innerHTML = (this.constructor as typeof MediaContainer).getTemplateHTML();
}
this.#mediaSlot = this.shadowRoot!.querySelector('slot[name=media]') as HTMLSlotElement;
this.#mediaSlot.addEventListener('slotchange', this.#handleMediaSlotChange);
}
#handleMediaSlotChange = () => {
const media = this.#mediaSlot.assignedElements({ flatten: true })[0];
this.#mediaStore.dispatch({ type: 'mediaelementchangerequest', detail: media });
};
}
customElements.define('media-container', MediaContainer);
+12
View File
@@ -0,0 +1,12 @@
import { ProviderMixin } from '@open-wc/context-protocol';
import { createMediaStore } from '@vjs-10/media-store';
export class MediaProvider extends ProviderMixin(HTMLElement) {
contexts = {
mediaStore: () => {
return createMediaStore();
},
};
}
customElements.define('media-provider', MediaProvider);
+34
View File
@@ -0,0 +1,34 @@
export function getTemplateHTML() {
return /* html */ `
<style>
:host {
display: block;
}
media-container {
display: block;
width: 100%;
height: 100%;
}
</style>
<slot></slot>
`;
}
export class MediaSkin extends HTMLElement {
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
static getTemplateHTML = getTemplateHTML;
constructor() {
super();
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof MediaSkin).shadowRootOptions);
this.shadowRoot!.innerHTML = (this.constructor as typeof MediaSkin).getTemplateHTML();
}
}
}
if (!customElements.get('media-skin')) {
customElements.define('media-skin', MediaSkin);
}
@@ -0,0 +1,107 @@
import { MediaSkin } from '../media-skin.js';
import '../media-container.js';
import '../components/connected-with-defaults/media-play-button.js';
import '../components/connected-with-defaults/media-mute-button.js';
import '../icons/index.js';
export function getTemplateHTML() {
return /* html */ `
${MediaSkin.getTemplateHTML()}
<style>
/** @TODO: Improve/Polish CSS Here */
/* Media Container UI/Styles */
media-container {
display: inline-block;
position: relative;
/* NOTE: Setting color here for generic inheritance, including SVG fill: currentColor defaults (CJP) */
color: rgb(238 238 238);
}
media-container > [slot=media] {
width: 100%;
height: 100%;
}
/* Media Container UI Overlay Styling */
media-container > .overlay {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
flex-flow: column nowrap;
align-items: start;
/* pointer-events: none; */
background: none;
}
/* Generic Media Button Styling */
.button {
border: none;
background: rgb(20 20 30 / .7);
padding: 4px;
cursor: pointer;
color: rgb(238 238 238);
}
.button .icon {
width: 24px;
height: 24px;
display: none;
}
/* Media Play Button UI/Styles */
media-play-button:not([data-paused]) .pause-icon,
media-play-button[data-paused] .play-icon {
display: inline-block;
}
/* One way to define the "default visible" icon (CJP) */
media-mute-button:not([data-volume-level]) .volume-low-icon,
media-mute-button[data-volume-level=high] .volume-high-icon,
media-mute-button[data-volume-level=low] .volume-low-icon,
media-mute-button[data-volume-level=medium] .volume-low-icon,
media-mute-button[data-volume-level=off] .volume-off-icon {
display: inline-block;
}
/* Media Control Bar UI/Styles */
.control-bar {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
}
.spacer {
flex-grow: 1;
}
</style>
<media-container>
<slot name="media" slot="media"></slot>
<div class="overlay">
<div class="spacer"></div>
<div class="control-bar">
<!-- NOTE: We can decide if we further want to provide a further, "themed" media-play-button that comes with baked in default styles and icons. (CJP) -->
<media-play-button class="button">
<media-play-icon class="icon play-icon"></media-play-icon>
<media-pause-icon class="icon pause-icon"></media-pause-icon>
</media-play-button>
<media-mute-button class="button">
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
</media-mute-button>
</div>
<div>
</media-container>
`;
}
export class MediaSkinDefault extends MediaSkin {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-skin-default', MediaSkinDefault);
@@ -0,0 +1,7 @@
export function namedNodeMapToObject(namedNodeMap: NamedNodeMap) {
const obj: Record<string, string> = {};
for (const attr of namedNodeMap) {
obj[attr.name] = attr.value;
}
return obj;
}