From 4b472ec49cd91f0af61cb5aaa039d428982d3b91 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 29 Jul 2025 13:17:46 -0700 Subject: [PATCH] feat(monorepo): migrate prototype code to organized package structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 42 +++- package.json | 4 +- packages/core/media-store/package.json | 3 + packages/core/media-store/src/factory.ts | 134 +++++++++++ packages/core/media-store/src/index.ts | 50 +--- packages/core/media-store/src/media-store.ts | 9 + .../src/state-mediators/audible.ts | 48 ++++ .../src/state-mediators/playable.ts | 18 ++ packages/core/media/src/index.ts | 70 +----- packages/core/media/src/playable.ts | 112 +++++++++ packages/core/playback-engine/package.json | 3 + .../src/HlsJSPlaybackEngine.ts | 76 ++++++ packages/core/playback-engine/src/index.ts | 84 +------ packages/html/html-icons/src/index.ts | 139 +---------- .../html/html-icons/src/media-chrome-icon.ts | 26 +++ .../html/html-icons/src/media-pause-icon.ts | 16 ++ .../html/html-icons/src/media-play-icon.ts | 21 ++ .../html-icons/src/media-volume-high-icon.ts | 23 ++ .../html-icons/src/media-volume-low-icon.ts | 23 ++ .../html-icons/src/media-volume-off-icon.ts | 23 ++ .../html/html-media-elements/package.json | 4 +- .../html/html-media-elements/src/index.ts | 156 +------------ .../html-media-elements/src/media-provider.ts | 12 + packages/html/html-media-store/src/index.ts | 85 +------ .../media-mute-button.ts | 11 + .../media-play-button.ts | 11 + .../components/connected/media-mute-button.ts | 58 +++++ .../components/connected/media-play-button.ts | 58 +++++ .../src/components/ui/media-chrome-button.ts | 47 ++++ .../src/components/ui/media-mute-button.ts | 34 +++ .../src/components/ui/media-play-button.ts | 22 ++ packages/html/html/src/index.ts | 221 +----------------- packages/html/html/src/media-container.ts | 42 ++++ packages/html/html/src/media-provider.ts | 12 + packages/html/html/src/media-skin.ts | 34 +++ .../html/html/src/skins/media-skin-default.ts | 107 +++++++++ packages/html/html/src/utils/element-utils.ts | 7 + .../react/react-icons/src/icons/PauseIcon.tsx | 11 + .../react/react-icons/src/icons/PlayIcon.tsx | 11 + .../react-icons/src/icons/VolumeHighIcon.tsx | 14 ++ .../react-icons/src/icons/VolumeLowIcon.tsx | 14 ++ .../react-icons/src/icons/VolumeOffIcon.tsx | 14 ++ .../react/react-icons/src/icons/types.d.ts | 6 + packages/react/react-icons/src/index.ts | 5 + .../react/react-media-elements/src/Video.tsx | 86 +++++++ .../react/react-media-elements/src/index.ts | 1 + packages/react/react-media-store/package.json | 3 +- .../react-media-store/src/MediaProvider.tsx | 83 +++++++ packages/react/react-media-store/src/index.ts | 1 + .../src/useSyncExternalStoreWithSelector.ts | 144 ++++++++++++ .../connected-with-defaults/MuteButton.tsx | 30 +++ .../connected-with-defaults/PlayButton.tsx | 30 +++ .../connected-with-defaults/Video.tsx | 86 +++++++ .../src/components/connected/MuteButton.tsx | 52 +++++ .../src/components/connected/PlayButton.tsx | 52 +++++ .../react/src/components/connected/Video.tsx | 53 +++++ .../react/src/components/ui/MuteButton.tsx | 46 ++++ .../react/src/components/ui/PlayButton.tsx | 40 ++++ packages/react/react/src/index.tsx | 205 +--------------- .../react/src/skins/MediaSkinDefault.tsx | 40 ++++ .../react/react/src/skins/styles.module.css | 90 +++++++ 61 files changed, 1967 insertions(+), 995 deletions(-) create mode 100644 packages/core/media-store/src/factory.ts create mode 100644 packages/core/media-store/src/media-store.ts create mode 100644 packages/core/media-store/src/state-mediators/audible.ts create mode 100644 packages/core/media-store/src/state-mediators/playable.ts create mode 100644 packages/core/media/src/playable.ts create mode 100644 packages/core/playback-engine/src/HlsJSPlaybackEngine.ts create mode 100644 packages/html/html-icons/src/media-chrome-icon.ts create mode 100644 packages/html/html-icons/src/media-pause-icon.ts create mode 100644 packages/html/html-icons/src/media-play-icon.ts create mode 100644 packages/html/html-icons/src/media-volume-high-icon.ts create mode 100644 packages/html/html-icons/src/media-volume-low-icon.ts create mode 100644 packages/html/html-icons/src/media-volume-off-icon.ts create mode 100644 packages/html/html-media-elements/src/media-provider.ts create mode 100644 packages/html/html/src/components/connected-with-defaults/media-mute-button.ts create mode 100644 packages/html/html/src/components/connected-with-defaults/media-play-button.ts create mode 100644 packages/html/html/src/components/connected/media-mute-button.ts create mode 100644 packages/html/html/src/components/connected/media-play-button.ts create mode 100644 packages/html/html/src/components/ui/media-chrome-button.ts create mode 100644 packages/html/html/src/components/ui/media-mute-button.ts create mode 100644 packages/html/html/src/components/ui/media-play-button.ts create mode 100644 packages/html/html/src/media-container.ts create mode 100644 packages/html/html/src/media-provider.ts create mode 100644 packages/html/html/src/media-skin.ts create mode 100644 packages/html/html/src/skins/media-skin-default.ts create mode 100644 packages/html/html/src/utils/element-utils.ts create mode 100644 packages/react/react-icons/src/icons/PauseIcon.tsx create mode 100644 packages/react/react-icons/src/icons/PlayIcon.tsx create mode 100644 packages/react/react-icons/src/icons/VolumeHighIcon.tsx create mode 100644 packages/react/react-icons/src/icons/VolumeLowIcon.tsx create mode 100644 packages/react/react-icons/src/icons/VolumeOffIcon.tsx create mode 100644 packages/react/react-icons/src/icons/types.d.ts create mode 100644 packages/react/react-icons/src/index.ts create mode 100644 packages/react/react-media-elements/src/Video.tsx create mode 100644 packages/react/react-media-elements/src/index.ts create mode 100644 packages/react/react-media-store/src/MediaProvider.tsx create mode 100644 packages/react/react-media-store/src/index.ts create mode 100644 packages/react/react-media-store/src/useSyncExternalStoreWithSelector.ts create mode 100644 packages/react/react/src/components/connected-with-defaults/MuteButton.tsx create mode 100644 packages/react/react/src/components/connected-with-defaults/PlayButton.tsx create mode 100644 packages/react/react/src/components/connected-with-defaults/Video.tsx create mode 100644 packages/react/react/src/components/connected/MuteButton.tsx create mode 100644 packages/react/react/src/components/connected/PlayButton.tsx create mode 100644 packages/react/react/src/components/connected/Video.tsx create mode 100644 packages/react/react/src/components/ui/MuteButton.tsx create mode 100644 packages/react/react/src/components/ui/PlayButton.tsx create mode 100644 packages/react/react/src/skins/MediaSkinDefault.tsx create mode 100644 packages/react/react/src/skins/styles.module.css diff --git a/package-lock.json b/package-lock.json index 93c65970..6763da95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ ], "devDependencies": { "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.0", "typescript": "^5.3.0" }, "engines": { @@ -2126,6 +2128,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@open-wc/context-protocol": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@open-wc/context-protocol/-/context-protocol-0.0.9.tgz", + "integrity": "sha512-3mMJ16BbRqFaZNR7ongBJEEvAjZFBpo5qUtc2dk/rlk3k09AVRp2Dcldu5VTB0r/tsWdfExbRLlCw/8f3cD/kQ==", + "license": "ISC" + }, "node_modules/@react-native-community/cli": { "version": "11.4.1", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-11.4.1.tgz", @@ -4080,6 +4088,12 @@ "node": ">=8" } }, + "node_modules/hls.js": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.7.tgz", + "integrity": "sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5519,6 +5533,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanostores": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.0.1.tgz", + "integrity": "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -7385,6 +7414,9 @@ "name": "@vjs-10/media-store", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "nanostores": "^1.0.1" + }, "devDependencies": { "typescript": "^5.3.0" } @@ -7393,6 +7425,9 @@ "name": "@vjs-10/playback-engine", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "hls.js": "^1.6.7" + }, "devDependencies": { "typescript": "^5.3.0" } @@ -7426,8 +7461,8 @@ "version": "0.1.0", "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" @@ -7564,8 +7599,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@vjs-10/media-store": "*", - "@vjs-10/react-media-elements": "*" + "@vjs-10/media-store": "*" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/package.json b/package.json index 13a0c1d0..35b7e44e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ }, "devDependencies": { "typescript": "^5.3.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.0" }, "engines": { "node": ">=18.0.0", diff --git a/packages/core/media-store/package.json b/packages/core/media-store/package.json index acd5f042..6f4abe41 100644 --- a/packages/core/media-store/package.json +++ b/packages/core/media-store/package.json @@ -20,6 +20,9 @@ "player" ], "license": "Apache-2.0", + "dependencies": { + "nanostores": "^1.0.1" + }, "devDependencies": { "typescript": "^5.3.0" }, diff --git a/packages/core/media-store/src/factory.ts b/packages/core/media-store/src/factory.ts new file mode 100644 index 00000000..1216c7ac --- /dev/null +++ b/packages/core/media-store/src/factory.ts @@ -0,0 +1,134 @@ +import { map, subscribeKeys } from 'nanostores'; + +export type StateOwners = { + media?: any; +}; + +export type EventOrAction = { + type: string; + detail?: D; + target?: EventTarget; +}; + +export type FacadeGetter = ( + stateOwners: StateOwners, + event?: EventOrAction, +) => T; + +export type FacadeSetter = (value: T, stateOwners: StateOwners) => void; + +export type StateOwnerUpdateHandler = ( + handler: (value: T) => void, + stateOwners: StateOwners, +) => void; + +export type ReadonlyFacadeProp = { + get: FacadeGetter; + mediaEvents?: string[]; +}; + +export type FacadeProp = ReadonlyFacadeProp & { + set: FacadeSetter; + /** @TODO We probably need to refactor this for more complex cases where we can't simply translate to a setter */ + actions: { [k: string]: (val: CustomEvent) => ReturnType> }; +}; + +export type StateMediator = { + mediaPaused: FacadeProp; + mediaMuted: FacadeProp; + mediaVolume: FacadeProp; + mediaVolumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>; +}; + +export function createMediaStore({ + // media, + stateMediator, +}: { + media?: any; + stateMediator: Partial & Pick; +}) { + const stateOwners: any = {}; + const store = map({}); + const stateUpdateHandlers: Record void> = {}; + const keys = Object.keys(stateMediator); + + function updateStateOwners(nextStateOwners: any) { + if (nextStateOwners.media === stateOwners.media) { + return; + } + + let media = stateOwners.media; + if (media) { + for (const { mediaEvents = [] } of Object.values(stateMediator)) { + for (const mediaEvent of mediaEvents) { + media.removeEventListener( + mediaEvent, + stateUpdateHandlers[mediaEvent], + ); + delete stateUpdateHandlers[mediaEvent]; + } + } + } + + Object.assign(stateOwners, nextStateOwners); + + media = stateOwners.media; + store.set(getInitialState(stateMediator, stateOwners)); + + if (media) { + for (const [stateName, stateObject] of Object.entries(stateMediator)) { + const { get, mediaEvents = [] } = stateObject; + for (const mediaEvent of mediaEvents) { + stateUpdateHandlers[mediaEvent] = () => + store.setKey(stateName, get(stateOwners)); + media.addEventListener(mediaEvent, stateUpdateHandlers[mediaEvent]); + } + } + } + } + + return { + dispatch(action: any) { + const { type, detail } = action; + + if (type === 'mediaelementchangerequest') { + updateStateOwners({ media: detail }); + } else { + for (const stateObject of Object.values(stateMediator).filter( + (stateMediator) => 'set' in stateMediator, + )) { + const { set, actions } = stateObject; + if (type in actions) { + /** @TODO FIXME ASAP!!! (CJP) */ + // @ts-ignore + set(actions[type as keyof typeof actions](), stateOwners); + } + } + } + }, + + getState() { + return store.get(); + }, + + subscribeKeys(keys: string[], callback: (state: any) => void) { + subscribeKeys(store, keys, callback); + }, + + // NOTE: In the POC architecture using nano-stores, subscribe is simply subscribeKeys across all keys. (CJP) + subscribe(callback: (state: any) => void) { + subscribeKeys(store, keys, callback); + } + }; +} + +function getInitialState( + stateMediator: Partial & Pick, + stateOwners: any, +) { + let initialState: any = {}; + for (const [stateName, { get }] of Object.entries(stateMediator)) { + initialState[stateName] = get(stateOwners); + } + return initialState; +} \ No newline at end of file diff --git a/packages/core/media-store/src/index.ts b/packages/core/media-store/src/index.ts index ac059bbc..8eedbc40 100644 --- a/packages/core/media-store/src/index.ts +++ b/packages/core/media-store/src/index.ts @@ -1,46 +1,4 @@ -export interface MediaState { - currentTime: number; - duration: number; - paused: boolean; - volume: number; - muted: boolean; -} - -export interface MediaStateOwner { - getState(): MediaState; - setState(state: Partial): void; -} - -export class MediaStore { - private owners: Set = new Set(); - private state: MediaState = { - currentTime: 0, - duration: 0, - paused: true, - volume: 1, - muted: false, - }; - - addOwner(owner: MediaStateOwner): void { - this.owners.add(owner); - } - - removeOwner(owner: MediaStateOwner): void { - this.owners.delete(owner); - } - - getState(): MediaState { - return { ...this.state }; - } - - updateState(updates: Partial): void { - this.state = { ...this.state, ...updates }; - this.notifyOwners(); - } - - private notifyOwners(): void { - this.owners.forEach(owner => { - owner.setState(this.state); - }); - } -} \ No newline at end of file +export * from './factory'; +export * from './media-store'; +export * from './state-mediators/playable'; +export * from './state-mediators/audible'; \ No newline at end of file diff --git a/packages/core/media-store/src/media-store.ts b/packages/core/media-store/src/media-store.ts new file mode 100644 index 00000000..5fb07826 --- /dev/null +++ b/packages/core/media-store/src/media-store.ts @@ -0,0 +1,9 @@ +import { playable } from './state-mediators/playable'; +import { audible } from './state-mediators/audible'; +import { createMediaStore as factory } from './factory'; +// Example of default media store with default state mediator definitions. (CJP) +// NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP) +const stateMediator = { ...playable, ...audible }; +type Params = Partial[0]>; +export const createMediaStore = (params: Params = {}) => + factory({ stateMediator, ...params }); \ No newline at end of file diff --git a/packages/core/media-store/src/state-mediators/audible.ts b/packages/core/media-store/src/state-mediators/audible.ts new file mode 100644 index 00000000..5748a54c --- /dev/null +++ b/packages/core/media-store/src/state-mediators/audible.ts @@ -0,0 +1,48 @@ +export const audible = { + mediaMuted: { + get(stateOwners: any) { + const { media } = stateOwners; + return media?.muted ?? false; + }, + set(value: boolean, stateOwners: any) { + const { media } = stateOwners; + if (!media) return; + media.muted = value; + }, + mediaEvents: ['volumechange'], + actions: { + /** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API or aren't simple 1:1 with getter vs. setter (CJP) */ + mediamuterequest: () => true, + mediaunmuterequest: () => false, + }, + }, + mediaVolume: { + get(stateOwners: any) { + const { media } = stateOwners; + return media?.volume ?? 1.0; + }, + set(value: number, stateOwners: any) { + const { media } = stateOwners; + if (!media) return; + if (!Number.isFinite(+value)) return; + media.volume = +value; + }, + mediaEvents: ['volumechange'], + actions: { + /** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */ + mediavolumerequest: ({ detail }: Pick, 'detail'> = { detail: 0 }) => +detail, + }, + }, + // NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP) + mediaVolumeLevel: { + get(stateOwners: any) { + const { media } = stateOwners; + if (typeof media?.volume == 'undefined') return 'high'; + if (media.muted || media.volume === 0) return 'off'; + if (media.volume < 0.5) return 'low'; + if (media.volume < 0.75) return 'medium'; + return 'high'; + }, + mediaEvents: ['volumechange'], + }, +}; \ No newline at end of file diff --git a/packages/core/media-store/src/state-mediators/playable.ts b/packages/core/media-store/src/state-mediators/playable.ts new file mode 100644 index 00000000..dbfe31a7 --- /dev/null +++ b/packages/core/media-store/src/state-mediators/playable.ts @@ -0,0 +1,18 @@ +export const playable = { + mediaPaused: { + get(stateOwners: any) { + const { media } = stateOwners; + return media?.paused ?? true; + }, + set(value: boolean, stateOwners: any) { + const { media } = stateOwners; + media?.[value ? 'pause' : 'play'](); + }, + mediaEvents: ['play', 'playing', 'pause', 'emptied'], + actions: { + /** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */ + mediaplayrequest: () => false, + mediapauserequest: () => true, + }, + }, +}; \ No newline at end of file diff --git a/packages/core/media/src/index.ts b/packages/core/media/src/index.ts index 15b23992..6752d241 100644 --- a/packages/core/media/src/index.ts +++ b/packages/core/media/src/index.ts @@ -1,69 +1 @@ -export type MediaReadyState = 0 | 1 | 2 | 3 | 4; - -export const READY_STATE = { - HAVE_NOTHING: 0 as MediaReadyState, - HAVE_METADATA: 1 as MediaReadyState, - HAVE_CURRENT_DATA: 2 as MediaReadyState, - HAVE_FUTURE_DATA: 3 as MediaReadyState, - HAVE_ENOUGH_DATA: 4 as MediaReadyState, -} as const; - -export type MediaNetworkState = 0 | 1 | 2 | 3; - -export const NETWORK_STATE = { - EMPTY: 0 as MediaNetworkState, - IDLE: 1 as MediaNetworkState, - LOADING: 2 as MediaNetworkState, - NO_SOURCE: 3 as MediaNetworkState, -} as const; - -export interface MediaElementLike { - currentTime: number; - duration: number; - paused: boolean; - ended: boolean; - volume: number; - muted: boolean; - playbackRate: number; - readyState: MediaReadyState; - networkState: MediaNetworkState; - - play(): Promise; - pause(): void; - load(): void; -} - -export function isMediaElement(element: any): element is HTMLMediaElement { - return element && - typeof element.play === 'function' && - typeof element.pause === 'function' && - typeof element.load === 'function' && - typeof element.currentTime === 'number'; -} - -export function createMediaElementAdapter(element: HTMLMediaElement): MediaElementLike { - return { - get currentTime() { return element.currentTime; }, - set currentTime(value: number) { element.currentTime = value; }, - - get duration() { return element.duration; }, - get paused() { return element.paused; }, - get ended() { return element.ended; }, - - get volume() { return element.volume; }, - set volume(value: number) { element.volume = value; }, - - get muted() { return element.muted; }, - set muted(value: boolean) { element.muted = value; }, - - get playbackRate() { return element.playbackRate; }, - set playbackRate(value: number) { element.playbackRate = value; }, - - get readyState() { return element.readyState as MediaReadyState; }, - get networkState() { return element.networkState as MediaNetworkState; }, - - play: () => element.play(), - pause: () => element.pause(), - load: () => element.load(), - }; -} \ No newline at end of file +export * from './playable'; \ No newline at end of file diff --git a/packages/core/media/src/playable.ts b/packages/core/media/src/playable.ts new file mode 100644 index 00000000..e9fb79c3 --- /dev/null +++ b/packages/core/media/src/playable.ts @@ -0,0 +1,112 @@ +import type { IBasePlaybackEngine } from '@vjs-10/playback-engine'; +import { createPlaybackEngine } from '@vjs-10/playback-engine'; +/** @TODO Split out "playable" vs. "audible" and compose via factory (current mixin pattern or spreadable mixin pattern) (CJP) */ +export const Events = ['volumechange', 'pause', 'play', 'playing'] as const; + +export interface IBaseMediaStateOwner< + T extends Pick = Pick, +> extends EventTarget, + Pick { + mediaElement?: T; +} + +export interface IPlayableMediaStateOwner + extends EventTarget, + IBaseMediaStateOwner, + Pick {} + +export interface IAudibleMediaStateOwner + extends EventTarget, + IBaseMediaStateOwner, + Pick {} + +export class PlayableMediaStateOwner + extends EventTarget + implements IPlayableMediaStateOwner, IAudibleMediaStateOwner +{ + protected _playbackEngine: IBasePlaybackEngine; + constructor() { + super(); + this._playbackEngine = createPlaybackEngine(); + } + + get mediaElement() { + return this._playbackEngine.mediaElement; + } + + set mediaElement(value) { + if (this.mediaElement === value) return; + if (this.mediaElement != null) { + Events.forEach((eventType) => { + this.mediaElement?.removeEventListener(eventType, this); + }); + } + this._playbackEngine.mediaElement = value; + if (this.mediaElement) { + Events.forEach((eventType) => { + this.mediaElement?.addEventListener(eventType, this); + }); + } + } + + get paused() { + return this.mediaElement?.paused ?? false; + } + + pause() { + /** @TODO implement deferred state etc. for cases where media has yet to be set */ + if (!this.mediaElement) return; + return this.mediaElement.pause(); + } + + play() { + /** @TODO implement deferred state etc. for cases where media has yet to be set */ + if (!this.mediaElement) return Promise.reject(); + return this.mediaElement.play(); + } + + get muted() { + return this.mediaElement?.muted ?? false; + } + + set muted(value) { + if (value === this.muted) return; + /** @TODO implement deferred state etc. for cases where media has yet to be set */ + if (!this.mediaElement) return; + this.mediaElement.muted = value; + } + + get volume() { + return this.mediaElement?.volume ?? 0; + } + + set volume(value) { + if (value === this.volume) return; + /** @TODO implement deferred state etc. for cases where media has yet to be set */ + if (!this.mediaElement) return; + this.mediaElement.volume = value; + } + + get src() { + return this._playbackEngine.src ?? ''; + } + + set src(value) { + this._playbackEngine.src = value; + } + + handleEvent(event: Event): void { + if (event.target === this.mediaElement) { + // @ts-ignore + const clonedEvent = new event.constructor(event.type, event); + this.dispatchEvent(clonedEvent); + } + } + + destroy() { + this.mediaElement = undefined; + this._playbackEngine.destroy(); + } +} + +export const createMediaStateOwner = () => new PlayableMediaStateOwner(); \ No newline at end of file diff --git a/packages/core/playback-engine/package.json b/packages/core/playback-engine/package.json index 33bc0c8f..43d235c9 100644 --- a/packages/core/playback-engine/package.json +++ b/packages/core/playback-engine/package.json @@ -21,6 +21,9 @@ "streaming" ], "license": "Apache-2.0", + "dependencies": { + "hls.js": "^1.6.7" + }, "devDependencies": { "typescript": "^5.3.0" }, diff --git a/packages/core/playback-engine/src/HlsJSPlaybackEngine.ts b/packages/core/playback-engine/src/HlsJSPlaybackEngine.ts new file mode 100644 index 00000000..b61c8a86 --- /dev/null +++ b/packages/core/playback-engine/src/HlsJSPlaybackEngine.ts @@ -0,0 +1,76 @@ +import Hls from 'hls.js'; + +/** @TODO Move interface/type defs (CJP) */ +/** @TODO Narrow and/or parameterize EventTarget via generics/inference (CJP) */ +export interface IBasePlaybackEngine extends EventTarget { + src: HTMLMediaElement['src'] | undefined; + // NOTE: Unlike other APIs, this *must* be an HTMLMediaElement based on internal and/or external API assumptions (CJP) + mediaElement: HTMLMediaElement | undefined; + /** @TODO Widen/extend and/or parameterize EventTarget via generics/inference (CJP) */ + element: HTMLElement | undefined; + destroy: () => void; +} + +export class HlsJSPlaybackEngine extends EventTarget { + constructor() { + super(); + } + + protected _hlsInstance: Hls | undefined; + + get src() { + return this._hlsInstance?.url ?? undefined; + } + + set src(val) { + if (this.src === val) return; + if (!this._hlsInstance) { + this._createHlsInstance(); + } + if (!this.src && val) { + this._hlsInstance?.loadSource(val); + } + } + + // NOTE: Some playback engines may not rely on an underlying HTMLMediaElement but still have + // an HTMLElement (e.g. HTMLCanvasElement, HTMLImageElement, etc.). This API provides a more + // generic interface for cases where we want more minimal assumptions (e.g. fullscreen behaviors for most playtforms but not iOS) + get element(): HTMLElement | undefined { + return this.mediaElement; + } + + set element(val) { + if (this.element === val) return; + /** @TODO programmatic type checking? (CJP) */ + this.mediaElement = val as HTMLMediaElement; + } + + get mediaElement() { + return this._hlsInstance?.media ?? undefined; + } + + set mediaElement(val) { + if (this.mediaElement === val) return; + if (!this._hlsInstance) { + this._createHlsInstance(); + } + if (!this.mediaElement && val) { + this._hlsInstance?.attachMedia(val); + } + } + + protected _createHlsInstance() { + this._hlsInstance = new Hls(); + } + + protected _destroyHlsInstance() { + this._hlsInstance?.destroy(); + this._hlsInstance = undefined; + } + + destroy() { + this._destroyHlsInstance(); + } +} + +export const createPlaybackEngine = () => new HlsJSPlaybackEngine(); \ No newline at end of file diff --git a/packages/core/playback-engine/src/index.ts b/packages/core/playback-engine/src/index.ts index 8529552c..beb77c85 100644 --- a/packages/core/playback-engine/src/index.ts +++ b/packages/core/playback-engine/src/index.ts @@ -1,83 +1 @@ -export interface MediaSource { - src: string; - type: string; -} - -export interface PlaybackEngineOptions { - autoplay?: boolean; - preload?: 'none' | 'metadata' | 'auto'; -} - -export abstract class PlaybackEngine { - protected element: HTMLMediaElement | null = null; - protected options: PlaybackEngineOptions; - - constructor(options: PlaybackEngineOptions = {}) { - this.options = options; - } - - abstract attach(element: HTMLMediaElement): void; - abstract detach(): void; - abstract load(source: MediaSource): Promise; - abstract play(): Promise; - abstract pause(): void; - abstract seekTo(time: number): void; - - getElement(): HTMLMediaElement | null { - return this.element; - } -} - -export class NativePlaybackEngine extends PlaybackEngine { - attach(element: HTMLMediaElement): void { - this.element = element; - } - - detach(): void { - this.element = null; - } - - async load(source: MediaSource): Promise { - if (!this.element) { - throw new Error('No media element attached'); - } - - this.element.src = source.src; - await new Promise((resolve, reject) => { - const onLoad = () => { - this.element!.removeEventListener('loadedmetadata', onLoad); - this.element!.removeEventListener('error', onError); - resolve(void 0); - }; - const onError = () => { - this.element!.removeEventListener('loadedmetadata', onLoad); - this.element!.removeEventListener('error', onError); - reject(new Error('Failed to load media')); - }; - - this.element.addEventListener('loadedmetadata', onLoad); - this.element.addEventListener('error', onError); - }); - } - - async play(): Promise { - if (!this.element) { - throw new Error('No media element attached'); - } - await this.element.play(); - } - - pause(): void { - if (!this.element) { - throw new Error('No media element attached'); - } - this.element.pause(); - } - - seekTo(time: number): void { - if (!this.element) { - throw new Error('No media element attached'); - } - this.element.currentTime = time; - } -} \ No newline at end of file +export * from './HlsJSPlaybackEngine'; \ No newline at end of file diff --git a/packages/html/html-icons/src/index.ts b/packages/html/html-icons/src/index.ts index 334c8784..a0eb7777 100644 --- a/packages/html/html-icons/src/index.ts +++ b/packages/html/html-icons/src/index.ts @@ -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 = `Icon "${this._name}" not found`; - return; - } - - const svgString = createSVGString(icon); - const styles = ` - - `; - - 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'; \ No newline at end of file +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'; \ No newline at end of file diff --git a/packages/html/html-icons/src/media-chrome-icon.ts b/packages/html/html-icons/src/media-chrome-icon.ts new file mode 100644 index 00000000..b6a72490 --- /dev/null +++ b/packages/html/html-icons/src/media-chrome-icon.ts @@ -0,0 +1,26 @@ +export function getTemplateHTML() { + return /* html */ ` + + `; +} + +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(); + } + } +} \ No newline at end of file diff --git a/packages/html/html-icons/src/media-pause-icon.ts b/packages/html/html-icons/src/media-pause-icon.ts new file mode 100644 index 00000000..c9b59b1e --- /dev/null +++ b/packages/html/html-icons/src/media-pause-icon.ts @@ -0,0 +1,16 @@ +import { MediaChromeIcon } from './media-chrome-icon.js'; + +export function getTemplateHTML() { + return /* html */ ` + ${MediaChromeIcon.getTemplateHTML()} + + + + `; +} + +export class MediaPauseIcon extends MediaChromeIcon { + static getTemplateHTML = getTemplateHTML; +} + +customElements.define('media-pause-icon', MediaPauseIcon); \ No newline at end of file diff --git a/packages/html/html-icons/src/media-play-icon.ts b/packages/html/html-icons/src/media-play-icon.ts new file mode 100644 index 00000000..03a2a76a --- /dev/null +++ b/packages/html/html-icons/src/media-play-icon.ts @@ -0,0 +1,21 @@ +import { MediaChromeIcon } from './media-chrome-icon.js'; + +export function getTemplateHTML() { + return /* html */ ` + ${MediaChromeIcon.getTemplateHTML()} + + + + + `; +} + +export class MediaPlayIcon extends MediaChromeIcon { + static getTemplateHTML = getTemplateHTML; +} + +customElements.define('media-play-icon', MediaPlayIcon); \ No newline at end of file diff --git a/packages/html/html-icons/src/media-volume-high-icon.ts b/packages/html/html-icons/src/media-volume-high-icon.ts new file mode 100644 index 00000000..74df096c --- /dev/null +++ b/packages/html/html-icons/src/media-volume-high-icon.ts @@ -0,0 +1,23 @@ +import { MediaChromeIcon } from './media-chrome-icon.js'; + +const highIcon = ``; + +export function getTemplateHTML() { + return /* html */ ` + ${MediaChromeIcon.getTemplateHTML()} + + ${highIcon} + `; +} + +export class MediaVolumeHighIcon extends MediaChromeIcon { + static getTemplateHTML = getTemplateHTML; +} + +customElements.define('media-volume-high-icon', MediaVolumeHighIcon); diff --git a/packages/html/html-icons/src/media-volume-low-icon.ts b/packages/html/html-icons/src/media-volume-low-icon.ts new file mode 100644 index 00000000..c9b9b0a7 --- /dev/null +++ b/packages/html/html-icons/src/media-volume-low-icon.ts @@ -0,0 +1,23 @@ +import { MediaChromeIcon } from './media-chrome-icon.js'; + +const lowIcon = ``; + +export function getTemplateHTML() { + return /* html */ ` + ${MediaChromeIcon.getTemplateHTML()} + + ${lowIcon} + `; +} + +export class MediaVolumeLowIcon extends MediaChromeIcon { + static getTemplateHTML = getTemplateHTML; +} + +customElements.define('media-volume-low-icon', MediaVolumeLowIcon); diff --git a/packages/html/html-icons/src/media-volume-off-icon.ts b/packages/html/html-icons/src/media-volume-off-icon.ts new file mode 100644 index 00000000..b43f2730 --- /dev/null +++ b/packages/html/html-icons/src/media-volume-off-icon.ts @@ -0,0 +1,23 @@ +import { MediaChromeIcon } from './media-chrome-icon.js'; + +const offIcon = ``; + +export function getTemplateHTML() { + return /* html */ ` + ${MediaChromeIcon.getTemplateHTML()} + + ${offIcon} + `; +} + +export class MediaVolumeOffIcon extends MediaChromeIcon { + static getTemplateHTML = getTemplateHTML; +} + +customElements.define('media-volume-off-icon', MediaVolumeOffIcon); diff --git a/packages/html/html-media-elements/package.json b/packages/html/html-media-elements/package.json index 71f8d8e3..075c62d3 100644 --- a/packages/html/html-media-elements/package.json +++ b/packages/html/html-media-elements/package.json @@ -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" diff --git a/packages/html/html-media-elements/src/index.ts b/packages/html/html-media-elements/src/index.ts index 31604c47..dcab91ae 100644 --- a/packages/html/html-media-elements/src/index.ts +++ b/packages/html/html-media-elements/src/index.ts @@ -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 = ` - - `; - - 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'; \ No newline at end of file +export * from './media-provider'; \ No newline at end of file diff --git a/packages/html/html-media-elements/src/media-provider.ts b/packages/html/html-media-elements/src/media-provider.ts new file mode 100644 index 00000000..ca2025bb --- /dev/null +++ b/packages/html/html-media-elements/src/media-provider.ts @@ -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); \ No newline at end of file diff --git a/packages/html/html-media-store/src/index.ts b/packages/html/html-media-store/src/index.ts index 0f27d492..9a95962d 100644 --- a/packages/html/html-media-store/src/index.ts +++ b/packages/html/html-media-store/src/index.ts @@ -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 = 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): 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'; \ No newline at end of file +// Store integration utilities for HTML/Web Components \ 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 new file mode 100644 index 00000000..c77bd2f1 --- /dev/null +++ b/packages/html/html/src/components/connected-with-defaults/media-mute-button.ts @@ -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; 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 new file mode 100644 index 00000000..ceb5899a --- /dev/null +++ b/packages/html/html/src/components/connected-with-defaults/media-play-button.ts @@ -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; diff --git a/packages/html/html/src/components/connected/media-mute-button.ts b/packages/html/html/src/components/connected/media-mute-button.ts new file mode 100644 index 00000000..b3cbce5b --- /dev/null +++ b/packages/html/html/src/components/connected/media-mute-button.ts @@ -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 }); + } + } + }; +}; diff --git a/packages/html/html/src/components/connected/media-play-button.ts b/packages/html/html/src/components/connected/media-play-button.ts new file mode 100644 index 00000000..551fb79e --- /dev/null +++ b/packages/html/html/src/components/connected/media-play-button.ts @@ -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 }); + } + } + }; +}; diff --git a/packages/html/html/src/components/ui/media-chrome-button.ts b/packages/html/html/src/components/ui/media-chrome-button.ts new file mode 100644 index 00000000..50e9663f --- /dev/null +++ b/packages/html/html/src/components/ui/media-chrome-button.ts @@ -0,0 +1,47 @@ +import { namedNodeMapToObject } from '../../utils/element-utils.js'; + +export function getTemplateHTML( + this: typeof MediaChromeButton, + _attrs: Record, + _props: Record = {} +) { + return /* html */ ` + + + + `; +} + +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() {} +} diff --git a/packages/html/html/src/components/ui/media-mute-button.ts b/packages/html/html/src/components/ui/media-mute-button.ts new file mode 100644 index 00000000..4ad3a432 --- /dev/null +++ b/packages/html/html/src/components/ui/media-mute-button.ts @@ -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); + } + } +} diff --git a/packages/html/html/src/components/ui/media-play-button.ts b/packages/html/html/src/components/ui/media-play-button.ts new file mode 100644 index 00000000..8064ddbc --- /dev/null +++ b/packages/html/html/src/components/ui/media-play-button.ts @@ -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); + } +} diff --git a/packages/html/html/src/index.ts b/packages/html/html/src/index.ts index 0b337b0b..49851cd6 100644 --- a/packages/html/html/src/index.ts +++ b/packages/html/html/src/index.ts @@ -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 = ` - - `; - - const template = ` -
- ${this.mediaElement.outerHTML} - ${this.getAttribute('controls') !== null ? this.controlBar.outerHTML : ''} -
- `; - - 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 = ` - -
-
-
- - `; - - 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(); + //