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