diff --git a/packages/html/html/src/components/MuteButton.ts b/packages/html/html/src/components/MuteButton.ts new file mode 100644 index 00000000..018b1aad --- /dev/null +++ b/packages/html/html/src/components/MuteButton.ts @@ -0,0 +1,65 @@ +import { toConnectedHTMLComponent, StateHook, PropsHook, EventsHook } from '../utils/component-factory'; +import { MediaMuteButton } from './ui/media-mute-button'; + +/** + * MuteButton state hook - equivalent to React's useMuteButtonState + * Handles media store state subscription and transformation + */ +export const useMuteButtonState: StateHook<{ muted: boolean; volumeLevel: string }> = { + keys: ['mediaMuted', 'mediaVolumeLevel'], + transform: (rawState) => ({ + muted: rawState.mediaMuted ?? false, + volumeLevel: rawState.mediaVolumeLevel ?? 'off' + }) +}; + +/** + * MuteButton props hook - equivalent to React's useMuteButtonProps + * Handles element attributes and properties based on state + */ +export const useMuteButtonProps: PropsHook<{ muted: boolean; volumeLevel: string }> = (state, element) => { + // Handle boolean data attribute: present with empty string when true, absent when false + // This matches the React component behavior exactly + if (state.muted) { + element.setAttribute('data-muted', ''); + } else { + element.removeAttribute('data-muted'); + } + + // Set volume level data attribute + element.setAttribute('data-volume-level', state.volumeLevel); + + // Set element properties for backwards compatibility + // @ts-ignore - Custom element property + element.mediaMuted = state.muted; + // @ts-ignore - Custom element property + element.mediaVolumeLevel = state.volumeLevel; +}; + +/** + * MuteButton events hook - equivalent to React's event handlers + * Handles event dispatch to media store + */ +export const useMuteButtonEvents: EventsHook = { + events: ['mediamuterequest', 'mediaunmuterequest'], + handler: (event, mediaStore) => { + if (['mediamuterequest', 'mediaunmuterequest'].includes(event.type)) { + const { type, detail } = event; + mediaStore.dispatch({ type, detail }); + } + } +}; + +/** + * Connected MuteButton component using hook-style architecture + * Equivalent to React's MuteButton = toConnectedComponent(...) + */ +export const MuteButton = toConnectedHTMLComponent( + MediaMuteButton, + useMuteButtonState, + useMuteButtonProps, + useMuteButtonEvents, + 'MuteButton' +); + +export default MuteButton; \ No newline at end of file diff --git a/packages/html/html/src/components/PlayButton.ts b/packages/html/html/src/components/PlayButton.ts new file mode 100644 index 00000000..0bf7ad7c --- /dev/null +++ b/packages/html/html/src/components/PlayButton.ts @@ -0,0 +1,59 @@ +import { toConnectedHTMLComponent, StateHook, PropsHook, EventsHook } from '../utils/component-factory'; +import { MediaPlayButton } from './ui/media-play-button'; + +/** + * PlayButton state hook - equivalent to React's usePlayButtonState + * Handles media store state subscription and transformation + */ +export const usePlayButtonState: StateHook<{ paused: boolean }> = { + keys: ['mediaPaused'], + transform: (rawState) => ({ + paused: rawState.mediaPaused ?? true + }) +}; + +/** + * PlayButton props hook - equivalent to React's usePlayButtonProps + * Handles element attributes and properties based on state + */ +export const usePlayButtonProps: PropsHook<{ paused: boolean }> = (state, element) => { + // Handle boolean data attribute: present with empty string when true, absent when false + // This matches the React component behavior exactly + if (state.paused) { + element.setAttribute('data-paused', ''); + } else { + element.removeAttribute('data-paused'); + } + + // Set element property for backwards compatibility + // @ts-ignore - Custom element property + element.mediaPaused = state.paused; +}; + +/** + * PlayButton events hook - equivalent to React's event handlers + * Handles event dispatch to media store + */ +export const usePlayButtonEvents: EventsHook = { + events: ['mediaplayrequest', 'mediapauserequest'], + handler: (event, mediaStore) => { + if (['mediaplayrequest', 'mediapauserequest'].includes(event.type)) { + const { type, detail } = event; + mediaStore.dispatch({ type, detail }); + } + } +}; + +/** + * Connected PlayButton component using hook-style architecture + * Equivalent to React's PlayButton = toConnectedComponent(...) + */ +export const PlayButton = toConnectedHTMLComponent( + MediaPlayButton, + usePlayButtonState, + usePlayButtonProps, + usePlayButtonEvents, + 'PlayButton' +); + +export default PlayButton; \ No newline at end of file diff --git a/packages/html/html/src/components/connected-with-defaults/media-mute-button.ts b/packages/html/html/src/components/connected-with-defaults/media-mute-button.ts index ae7999db..4c816891 100644 --- a/packages/html/html/src/components/connected-with-defaults/media-mute-button.ts +++ b/packages/html/html/src/components/connected-with-defaults/media-mute-button.ts @@ -1,12 +1,10 @@ -import { toConnectedMediaMuteButton } from "../connected/media-mute-button"; -import { MediaMuteButton as BaseMediaMuteButton } from "../ui/media-mute-button"; -const MediaMuteButton = toConnectedMediaMuteButton(BaseMediaMuteButton); +import { MuteButton } from '../MuteButton.js'; // 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')) { // @ts-ignore - Custom element constructor compatibility - globalThis.customElements.define('media-mute-button', MediaMuteButton); + globalThis.customElements.define('media-mute-button', MuteButton); } -export { MediaMuteButton }; -export default MediaMuteButton; +export { MuteButton as MediaMuteButton }; +export default MuteButton; \ No newline at end of file diff --git a/packages/html/html/src/components/connected-with-defaults/media-play-button.ts b/packages/html/html/src/components/connected-with-defaults/media-play-button.ts index 3dfadb75..328101dd 100644 --- a/packages/html/html/src/components/connected-with-defaults/media-play-button.ts +++ b/packages/html/html/src/components/connected-with-defaults/media-play-button.ts @@ -1,12 +1,10 @@ -import { toConnectedMediaPlayButton } from "../connected/media-play-button"; -import { MediaPlayButton as BaseMediaPlayButton } from "../ui/media-play-button"; -const MediaPlayButton = toConnectedMediaPlayButton(BaseMediaPlayButton); +import { PlayButton } from '../PlayButton.js'; // 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')) { // @ts-ignore - Custom element constructor compatibility - globalThis.customElements.define('media-play-button', MediaPlayButton); + globalThis.customElements.define('media-play-button', PlayButton); } -export { MediaPlayButton }; -export default MediaPlayButton; +export { PlayButton as MediaPlayButton }; +export default PlayButton; \ No newline at end of file diff --git a/packages/html/html/src/components/connected/media-mute-button.ts b/packages/html/html/src/components/connected/media-mute-button.ts deleted file mode 100644 index 75a6f06e..00000000 --- a/packages/html/html/src/components/connected/media-mute-button.ts +++ /dev/null @@ -1,65 +0,0 @@ -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) => { - // @ts-ignore - Custom element constructor compatibility - 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; - // @ts-ignore - Element property access - this.setAttribute('data-volume-level', mediaVolumeLevel); - // @ts-ignore - Element property access - this.toggleAttribute('data-muted', mediaMuted); - }, - ); - }, - }; - - connectedCallback(): void { - super.connectedCallback?.(); - // @ts-ignore - Element property access - this.addEventListener('mediamuterequest', this); - // @ts-ignore - Element property access - this.addEventListener('mediaunmuterequest', this); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - // @ts-ignore - Element property access - this.removeEventListener('mediamuterequest', this); - // @ts-ignore - Element property access - 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 }); - } - } - }; -}; diff --git a/packages/html/html/src/components/connected/media-play-button.ts b/packages/html/html/src/components/connected/media-play-button.ts deleted file mode 100644 index 8a88e468..00000000 --- a/packages/html/html/src/components/connected/media-play-button.ts +++ /dev/null @@ -1,63 +0,0 @@ -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; - // @ts-ignore - Element property access - this.toggleAttribute('data-paused', mediaPaused); - }, - ); - }, - }; - - connectedCallback(): void { - super.connectedCallback?.(); - // @ts-ignore - Element property access - this.addEventListener('mediaplayrequest', this); - // @ts-ignore - Element property access - this.addEventListener('mediapauserequest', this); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - // @ts-ignore - Element property access - this.removeEventListener('mediaplayrequest', this); - // @ts-ignore - Element property access - 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 }); - } - } - }; -}; diff --git a/packages/html/html/src/index.ts b/packages/html/html/src/index.ts index 49851cd6..16c099c7 100644 --- a/packages/html/html/src/index.ts +++ b/packages/html/html/src/index.ts @@ -1,6 +1,10 @@ export * as MediaProvider from './media-provider.js'; export * as MediaThemeDefault from './skins/media-skin-default.js'; +// New hook-style components +export { PlayButton } from './components/PlayButton.js'; +export { MuteButton } from './components/MuteButton.js'; + export function defineVjsPlayer() { /** @TODO - Reimplement me (at least as a POC) (CJP) */ // defineVideoProvider(); diff --git a/packages/html/html/src/utils/component-factory.ts b/packages/html/html/src/utils/component-factory.ts new file mode 100644 index 00000000..2eb3acd9 --- /dev/null +++ b/packages/html/html/src/utils/component-factory.ts @@ -0,0 +1,107 @@ +import { ConsumerMixin } from '@open-wc/context-protocol'; + +/** + * Generic types for HTML component hooks pattern + * Mirrors the React hooks architecture for consistency + */ +export type StateHook = { + keys: string[]; + transform: (rawState: any) => T; +}; + +export type PropsHook = (state: T, element: HTMLElement) => void; + +export type EventsHook = { + events: string[]; + handler: (event: CustomEvent, mediaStore: any) => void; +}; + +/** + * Generic factory function to create connected HTML components using hooks pattern. + * Provides equivalent functionality to React's toConnectedComponent but for custom elements. + * + * @param BaseClass - Base custom element class to extend + * @param stateHook - Hook that defines state keys and transformation logic + * @param propsHook - Hook that handles element attributes and properties based on state + * @param eventsHook - Hook that defines event handling logic + * @param displayName - Display name for debugging + * @returns Connected custom element class with media store integration + */ +export const toConnectedHTMLComponent = ( + BaseClass: CustomElementConstructor, + stateHook: StateHook, + propsHook: PropsHook, + eventsHook: EventsHook, + displayName?: string +) => { + const ConnectedComponent = class extends ConsumerMixin(BaseClass) { + static get observedAttributes(): string[] { + return [ + // @ts-ignore + ...(super.observedAttributes ?? []) + ]; + } + + _mediaStore: any; + _state: TState | undefined; + + contexts = { + mediaStore: (mediaStore: any) => { + this._mediaStore = mediaStore; + + // Subscribe to media store state changes + // Split into two phases: state transformation, then props update + this._mediaStore.subscribeKeys( + stateHook.keys, + (rawState: any) => { + // Phase 1: Transform raw media store state (state concern) + this._state = stateHook.transform(rawState); + + // Phase 2: Update element attributes/properties (props concern) + if (this._state !== undefined) { + // @ts-ignore - Element property access + propsHook(this._state, this); + } + } + ); + } + }; + + connectedCallback(): void { + super.connectedCallback?.(); + + // Set up event listeners + eventsHook.events.forEach(eventType => { + // @ts-ignore - Element property access + this.addEventListener(eventType, this); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback?.(); + + // Clean up event listeners + eventsHook.events.forEach(eventType => { + // @ts-ignore - Element property access + this.removeEventListener(eventType, this); + }); + } + + handleEvent(event: CustomEvent): void { + // @ts-ignore + super.handleEvent?.(event); + + // Delegate to event hook if media store is available + if (this._mediaStore) { + eventsHook.handler(event, this._mediaStore); + } + } + }; + + // Set display name for debugging and dev tools + if (displayName) { + Object.defineProperty(ConnectedComponent, 'name', { value: displayName }); + } + + return ConnectedComponent; +}; \ No newline at end of file