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
+5 -134
View File
@@ -1,134 +1,5 @@
import { getIcon, createSVGString, IconDefinition } from '@vjs-10/icons';
export interface IconElementOptions {
className?: string;
size?: number;
color?: string;
}
export function createIconElement(iconName: string, options: IconElementOptions = {}): HTMLElement {
const icon = getIcon(iconName);
if (!icon) {
throw new Error(`Icon "${iconName}" not found`);
}
const container = document.createElement('span');
container.className = `vjs-icon vjs-icon-${iconName} ${options.className || ''}`.trim();
const svgString = createSVGString(icon);
container.innerHTML = svgString;
const svg = container.querySelector('svg');
if (svg && options.size) {
svg.style.width = `${options.size}px`;
svg.style.height = `${options.size}px`;
}
if (svg && options.color) {
svg.style.fill = options.color;
}
return container;
}
export class VjsIcon extends HTMLElement {
private _name: string = '';
private _size?: number;
private _color?: string;
static get observedAttributes() {
return ['name', 'size', 'color'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) {
switch (name) {
case 'name':
this._name = newValue;
break;
case 'size':
this._size = newValue ? parseInt(newValue, 10) : undefined;
break;
case 'color':
this._color = newValue;
break;
}
this.render();
}
}
private render() {
if (!this.shadowRoot || !this._name) return;
const icon = getIcon(this._name);
if (!icon) {
this.shadowRoot.innerHTML = `<span>Icon "${this._name}" not found</span>`;
return;
}
const svgString = createSVGString(icon);
const styles = `
<style>
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
svg {
width: ${this._size ? `${this._size}px` : '1em'};
height: ${this._size ? `${this._size}px` : '1em'};
fill: ${this._color || 'currentColor'};
}
</style>
`;
this.shadowRoot.innerHTML = styles + svgString;
}
get name() {
return this._name;
}
set name(value: string) {
this.setAttribute('name', value);
}
get size() {
return this._size;
}
set size(value: number | undefined) {
if (value !== undefined) {
this.setAttribute('size', value.toString());
} else {
this.removeAttribute('size');
}
}
get color() {
return this._color;
}
set color(value: string | undefined) {
if (value !== undefined) {
this.setAttribute('color', value);
} else {
this.removeAttribute('color');
}
}
}
if (!customElements.get('vjs-icon')) {
customElements.define('vjs-icon', VjsIcon);
}
export { getIcon, createSVGString } from '@vjs-10/icons';
export * as MediaPlayIcon from './media-play-icon.js';
export * as MediaPauseIcon from './media-pause-icon.js';
export * as MediaVolumeHighIcon from './media-volume-high-icon.js';
export * as MediaVolumeLowIcon from './media-volume-low-icon.js';
export * as MediaVolumeOffIcon from './media-volume-off-icon.js';
@@ -0,0 +1,26 @@
export function getTemplateHTML() {
return /* html */ `
<style>
:host {
display: inline-block;
}
svg {
fill: currentColor;
}
</style>
`;
}
export class MediaChromeIcon extends HTMLElement {
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
static getTemplateHTML = getTemplateHTML;
constructor() {
super();
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof MediaChromeIcon).shadowRootOptions);
this.shadowRoot!.innerHTML = (this.constructor as typeof MediaChromeIcon).getTemplateHTML();
}
}
}
@@ -0,0 +1,16 @@
import { MediaChromeIcon } from './media-chrome-icon.js';
export function getTemplateHTML() {
return /* html */ `
${MediaChromeIcon.getTemplateHTML()}
<svg viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
`;
}
export class MediaPauseIcon extends MediaChromeIcon {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-pause-icon', MediaPauseIcon);
@@ -0,0 +1,21 @@
import { MediaChromeIcon } from './media-chrome-icon.js';
export function getTemplateHTML() {
return /* html */ `
${MediaChromeIcon.getTemplateHTML()}
<style>
:host {
display: var(--media-play-icon-display, inline-flex);
}
</style>
<svg viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
}
export class MediaPlayIcon extends MediaChromeIcon {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-play-icon', MediaPlayIcon);
@@ -0,0 +1,23 @@
import { MediaChromeIcon } from './media-chrome-icon.js';
const highIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4ZM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54Z"/>
</svg>`;
export function getTemplateHTML() {
return /* html */ `
${MediaChromeIcon.getTemplateHTML()}
<style>
:host {
display: var(--media-play-icon-display, inline-flex);
}
</style>
${highIcon}
`;
}
export class MediaVolumeHighIcon extends MediaChromeIcon {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-volume-high-icon', MediaVolumeHighIcon);
@@ -0,0 +1,23 @@
import { MediaChromeIcon } from './media-chrome-icon.js';
const lowIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3Zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.47 4.47 0 0 0 2.5-4Z"/>
</svg>`;
export function getTemplateHTML() {
return /* html */ `
${MediaChromeIcon.getTemplateHTML()}
<style>
:host {
display: var(--media-play-icon-display, inline-flex);
}
</style>
${lowIcon}
`;
}
export class MediaVolumeLowIcon extends MediaChromeIcon {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-volume-low-icon', MediaVolumeLowIcon);
@@ -0,0 +1,23 @@
import { MediaChromeIcon } from './media-chrome-icon.js';
const offIcon = `<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M16.5 12A4.5 4.5 0 0 0 14 8v2.18l2.45 2.45a4.22 4.22 0 0 0 .05-.63Zm2.5 0a6.84 6.84 0 0 1-.54 2.64L20 16.15A8.8 8.8 0 0 0 21 12a9 9 0 0 0-7-8.77v2.06A7 7 0 0 1 19 12ZM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25A6.92 6.92 0 0 1 14 18.7v2.06A9 9 0 0 0 17.69 19l2 2.05L21 19.73l-9-9L4.27 3ZM12 4 9.91 6.09 12 8.18V4Z"/>
</svg>`;
export function getTemplateHTML() {
return /* html */ `
${MediaChromeIcon.getTemplateHTML()}
<style>
:host {
display: var(--media-play-icon-display, inline-flex);
}
</style>
${offIcon}
`;
}
export class MediaVolumeOffIcon extends MediaChromeIcon {
static getTemplateHTML = getTemplateHTML;
}
customElements.define('media-volume-off-icon', MediaVolumeOffIcon);
@@ -22,8 +22,8 @@
],
"license": "Apache-2.0",
"dependencies": {
"@vjs-10/media": "*",
"@vjs-10/playback-engine": "*"
"@open-wc/context-protocol": "^0.0.9",
"@vjs-10/media-store": "*"
},
"devDependencies": {
"typescript": "^5.3.0"
+1 -155
View File
@@ -1,155 +1 @@
import { MediaElementLike, createMediaElementAdapter } from '@vjs-10/media';
import { PlaybackEngine, NativePlaybackEngine } from '@vjs-10/playback-engine';
export interface MediaElementOptions {
playbackEngine?: PlaybackEngine;
controls?: boolean;
autoplay?: boolean;
preload?: 'none' | 'metadata' | 'auto';
}
export class VjsMediaElement extends HTMLElement {
private mediaElement: HTMLVideoElement | HTMLAudioElement;
private playbackEngine: PlaybackEngine;
private adapter: MediaElementLike;
constructor() {
super();
this.attachShadow({ mode: 'open' });
const mediaType = this.getAttribute('media-type') || 'video';
this.mediaElement = mediaType === 'audio'
? document.createElement('audio')
: document.createElement('video');
this.playbackEngine = new NativePlaybackEngine();
this.adapter = createMediaElementAdapter(this.mediaElement);
}
static get observedAttributes() {
return ['src', 'controls', 'autoplay', 'preload', 'media-type'];
}
connectedCallback() {
this.render();
this.setupEventListeners();
this.playbackEngine.attach(this.mediaElement);
}
disconnectedCallback() {
this.playbackEngine.detach();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
switch (name) {
case 'src':
if (newValue) {
this.playbackEngine.load({ src: newValue, type: 'video/mp4' });
}
break;
case 'controls':
this.mediaElement.controls = newValue !== null;
break;
case 'autoplay':
this.mediaElement.autoplay = newValue !== null;
break;
case 'preload':
this.mediaElement.preload = newValue as any;
break;
}
}
private render() {
if (!this.shadowRoot) return;
const styles = `
<style>
:host {
display: block;
width: 100%;
}
video, audio {
width: 100%;
height: 100%;
}
</style>
`;
this.shadowRoot.innerHTML = styles;
this.shadowRoot.appendChild(this.mediaElement);
}
private setupEventListeners() {
this.mediaElement.addEventListener('play', () => {
this.dispatchEvent(new CustomEvent('vjs-play'));
});
this.mediaElement.addEventListener('pause', () => {
this.dispatchEvent(new CustomEvent('vjs-pause'));
});
this.mediaElement.addEventListener('timeupdate', () => {
this.dispatchEvent(new CustomEvent('vjs-timeupdate', {
detail: { currentTime: this.mediaElement.currentTime }
}));
});
this.mediaElement.addEventListener('loadedmetadata', () => {
this.dispatchEvent(new CustomEvent('vjs-loadedmetadata', {
detail: { duration: this.mediaElement.duration }
}));
});
}
async play() {
return this.playbackEngine.play();
}
pause() {
this.playbackEngine.pause();
}
seekTo(time: number) {
this.playbackEngine.seekTo(time);
}
get currentTime() {
return this.adapter.currentTime;
}
set currentTime(value: number) {
this.adapter.currentTime = value;
}
get duration() {
return this.adapter.duration;
}
get paused() {
return this.adapter.paused;
}
get volume() {
return this.adapter.volume;
}
set volume(value: number) {
this.adapter.volume = value;
}
get muted() {
return this.adapter.muted;
}
set muted(value: boolean) {
this.adapter.muted = value;
}
}
if (!customElements.get('vjs-media')) {
customElements.define('vjs-media', VjsMediaElement);
}
export { MediaElementLike, createMediaElementAdapter } from '@vjs-10/media';
export * from './media-provider';
@@ -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);
+1 -84
View File
@@ -1,84 +1 @@
import { MediaStore, MediaState, MediaStateOwner } from '@vjs-10/media-store';
import { VjsMediaElement } from '@vjs-10/html-media-elements';
export class HTMLMediaStateOwner implements MediaStateOwner {
private element: VjsMediaElement;
private listeners: Map<string, EventListener> = new Map();
constructor(element: VjsMediaElement) {
this.element = element;
this.setupEventListeners();
}
getState(): MediaState {
return {
currentTime: this.element.currentTime,
duration: this.element.duration,
paused: this.element.paused,
volume: this.element.volume,
muted: this.element.muted,
};
}
setState(state: Partial<MediaState>): void {
if (state.currentTime !== undefined && state.currentTime !== this.element.currentTime) {
this.element.currentTime = state.currentTime;
}
if (state.volume !== undefined && state.volume !== this.element.volume) {
this.element.volume = state.volume;
}
if (state.muted !== undefined && state.muted !== this.element.muted) {
this.element.muted = state.muted;
}
}
private setupEventListeners() {
const timeUpdateListener = () => {
this.dispatchStateChange();
};
const playPauseListener = () => {
this.dispatchStateChange();
};
const volumeChangeListener = () => {
this.dispatchStateChange();
};
this.element.addEventListener('vjs-timeupdate', timeUpdateListener);
this.element.addEventListener('vjs-play', playPauseListener);
this.element.addEventListener('vjs-pause', playPauseListener);
this.element.addEventListener('volumechange', volumeChangeListener);
this.listeners.set('timeupdate', timeUpdateListener);
this.listeners.set('play', playPauseListener);
this.listeners.set('pause', playPauseListener);
this.listeners.set('volumechange', volumeChangeListener);
}
private dispatchStateChange() {
this.element.dispatchEvent(new CustomEvent('vjs-state-change', {
detail: this.getState()
}));
}
destroy() {
this.listeners.forEach((listener, event) => {
this.element.removeEventListener(event as any, listener);
});
this.listeners.clear();
}
}
export function connectMediaElementToStore(element: VjsMediaElement, store: MediaStore): HTMLMediaStateOwner {
const owner = new HTMLMediaStateOwner(element);
store.addOwner(owner);
element.addEventListener('vjs-state-change', (event: CustomEvent) => {
store.updateState(event.detail);
});
return owner;
}
export { MediaStore, MediaState, MediaStateOwner } from '@vjs-10/media-store';
// Store integration utilities for HTML/Web Components
@@ -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;
}