mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude
parent
fc5a0e46fd
commit
4b472ec49c
@@ -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 +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 +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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user