mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
Flatten workspace (#120)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { namedNodeMapToObject } from '@vjs-10/utils/dom';
|
||||
|
||||
export function getTemplateHTML(
|
||||
this: typeof MediaChromeButton,
|
||||
_attrs: Record<string, string>,
|
||||
_props: Record<string, any> = {},
|
||||
): string {
|
||||
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: typeof 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);
|
||||
this.addEventListener('keydown', this);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const { type } = event;
|
||||
if (type === 'keydown') {
|
||||
this.#handleKeyDown(event as KeyboardEvent);
|
||||
}
|
||||
}
|
||||
|
||||
#handleKeyDown = (event: KeyboardEvent): void => {
|
||||
const { metaKey, altKey, key } = event;
|
||||
if (metaKey || altKey || !['Enter', ' '].includes(key)) {
|
||||
this.removeEventListener('keyup', this.#handleKeyUp);
|
||||
return;
|
||||
}
|
||||
this.addEventListener('keyup', this.#handleKeyUp, { once: true });
|
||||
};
|
||||
|
||||
#handleKeyUp = (_event: KeyboardEvent): void => {
|
||||
this.handleEvent({ type: 'click' } as Event);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { CurrentTimeDisplayState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { currentTimeDisplayStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { formatDisplayTime } from '@vjs-10/utils';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
|
||||
export class CurrentTimeDisplayBase extends HTMLElement {
|
||||
static shadowRootOptions = {
|
||||
mode: 'open' as ShadowRootMode,
|
||||
};
|
||||
|
||||
static observedAttributes: string[] = ['show-remaining'];
|
||||
|
||||
_state:
|
||||
| {
|
||||
currentTime: number | undefined;
|
||||
duration: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (!this.shadowRoot) {
|
||||
this.attachShadow((this.constructor as typeof CurrentTimeDisplayBase).shadowRootOptions);
|
||||
}
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this._state?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._state?.duration ?? 0;
|
||||
}
|
||||
|
||||
get showRemaining(): boolean {
|
||||
return this.hasAttribute('show-remaining');
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
||||
if (name === 'show-remaining' && this._state) {
|
||||
// Re-render with current state when show-remaining attribute changes
|
||||
this._update({}, this._state);
|
||||
}
|
||||
}
|
||||
|
||||
_update(_props: any, state: any): void {
|
||||
this._state = state;
|
||||
|
||||
/** @TODO Should this live here or elsewhere? (CJP) */
|
||||
const timeLabel
|
||||
= this.showRemaining && state.duration != null && state.currentTime != null
|
||||
? formatDisplayTime(-(state.duration - state.currentTime))
|
||||
: formatDisplayTime(state.currentTime);
|
||||
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.textContent = timeLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useCurrentTimeDisplayState: StateHook<{
|
||||
currentTime: number | undefined;
|
||||
duration: number | undefined;
|
||||
}> = {
|
||||
keys: [...currentTimeDisplayStateDefinition.keys],
|
||||
transform: (rawState, _mediaStore) => ({
|
||||
...currentTimeDisplayStateDefinition.stateTransform(rawState),
|
||||
// Current time display is read-only, so no request methods needed
|
||||
}),
|
||||
};
|
||||
|
||||
export const getCurrentTimeDisplayProps: PropsHook<{
|
||||
currentTime: number | undefined;
|
||||
duration: number | undefined;
|
||||
}> = (_state, _element) => {
|
||||
const baseProps: Record<string, any> = {};
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const CurrentTimeDisplay: ConnectedComponentConstructor<CurrentTimeDisplayState> = toConnectedHTMLComponent(
|
||||
CurrentTimeDisplayBase,
|
||||
useCurrentTimeDisplayState,
|
||||
getCurrentTimeDisplayProps,
|
||||
'CurrentTimeDisplay',
|
||||
);
|
||||
|
||||
// Register the custom element
|
||||
if (!globalThis.customElements.get('media-current-time-display')) {
|
||||
globalThis.customElements.define('media-current-time-display', CurrentTimeDisplay);
|
||||
}
|
||||
|
||||
export default CurrentTimeDisplay;
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DurationDisplayState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { durationDisplayStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { formatDisplayTime } from '@vjs-10/utils';
|
||||
import { namedNodeMapToObject } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
|
||||
export function getTemplateHTML(
|
||||
this: typeof DurationDisplayBase,
|
||||
_attrs: Record<string, string>,
|
||||
_props: Record<string, any> = {},
|
||||
) {
|
||||
return /* html */ `
|
||||
<span></span>
|
||||
`;
|
||||
}
|
||||
|
||||
export class DurationDisplayBase extends HTMLElement {
|
||||
static shadowRootOptions = {
|
||||
mode: 'open' as ShadowRootMode,
|
||||
};
|
||||
|
||||
static getTemplateHTML: typeof getTemplateHTML = getTemplateHTML;
|
||||
|
||||
_state:
|
||||
| {
|
||||
duration: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (!this.shadowRoot) {
|
||||
this.attachShadow((this.constructor as typeof DurationDisplayBase).shadowRootOptions);
|
||||
|
||||
const attrs = namedNodeMapToObject(this.attributes);
|
||||
const html = (this.constructor as typeof DurationDisplayBase).getTemplateHTML(attrs);
|
||||
const shadowRoot = this.shadowRoot as unknown as ShadowRoot;
|
||||
shadowRoot.setHTMLUnsafe ? shadowRoot.setHTMLUnsafe(html) : (shadowRoot.innerHTML = html);
|
||||
}
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._state?.duration ?? 0;
|
||||
}
|
||||
|
||||
_update(_props: any, state: any): void {
|
||||
this._state = state;
|
||||
|
||||
// Update the span content with formatted duration
|
||||
const spanElement = this.shadowRoot?.querySelector('span') as HTMLElement;
|
||||
if (spanElement) {
|
||||
spanElement.textContent = formatDisplayTime(state.duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useDurationDisplayState: StateHook<{
|
||||
duration: number | undefined;
|
||||
}> = {
|
||||
keys: [...durationDisplayStateDefinition.keys],
|
||||
transform: (rawState, _mediaStore) => ({
|
||||
...durationDisplayStateDefinition.stateTransform(rawState),
|
||||
// Duration display is read-only, so no request methods needed
|
||||
}),
|
||||
};
|
||||
|
||||
export const getDurationDisplayProps: PropsHook<{
|
||||
duration: number | undefined;
|
||||
}> = (_state, _element) => {
|
||||
const baseProps: Record<string, any> = {};
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const DurationDisplay: ConnectedComponentConstructor<DurationDisplayState> = toConnectedHTMLComponent(
|
||||
DurationDisplayBase,
|
||||
useDurationDisplayState,
|
||||
getDurationDisplayProps,
|
||||
'DurationDisplay',
|
||||
);
|
||||
|
||||
// Register the custom element
|
||||
if (!globalThis.customElements.get('media-duration-display')) {
|
||||
globalThis.customElements.define('media-duration-display', DurationDisplay);
|
||||
}
|
||||
|
||||
export default DurationDisplay;
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FullscreenButtonState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { fullscreenButtonStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { setAttributes } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
import { MediaChromeButton } from './media-chrome-button';
|
||||
|
||||
export class FullscreenButtonBase extends MediaChromeButton {
|
||||
_state:
|
||||
| {
|
||||
fullscreen: boolean;
|
||||
requestEnterFullscreen: () => void;
|
||||
requestExitFullscreen: () => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { type } = event;
|
||||
const state = this._state;
|
||||
if (state && type === 'click') {
|
||||
if (state.fullscreen) {
|
||||
state.requestExitFullscreen();
|
||||
} else {
|
||||
state.requestEnterFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get fullscreen(): boolean {
|
||||
return this._state?.fullscreen ?? false;
|
||||
}
|
||||
|
||||
_update(props: any, state: any, _mediaStore?: any): void {
|
||||
this._state = state;
|
||||
/** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */
|
||||
setAttributes(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FullscreenButton state hook - equivalent to React's useFullscreenButtonState
|
||||
* Handles media store state subscription and transformation
|
||||
*/
|
||||
export const getFullscreenButtonState: StateHook<{ fullscreen: boolean }> = {
|
||||
keys: fullscreenButtonStateDefinition.keys,
|
||||
transform: (rawState, mediaStore) => ({
|
||||
...fullscreenButtonStateDefinition.stateTransform(rawState),
|
||||
...fullscreenButtonStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
}),
|
||||
};
|
||||
|
||||
export const getFullscreenButtonProps: PropsHook<{ fullscreen: boolean }> = (state, _element) => {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** data attributes/props */
|
||||
'data-fullscreen': state.fullscreen,
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
'aria-label': state.fullscreen ? 'exit fullscreen' : 'enter fullscreen',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
|
||||
/** @TODO Figure out how we want to handle attr overrides (e.g. aria-label) (CJP) */
|
||||
/** external props spread last to allow for overriding */
|
||||
// ...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const FullscreenButton: ConnectedComponentConstructor<FullscreenButtonState> = toConnectedHTMLComponent(
|
||||
FullscreenButtonBase,
|
||||
getFullscreenButtonState,
|
||||
getFullscreenButtonProps,
|
||||
'FullscreenButton',
|
||||
);
|
||||
|
||||
// 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-fullscreen-button')) {
|
||||
globalThis.customElements.define('media-fullscreen-button', FullscreenButton);
|
||||
}
|
||||
|
||||
export default FullscreenButton;
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { MuteButtonState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { muteButtonStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { setAttributes } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
import { MediaChromeButton } from './media-chrome-button';
|
||||
|
||||
export class MuteButtonBase extends MediaChromeButton {
|
||||
_state:
|
||||
| {
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestMute: () => void;
|
||||
requestUnmute: () => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { type } = event;
|
||||
const state = this._state;
|
||||
|
||||
if (state) {
|
||||
if (type === 'click') {
|
||||
if (state.volumeLevel === 'off') {
|
||||
state.requestUnmute();
|
||||
} else {
|
||||
state.requestMute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this._state?.muted ?? false;
|
||||
}
|
||||
|
||||
get volumeLevel(): string {
|
||||
return this._state?.volumeLevel ?? 'high';
|
||||
}
|
||||
|
||||
_update(props: any, state: any): void {
|
||||
this._state = state;
|
||||
/** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */
|
||||
setAttributes(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
export const getMuteButtonState: StateHook<{
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
}> = {
|
||||
keys: muteButtonStateDefinition.keys,
|
||||
transform: (rawState, mediaStore) => ({
|
||||
...muteButtonStateDefinition.stateTransform(rawState),
|
||||
...muteButtonStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
}),
|
||||
};
|
||||
|
||||
export const getMuteButtonProps: PropsHook<{
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
}> = (state, _element) => {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** data attributes/props */
|
||||
'data-muted': state.muted,
|
||||
'data-volume-level': state.volumeLevel,
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
'aria-label': state.muted ? 'unmute' : 'mute',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.muted ? 'Unmute' : 'Mute',
|
||||
/** @TODO Figure out how we want to handle attr overrides (e.g. aria-label) (CJP) */
|
||||
/** external props spread last to allow for overriding */
|
||||
// ...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const MuteButton: ConnectedComponentConstructor<MuteButtonState> = toConnectedHTMLComponent(
|
||||
MuteButtonBase,
|
||||
getMuteButtonState,
|
||||
getMuteButtonProps,
|
||||
'MuteButton',
|
||||
);
|
||||
|
||||
// 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', MuteButton);
|
||||
}
|
||||
|
||||
export default MuteButton;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PlayButtonState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { playButtonStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { setAttributes } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
import { MediaChromeButton } from './media-chrome-button';
|
||||
|
||||
export class PlayButtonBase extends MediaChromeButton {
|
||||
_state: { paused: boolean; requestPlay: () => void; requestPause: () => void } | undefined;
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { type } = event;
|
||||
const state = this._state;
|
||||
if (state && type === 'click') {
|
||||
if (state.paused) {
|
||||
state.requestPlay();
|
||||
} else {
|
||||
state.requestPause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get paused(): boolean {
|
||||
return this._state?.paused ?? true;
|
||||
}
|
||||
|
||||
_update(props: any, state: any, _mediaStore?: any): void {
|
||||
this._state = state;
|
||||
/** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */
|
||||
setAttributes(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
export const getPlayButtonState: StateHook<{ paused: boolean }> = {
|
||||
keys: playButtonStateDefinition.keys,
|
||||
transform: (rawState, mediaStore) => ({
|
||||
...playButtonStateDefinition.stateTransform(rawState),
|
||||
...playButtonStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
}),
|
||||
};
|
||||
|
||||
export const getPlayButtonProps: PropsHook<{ paused: boolean }> = (state, _element) => {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** data attributes/props */
|
||||
'data-paused': state.paused,
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
'aria-label': state.paused ? 'play' : 'pause',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.paused ? 'Play' : 'Pause',
|
||||
/** @TODO Figure out how we want to handle attr overrides (e.g. aria-label) (CJP) */
|
||||
/** external props spread last to allow for overriding */
|
||||
// ...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const PlayButton: ConnectedComponentConstructor<PlayButtonState> = toConnectedHTMLComponent(
|
||||
PlayButtonBase,
|
||||
getPlayButtonState,
|
||||
getPlayButtonProps,
|
||||
'PlayButton',
|
||||
);
|
||||
|
||||
// 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', PlayButton);
|
||||
}
|
||||
|
||||
export default PlayButton;
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { Placement } from '@floating-ui/dom';
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||
import { uniqueId } from '@vjs-10/utils';
|
||||
|
||||
import { getDocument, getNextTabbable, getPreviousTabbable, isOutsideEvent } from '@vjs-10/utils/dom';
|
||||
|
||||
export class MediaPopoverRoot extends HTMLElement {
|
||||
#open = false;
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#cleanup: (() => void) | null = null;
|
||||
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
|
||||
#abortController: AbortController | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#updateVisibility();
|
||||
|
||||
this.#abortController ??= new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
|
||||
this.addEventListener('mouseenter', this, { signal });
|
||||
this.addEventListener('mouseleave', this, { signal });
|
||||
this.addEventListener('focusin', this, { signal });
|
||||
this.addEventListener('focusout', this, { signal });
|
||||
|
||||
getDocument(this).documentElement.addEventListener('mouseleave', this, { signal });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#cleanup?.();
|
||||
|
||||
this.#transitionStatus = 'unmounted';
|
||||
this.#updateVisibility();
|
||||
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
switch (event.type) {
|
||||
case 'mouseenter':
|
||||
this.#handleMouseEnter();
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this.#handleMouseLeave(event as MouseEvent);
|
||||
break;
|
||||
case 'focusin':
|
||||
this.#handleFocusIn(event as FocusEvent);
|
||||
break;
|
||||
case 'focusout':
|
||||
this.#handleFocusOut(event as FocusEvent);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return ['open-on-hover', 'delay', 'close-delay'];
|
||||
}
|
||||
|
||||
get openOnHover(): boolean {
|
||||
return this.hasAttribute('open-on-hover');
|
||||
}
|
||||
|
||||
get delay(): number {
|
||||
return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
|
||||
}
|
||||
|
||||
get closeDelay(): number {
|
||||
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
|
||||
}
|
||||
|
||||
get #triggerElement(): MediaPopoverTrigger | null {
|
||||
return this.querySelector('media-popover-trigger') as MediaPopoverTrigger | null;
|
||||
}
|
||||
|
||||
get #portalElement(): MediaPopoverPortal | null {
|
||||
return this.querySelector('media-popover-portal') as MediaPopoverPortal | null;
|
||||
}
|
||||
|
||||
get #positionerElement(): MediaPopoverPositioner | null {
|
||||
return this.#portalElement?.querySelector('media-popover-positioner') as MediaPopoverPositioner | null;
|
||||
}
|
||||
|
||||
get #popupElement(): MediaPopoverPopup | null {
|
||||
return this.#portalElement?.querySelector('media-popover-popup') as MediaPopoverPopup | null;
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
if (this.#open === open) return;
|
||||
|
||||
this.#open = open;
|
||||
|
||||
if (open) {
|
||||
this.#setupFloating();
|
||||
this.#portalElement?.renderGuards();
|
||||
} else {
|
||||
this.#portalElement?.removeGuards();
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
this.#transitionStatus = 'initial';
|
||||
requestAnimationFrame(() => {
|
||||
this.#transitionStatus = 'open';
|
||||
this.#updateVisibility();
|
||||
});
|
||||
} else {
|
||||
this.#transitionStatus = 'close';
|
||||
}
|
||||
|
||||
this.#updateVisibility();
|
||||
}
|
||||
|
||||
#updateVisibility(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
if (this.#popupElement) {
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
this.#popupElement.setAttribute('data-side', placement);
|
||||
|
||||
this.#popupElement.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
|
||||
this.#popupElement.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
|
||||
this.#popupElement.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
this.#popupElement.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
|
||||
this.#abortController ??= new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
this.#popupElement.addEventListener('mouseleave', this, { signal });
|
||||
}
|
||||
|
||||
const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
triggerElement.setAttribute('aria-expanded', this.#open.toString());
|
||||
triggerElement.toggleAttribute('data-popup-open', this.#open);
|
||||
|
||||
if (this.#popupElement?.id) {
|
||||
triggerElement.setAttribute('aria-controls', this.#popupElement?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#setupFloating(): void {
|
||||
if (!this.#triggerElement || !this.#popupElement) return;
|
||||
|
||||
const trigger = this.#triggerElement.firstElementChild as HTMLElement;
|
||||
const popup = this.#popupElement;
|
||||
|
||||
if (!trigger || !popup) return;
|
||||
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
const sideOffset = this.#positionerElement?.sideOffset;
|
||||
|
||||
const updatePosition = () => {
|
||||
computePosition(trigger, popup, {
|
||||
placement,
|
||||
middleware: [offset(sideOffset), flip(), shift()],
|
||||
}).then(({ x, y }: { x: number; y: number }) => {
|
||||
Object.assign(popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
this.#cleanup = autoUpdate(trigger, popup, updatePosition);
|
||||
}
|
||||
|
||||
#clearHoverTimeout(): void {
|
||||
if (this.#hoverTimeout) {
|
||||
clearTimeout(this.#hoverTimeout);
|
||||
this.#hoverTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
#handleMouseEnter(): void {
|
||||
if (!this.openOnHover) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.setOpen(true);
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
#handleMouseLeave(event: MouseEvent): void {
|
||||
if (!this.openOnHover) return;
|
||||
|
||||
if (event.relatedTarget && this.#popupElement?.contains(event.relatedTarget as Node)) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.setOpen(false);
|
||||
}, this.closeDelay);
|
||||
}
|
||||
|
||||
#handleFocusIn(_event: FocusEvent): void {
|
||||
this.setOpen(true);
|
||||
}
|
||||
|
||||
#handleFocusOut(event: FocusEvent): void {
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && relatedTarget.hasAttribute('data-focus-guard')) return;
|
||||
|
||||
this.setOpen(false);
|
||||
};
|
||||
}
|
||||
|
||||
export class MediaPopoverTrigger extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const triggerElement = this.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
triggerElement.setAttribute('aria-haspopup', 'true');
|
||||
triggerElement.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes') {
|
||||
const rootElement = this.closest('media-popover-root') as MediaPopoverRoot;
|
||||
let popupElement = rootElement.querySelector('media-popover-popup') as MediaPopoverPopup;
|
||||
|
||||
if (!popupElement) {
|
||||
const portalElement = rootElement.querySelector('media-popover-portal') as MediaPopoverPortal;
|
||||
if (!portalElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupElement = portalElement.querySelector('media-popover-popup') as MediaPopoverPopup;
|
||||
if (!popupElement) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const attributeName = mutation.attributeName;
|
||||
if (!attributeName || !attributeName.startsWith('data-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValue = triggerElement.getAttribute(attributeName);
|
||||
if (attributeValue !== null) {
|
||||
popupElement.setAttribute(attributeName, attributeValue);
|
||||
} else {
|
||||
popupElement.removeAttribute(attributeName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(triggerElement, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaPopoverPortal extends HTMLElement {
|
||||
#portal: HTMLElement | null = null;
|
||||
#childrenArray: Element[] = [];
|
||||
#guards: HTMLElement[] = [];
|
||||
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
this.#setupPortal();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanupPortal();
|
||||
}
|
||||
|
||||
querySelector(selector: string): HTMLElement | null {
|
||||
return this.#portal!.querySelector(selector);
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): NodeListOf<Element> {
|
||||
return this.#portal!.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
this.dispatchEvent(new Event(event.type, { bubbles: true }));
|
||||
}
|
||||
|
||||
#setupPortal(): void {
|
||||
const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
|
||||
if (!portalId) return;
|
||||
|
||||
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
|
||||
// NOTE: Hacky solution in part to ensure styling propogates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
|
||||
const portalContainer
|
||||
= ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
|
||||
?? (this.getRootNode() as ShadowRoot | Document)
|
||||
.querySelector('media-container')
|
||||
?.shadowRoot
|
||||
?.getElementById(portalId))
|
||||
? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
|
||||
: undefined;
|
||||
if (!portalContainer) return;
|
||||
|
||||
this.#portal = document.createElement('div');
|
||||
this.#portal.slot = 'portal';
|
||||
this.#portal.id = uniqueId();
|
||||
|
||||
this.#childrenArray = Array.from(this.children);
|
||||
this.#portal.append(...this.#childrenArray);
|
||||
portalContainer.append(this.#portal);
|
||||
}
|
||||
|
||||
#cleanupPortal(): void {
|
||||
if (!this.#portal) return;
|
||||
|
||||
this.removeGuards();
|
||||
|
||||
this.append(...this.#childrenArray);
|
||||
this.#portal.remove();
|
||||
this.#portal = null;
|
||||
this.#childrenArray = [];
|
||||
}
|
||||
|
||||
renderGuards(): void {
|
||||
if (!this.#portal) return;
|
||||
|
||||
if (this.#guards.length === 0) {
|
||||
const beforeInsideGuard = createFocusGuard('inside');
|
||||
const afterInsideGuard = createFocusGuard('inside');
|
||||
const beforeOutsideGuard = createFocusGuard('outside');
|
||||
const afterOutsideGuard = createFocusGuard('outside');
|
||||
|
||||
beforeOutsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
beforeInsideGuard.focus();
|
||||
} else {
|
||||
getPreviousTabbable(this)?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
afterOutsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
afterInsideGuard.focus();
|
||||
} else {
|
||||
getNextTabbable(this)?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
beforeInsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
getNextTabbable(this.#portal)?.focus();
|
||||
} else {
|
||||
beforeOutsideGuard.focus();
|
||||
}
|
||||
});
|
||||
|
||||
afterInsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
getPreviousTabbable(this.#portal)?.focus();
|
||||
} else {
|
||||
afterOutsideGuard.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Add guards to portal element (outside guards)
|
||||
this.prepend(beforeOutsideGuard);
|
||||
this.append(afterOutsideGuard);
|
||||
|
||||
// Add guards to portal container (inside guards)
|
||||
this.#portal.prepend(beforeInsideGuard);
|
||||
this.#portal.append(afterInsideGuard);
|
||||
|
||||
this.#guards = [beforeOutsideGuard, afterOutsideGuard, beforeInsideGuard, afterInsideGuard];
|
||||
}
|
||||
}
|
||||
|
||||
removeGuards(): void {
|
||||
this.#guards.forEach(guard => guard.remove());
|
||||
this.#guards = [];
|
||||
}
|
||||
}
|
||||
|
||||
function createFocusGuard(dataType: 'inside' | 'outside'): HTMLElement {
|
||||
const focusGuard = document.createElement('span');
|
||||
focusGuard.setAttribute('data-type', dataType);
|
||||
focusGuard.setAttribute('tabindex', '0');
|
||||
focusGuard.toggleAttribute('data-focus-guard', true);
|
||||
return focusGuard;
|
||||
}
|
||||
|
||||
export class MediaPopoverPositioner extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const popup = this.firstElementChild as HTMLElement;
|
||||
if (popup) {
|
||||
Object.assign(popup.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get side(): Placement {
|
||||
return this.getAttribute('side') as Placement;
|
||||
}
|
||||
|
||||
get sideOffset(): number {
|
||||
return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10);
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaPopoverPopup extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.setAttribute('role', 'dialog');
|
||||
this.setAttribute('aria-modal', 'false');
|
||||
this.id = uniqueId();
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-root')) {
|
||||
globalThis.customElements.define('media-popover-root', MediaPopoverRoot);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-trigger')) {
|
||||
globalThis.customElements.define('media-popover-trigger', MediaPopoverTrigger);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-portal')) {
|
||||
globalThis.customElements.define('media-popover-portal', MediaPopoverPortal);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-positioner')) {
|
||||
globalThis.customElements.define('media-popover-positioner', MediaPopoverPositioner);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-popup')) {
|
||||
globalThis.customElements.define('media-popover-popup', MediaPopoverPopup);
|
||||
}
|
||||
|
||||
export const Popover: {
|
||||
Root: typeof MediaPopoverRoot;
|
||||
Trigger: typeof MediaPopoverTrigger;
|
||||
Portal: typeof MediaPopoverPortal;
|
||||
Positioner: typeof MediaPopoverPositioner;
|
||||
Popup: typeof MediaPopoverPopup;
|
||||
} = {
|
||||
Root: MediaPopoverRoot,
|
||||
Trigger: MediaPopoverTrigger,
|
||||
Portal: MediaPopoverPortal,
|
||||
Positioner: MediaPopoverPositioner,
|
||||
Popup: MediaPopoverPopup,
|
||||
};
|
||||
|
||||
export default Popover;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PreviewTimeDisplayState } from '@vjs-10/core/store';
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { previewTimeDisplayStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { formatDisplayTime } from '@vjs-10/utils';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
|
||||
export class PreviewTimeDisplayBase extends HTMLElement {
|
||||
static shadowRootOptions = {
|
||||
mode: 'open' as ShadowRootMode,
|
||||
};
|
||||
|
||||
static observedAttributes: string[] = ['show-remaining'];
|
||||
|
||||
_state:
|
||||
| {
|
||||
previewTime: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (!this.shadowRoot) {
|
||||
this.attachShadow((this.constructor as typeof PreviewTimeDisplayBase).shadowRootOptions);
|
||||
}
|
||||
}
|
||||
|
||||
get previewTime(): number {
|
||||
return this._state?.previewTime ?? 0;
|
||||
}
|
||||
|
||||
get showRemaining(): boolean {
|
||||
return this.hasAttribute('show-remaining');
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
||||
if (name === 'show-remaining' && this._state) {
|
||||
// Re-render with current state when show-remaining attribute changes
|
||||
this._update({}, this._state);
|
||||
}
|
||||
}
|
||||
|
||||
_update(_props: any, state: any): void {
|
||||
this._state = state;
|
||||
|
||||
/** @TODO Should this live here or elsewhere? (CJP) */
|
||||
const timeLabel = formatDisplayTime(state.previewTime);
|
||||
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.textContent = timeLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const usePreviewTimeDisplayState: StateHook<{
|
||||
previewTime: number | undefined;
|
||||
}> = {
|
||||
keys: [...previewTimeDisplayStateDefinition.keys],
|
||||
transform: (rawState, _mediaStore) => ({
|
||||
...previewTimeDisplayStateDefinition.stateTransform(rawState),
|
||||
// Preview time display is read-only, so no request methods needed
|
||||
}),
|
||||
};
|
||||
|
||||
export const getPreviewTimeDisplayProps: PropsHook<{
|
||||
previewTime: number | undefined;
|
||||
}> = (_state, _element) => {
|
||||
const baseProps: Record<string, any> = {};
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export const PreviewTimeDisplay: ConnectedComponentConstructor<PreviewTimeDisplayState> = toConnectedHTMLComponent(
|
||||
PreviewTimeDisplayBase,
|
||||
usePreviewTimeDisplayState,
|
||||
getPreviewTimeDisplayProps,
|
||||
'PreviewTimeDisplay',
|
||||
);
|
||||
|
||||
// Register the custom element
|
||||
if (!globalThis.customElements.get('preview-time-display')) {
|
||||
globalThis.customElements.define('preview-time-display', PreviewTimeDisplay);
|
||||
}
|
||||
|
||||
export default PreviewTimeDisplay;
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { TimeSlider as CoreTimeSlider } from '@vjs-10/core';
|
||||
import { timeSliderStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { setAttributes } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
|
||||
interface TimeSliderRootState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSeek: (time: number) => void;
|
||||
core: CoreTimeSlider | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TimeSlider Root props hook - equivalent to React's useTimeSliderRootProps
|
||||
* Handles element attributes and properties based on state
|
||||
*/
|
||||
export const getTimeSliderRootProps: PropsHook<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSeek: (time: number) => void;
|
||||
core: CoreTimeSlider | null;
|
||||
}> = (state, element) => {
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const currentTimeText = formatTime(state.currentTime);
|
||||
const durationText = formatTime(state.duration);
|
||||
|
||||
const baseProps: Record<string, any> = {
|
||||
role: 'slider',
|
||||
tabindex: element.getAttribute('tabindex') ?? '0',
|
||||
'data-current-time': state.currentTime.toString(),
|
||||
'data-duration': state.duration.toString(),
|
||||
'data-orientation': (element as any).orientation || 'horizontal',
|
||||
'aria-label': 'Seek',
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': Math.round(state.duration).toString(),
|
||||
'aria-valuenow': Math.round(state.currentTime).toString(),
|
||||
'aria-valuetext': `${currentTimeText} of ${durationText}`,
|
||||
'aria-orientation': (element as any).orientation || 'horizontal',
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
export class TimeSliderRootBase extends HTMLElement {
|
||||
static readonly observedAttributes: readonly string[] = ['orientation'];
|
||||
|
||||
_state: TimeSliderRootState | undefined;
|
||||
_core: CoreTimeSlider | null = null;
|
||||
|
||||
get currentTime(): number {
|
||||
return this._state?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._state?.duration ?? 0;
|
||||
}
|
||||
|
||||
get orientation(): 'horizontal' | 'vertical' {
|
||||
return (this.getAttribute('orientation') as 'horizontal' | 'vertical') || 'horizontal';
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
||||
if (name === 'orientation' && this._state) {
|
||||
this._render(getTimeSliderRootProps(this._state, this), this._state);
|
||||
}
|
||||
}
|
||||
|
||||
_update(_props: any, state: any): void {
|
||||
this._state = state;
|
||||
|
||||
if (state && !this._core) {
|
||||
this._core = new CoreTimeSlider();
|
||||
this._core.subscribe(() => this._render(getTimeSliderRootProps(state, this), state));
|
||||
this._core.attach(this);
|
||||
state.core = this._core;
|
||||
}
|
||||
|
||||
this._core?.setState(state);
|
||||
}
|
||||
|
||||
_render(props: any, state: any): void {
|
||||
const coreState = state?.core?.getState();
|
||||
if (!coreState) return;
|
||||
|
||||
this.style.setProperty('--slider-fill', `${Math.round(coreState._fillWidth)}%`);
|
||||
this.style.setProperty('--slider-pointer', `${Math.round(coreState._pointerWidth)}%`);
|
||||
|
||||
setAttributes(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeSliderTrackBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
// Set this element as the track element in the core TimeSlider
|
||||
const rootElement = this.closest('media-time-slider-root') as any;
|
||||
if (rootElement?._state?.core) {
|
||||
rootElement._state.core.setState({ _trackElement: this });
|
||||
}
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.width = '100%';
|
||||
this.style.removeProperty('height');
|
||||
} else {
|
||||
this.style.height = '100%';
|
||||
this.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeSliderProgressBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.style.position = 'absolute';
|
||||
this.style.width = 'var(--slider-fill, 0%)';
|
||||
this.style.height = '100%';
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.width = 'var(--slider-fill, 0%)';
|
||||
this.style.height = '100%';
|
||||
this.style.top = '0';
|
||||
this.style.removeProperty('bottom');
|
||||
} else {
|
||||
this.style.height = 'var(--slider-fill, 0%)';
|
||||
this.style.width = '100%';
|
||||
this.style.bottom = '0';
|
||||
this.style.removeProperty('top');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeSliderPointerBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.style.position = 'absolute';
|
||||
this.style.width = 'var(--slider-pointer, 0%)';
|
||||
this.style.height = '100%';
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.width = 'var(--slider-pointer, 0%)';
|
||||
this.style.height = '100%';
|
||||
this.style.top = '0';
|
||||
this.style.removeProperty('bottom');
|
||||
} else {
|
||||
this.style.height = 'var(--slider-pointer, 0%)';
|
||||
this.style.width = '100%';
|
||||
this.style.bottom = '0';
|
||||
this.style.removeProperty('top');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeSliderThumbBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.style.position = 'absolute';
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
// Set appropriate positioning based on orientation
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.left = 'var(--slider-fill, 0%)';
|
||||
this.style.top = '50%';
|
||||
this.style.transform = 'translate(-50%, -50%)';
|
||||
} else {
|
||||
this.style.bottom = 'var(--slider-fill, 0%)';
|
||||
this.style.left = '50%';
|
||||
this.style.transform = 'translate(-50%, 50%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useTimeSliderRootState: StateHook<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSeek: (time: number) => void;
|
||||
core: CoreTimeSlider | null;
|
||||
}> = {
|
||||
keys: timeSliderStateDefinition.keys,
|
||||
transform: (rawState, mediaStore) => ({
|
||||
...timeSliderStateDefinition.stateTransform(rawState),
|
||||
...timeSliderStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
core: null,
|
||||
}),
|
||||
};
|
||||
|
||||
export const getTimeSliderTrackProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-time-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
export const getTimeSliderProgressProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-time-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
export const getTimeSliderPointerProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-time-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
export const getTimeSliderThumbProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-time-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
export const TimeSliderRoot: ConnectedComponentConstructor<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSeek: (time: number) => void;
|
||||
core: CoreTimeSlider | null;
|
||||
}> = toConnectedHTMLComponent(TimeSliderRootBase, useTimeSliderRootState, getTimeSliderRootProps, 'TimeSliderRoot');
|
||||
|
||||
export const TimeSliderTrack: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
TimeSliderTrackBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getTimeSliderTrackProps,
|
||||
'TimeSliderTrack',
|
||||
);
|
||||
|
||||
export const TimeSliderProgress: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
TimeSliderProgressBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getTimeSliderProgressProps,
|
||||
'TimeSliderProgress',
|
||||
);
|
||||
|
||||
export const TimeSliderPointer: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
TimeSliderPointerBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getTimeSliderPointerProps,
|
||||
'TimeSliderPointer',
|
||||
);
|
||||
|
||||
export const TimeSliderThumb: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
TimeSliderThumbBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getTimeSliderThumbProps,
|
||||
'TimeSliderThumb',
|
||||
);
|
||||
|
||||
export const TimeSlider = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: TimeSliderRoot,
|
||||
Track: TimeSliderTrack,
|
||||
Progress: TimeSliderProgress,
|
||||
Pointer: TimeSliderPointer,
|
||||
Thumb: TimeSliderThumb,
|
||||
},
|
||||
) as {
|
||||
Root: typeof TimeSliderRoot;
|
||||
Track: typeof TimeSliderTrack;
|
||||
Progress: typeof TimeSliderProgress;
|
||||
Pointer: typeof TimeSliderPointer;
|
||||
Thumb: typeof TimeSliderThumb;
|
||||
};
|
||||
|
||||
if (!globalThis.customElements.get('media-time-slider-root')) {
|
||||
globalThis.customElements.define('media-time-slider-root', TimeSliderRoot);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-time-slider-track')) {
|
||||
globalThis.customElements.define('media-time-slider-track', TimeSliderTrack);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-time-slider-progress')) {
|
||||
globalThis.customElements.define('media-time-slider-progress', TimeSliderProgress);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-time-slider-pointer')) {
|
||||
globalThis.customElements.define('media-time-slider-pointer', TimeSliderPointer);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-time-slider-thumb')) {
|
||||
globalThis.customElements.define('media-time-slider-thumb', TimeSliderThumb);
|
||||
}
|
||||
|
||||
export default TimeSlider;
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { Placement } from '@floating-ui/dom';
|
||||
import type { MediaContainer } from '@/media/media-container';
|
||||
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||
import { uniqueId } from '@vjs-10/utils';
|
||||
|
||||
export class MediaTooltipRoot extends HTMLElement {
|
||||
#open = false;
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#cleanup: (() => void) | null = null;
|
||||
#arrowElement: HTMLElement | null = null;
|
||||
#mousePosition = { x: 0, y: 0 };
|
||||
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('mouseenter', this);
|
||||
this.addEventListener('mouseleave', this);
|
||||
this.addEventListener('mousemove', this);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
if (event.type === 'mouseenter') {
|
||||
this.#handleMouseEnter();
|
||||
} else if (event.type === 'mouseleave') {
|
||||
this.#handleMouseLeave();
|
||||
} else if (event.type === 'mousemove') {
|
||||
this.#handleMouseMove(event as MouseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#updateVisibility();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#cleanup?.();
|
||||
|
||||
this.#transitionStatus = 'unmounted';
|
||||
this.#updateVisibility();
|
||||
}
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return ['delay', 'close-delay', 'track-cursor-axis'];
|
||||
}
|
||||
|
||||
get delay(): number {
|
||||
return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
|
||||
}
|
||||
|
||||
get closeDelay(): number {
|
||||
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
|
||||
}
|
||||
|
||||
get trackCursorAxis(): 'x' | 'y' | 'both' | undefined {
|
||||
const value = this.getAttribute('track-cursor-axis');
|
||||
return value === 'x' || value === 'y' || value === 'both' ? value : undefined;
|
||||
}
|
||||
|
||||
get #triggerElement(): MediaTooltipTrigger | null {
|
||||
return this.querySelector('media-tooltip-trigger') as MediaTooltipTrigger | null;
|
||||
}
|
||||
|
||||
get #portalElement(): MediaTooltipPortal | null {
|
||||
return this.querySelector('media-tooltip-portal') as MediaTooltipPortal | null;
|
||||
}
|
||||
|
||||
get #positionerElement(): MediaTooltipPositioner | null {
|
||||
return this.#portalElement?.querySelector('media-tooltip-positioner') as MediaTooltipPositioner | null;
|
||||
}
|
||||
|
||||
get #popupElement(): MediaTooltipPopup | null {
|
||||
return this.#portalElement?.querySelector('media-tooltip-popup') as MediaTooltipPopup | null;
|
||||
}
|
||||
|
||||
#setOpen(open: boolean): void {
|
||||
if (this.#open === open) return;
|
||||
|
||||
this.#open = open;
|
||||
|
||||
if (open) {
|
||||
this.#transitionStatus = 'initial';
|
||||
requestAnimationFrame(() => {
|
||||
this.#transitionStatus = 'open';
|
||||
this.#updateVisibility();
|
||||
});
|
||||
} else {
|
||||
this.#transitionStatus = 'close';
|
||||
}
|
||||
|
||||
this.#updateVisibility();
|
||||
|
||||
if (open) {
|
||||
this.#setupFloating();
|
||||
} else {
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
#updateVisibility(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
if (this.#popupElement) {
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
this.#popupElement.setAttribute('data-side', placement);
|
||||
|
||||
this.#popupElement.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
|
||||
this.#popupElement.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
|
||||
this.#popupElement.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
this.#popupElement.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
}
|
||||
|
||||
const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
triggerElement.toggleAttribute('data-popup-open', this.#open);
|
||||
}
|
||||
}
|
||||
|
||||
#setupFloating(): void {
|
||||
if (!this.#triggerElement || !this.#popupElement) return;
|
||||
|
||||
const trigger = this.#triggerElement.firstElementChild as HTMLElement;
|
||||
const popup = this.#popupElement;
|
||||
|
||||
if (!trigger || !popup) return;
|
||||
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
const sideOffset = this.#positionerElement?.sideOffset ?? 0;
|
||||
const collisionPadding = this.#positionerElement?.collisionPadding ?? 0;
|
||||
const mediaContainer = this.closest('media-container') as MediaContainer;
|
||||
|
||||
this.#arrowElement = popup.querySelector('media-tooltip-arrow') as HTMLElement;
|
||||
|
||||
const updatePosition = () => {
|
||||
const middleware = [
|
||||
offset(sideOffset),
|
||||
flip(),
|
||||
shift({
|
||||
boundary: mediaContainer,
|
||||
padding: collisionPadding,
|
||||
}),
|
||||
];
|
||||
|
||||
if (this.#arrowElement) {
|
||||
middleware.push(arrow({ element: this.#arrowElement }));
|
||||
}
|
||||
|
||||
const referenceElement = this.trackCursorAxis
|
||||
? {
|
||||
getBoundingClientRect: () => {
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
|
||||
if (this.trackCursorAxis === 'x') {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: triggerRect.top,
|
||||
right: this.#mousePosition.x,
|
||||
bottom: triggerRect.bottom,
|
||||
left: this.#mousePosition.x,
|
||||
x: this.#mousePosition.x,
|
||||
y: triggerRect.top,
|
||||
};
|
||||
} else if (this.trackCursorAxis === 'y') {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: this.#mousePosition.y,
|
||||
right: triggerRect.right,
|
||||
bottom: this.#mousePosition.y,
|
||||
left: triggerRect.left,
|
||||
x: triggerRect.left,
|
||||
y: this.#mousePosition.y,
|
||||
};
|
||||
} else {
|
||||
// Track both axes (trackCursorAxis === 'both')
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: this.#mousePosition.y,
|
||||
right: this.#mousePosition.x,
|
||||
bottom: this.#mousePosition.y,
|
||||
left: this.#mousePosition.x,
|
||||
x: this.#mousePosition.x,
|
||||
y: this.#mousePosition.y,
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
: trigger;
|
||||
|
||||
computePosition(referenceElement, popup, {
|
||||
placement,
|
||||
middleware,
|
||||
}).then(({ x, y, middlewareData, placement: computedPlacement }: { x: number; y: number; middlewareData: any; placement: Placement }) => {
|
||||
Object.assign(popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
popup.setAttribute('data-side', computedPlacement);
|
||||
|
||||
if (this.#arrowElement && middlewareData.arrow) {
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
||||
Object.assign(this.#arrowElement.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : undefined,
|
||||
top: arrowY != null ? `${arrowY}px` : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
if (!this.trackCursorAxis) {
|
||||
this.#cleanup = autoUpdate(trigger, popup, updatePosition);
|
||||
}
|
||||
}
|
||||
|
||||
#updatePosition(): void {
|
||||
if (this.#open && this.trackCursorAxis) {
|
||||
this.#setupFloating();
|
||||
}
|
||||
}
|
||||
|
||||
#clearHoverTimeout(): void {
|
||||
if (this.#hoverTimeout) {
|
||||
clearTimeout(this.#hoverTimeout);
|
||||
this.#hoverTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
#handleMouseEnter(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(true);
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
#handleMouseLeave(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, this.closeDelay);
|
||||
}
|
||||
|
||||
#handleMouseMove(event: MouseEvent): void {
|
||||
if (this.trackCursorAxis) {
|
||||
this.#mousePosition = { x: event.clientX, y: event.clientY };
|
||||
|
||||
if (this.#open) {
|
||||
this.#updatePosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTooltipTrigger extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const triggerElement = this.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes') {
|
||||
const rootElement = this.closest('media-tooltip-root') as MediaTooltipRoot;
|
||||
let popupElement = rootElement.querySelector('media-tooltip-popup') as MediaTooltipPopup;
|
||||
|
||||
if (!popupElement) {
|
||||
const portalElement = rootElement.querySelector('media-tooltip-portal') as MediaTooltipPortal;
|
||||
if (!portalElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupElement = portalElement.querySelector('media-tooltip-popup') as MediaTooltipPopup;
|
||||
if (!popupElement) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const attributeName = mutation.attributeName;
|
||||
if (!attributeName || !attributeName.startsWith('data-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValue = triggerElement.getAttribute(attributeName);
|
||||
if (attributeValue !== null) {
|
||||
popupElement.setAttribute(attributeName, attributeValue);
|
||||
} else {
|
||||
popupElement.removeAttribute(attributeName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(triggerElement, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTooltipPortal extends HTMLElement {
|
||||
#portal: HTMLElement | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
this.#setupPortal();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanupPortal();
|
||||
}
|
||||
|
||||
querySelector(selector: string): HTMLElement | null {
|
||||
return this.#portal?.querySelector(selector) ?? null;
|
||||
}
|
||||
|
||||
#setupPortal(): void {
|
||||
const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
|
||||
if (!portalId) return;
|
||||
|
||||
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
|
||||
// NOTE: Hacky solution in part to ensure styling propogates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
|
||||
const portalContainer
|
||||
= ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
|
||||
?? (this.getRootNode() as ShadowRoot | Document)
|
||||
.querySelector('media-container')
|
||||
?.shadowRoot
|
||||
?.getElementById(portalId))
|
||||
? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
|
||||
: undefined;
|
||||
if (!portalContainer) return;
|
||||
|
||||
this.#portal = document.createElement('div');
|
||||
this.#portal.slot = 'portal';
|
||||
this.#portal.id = uniqueId();
|
||||
portalContainer.append(this.#portal);
|
||||
|
||||
this.#portal.append(...this.children);
|
||||
}
|
||||
|
||||
#cleanupPortal(): void {
|
||||
if (!this.#portal) return;
|
||||
|
||||
// Move children back to the portal element
|
||||
this.append(...this.#portal.children);
|
||||
this.#portal.remove();
|
||||
this.#portal = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTooltipPositioner extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const popup = this.firstElementChild as HTMLElement;
|
||||
if (popup) {
|
||||
Object.assign(popup.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get side(): Placement {
|
||||
return (this.getAttribute('side') as Placement) ?? 'top';
|
||||
}
|
||||
|
||||
get sideOffset(): number {
|
||||
return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10);
|
||||
}
|
||||
|
||||
get collisionPadding(): number {
|
||||
return Number.parseInt(this.getAttribute('collision-padding') ?? '0', 10);
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTooltipPopup extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.setAttribute('role', 'tooltip');
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTooltipArrow extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-root')) {
|
||||
globalThis.customElements.define('media-tooltip-root', MediaTooltipRoot);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-trigger')) {
|
||||
globalThis.customElements.define('media-tooltip-trigger', MediaTooltipTrigger);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-portal')) {
|
||||
globalThis.customElements.define('media-tooltip-portal', MediaTooltipPortal);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-positioner')) {
|
||||
globalThis.customElements.define('media-tooltip-positioner', MediaTooltipPositioner);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-popup')) {
|
||||
globalThis.customElements.define('media-tooltip-popup', MediaTooltipPopup);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-tooltip-arrow')) {
|
||||
globalThis.customElements.define('media-tooltip-arrow', MediaTooltipArrow);
|
||||
}
|
||||
|
||||
export const Tooltip: {
|
||||
Root: typeof MediaTooltipRoot;
|
||||
Trigger: typeof MediaTooltipTrigger;
|
||||
Portal: typeof MediaTooltipPortal;
|
||||
Positioner: typeof MediaTooltipPositioner;
|
||||
Popup: typeof MediaTooltipPopup;
|
||||
Arrow: typeof MediaTooltipArrow;
|
||||
} = {
|
||||
Root: MediaTooltipRoot,
|
||||
Trigger: MediaTooltipTrigger,
|
||||
Portal: MediaTooltipPortal,
|
||||
Positioner: MediaTooltipPositioner,
|
||||
Popup: MediaTooltipPopup,
|
||||
Arrow: MediaTooltipArrow,
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
|
||||
|
||||
import { VolumeSlider as CoreVolumeSlider } from '@vjs-10/core';
|
||||
import { volumeSliderStateDefinition } from '@vjs-10/core/store';
|
||||
|
||||
import { setAttributes } from '@vjs-10/utils/dom';
|
||||
import { toConnectedHTMLComponent } from '../utils/component-factory';
|
||||
|
||||
/**
|
||||
* VolumeSlider Root props hook - equivalent to React's useVolumeSliderRootProps
|
||||
* Handles element attributes and properties based on state
|
||||
*/
|
||||
export const getVolumeSliderRootProps: PropsHook<{
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
core: CoreVolumeSlider | null;
|
||||
}> = (state, element) => {
|
||||
const volumeText = `${Math.round(state.muted ? 0 : state.volume * 100)}%`;
|
||||
|
||||
const baseProps: Record<string, any> = {
|
||||
role: 'slider',
|
||||
tabindex: element.getAttribute('tabindex') ?? '0',
|
||||
'data-muted': state.muted.toString(),
|
||||
'data-volume-level': state.volumeLevel,
|
||||
'data-orientation': (element as any).orientation || 'horizontal',
|
||||
'aria-label': 'Volume',
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': '100',
|
||||
'aria-valuetext': volumeText,
|
||||
'aria-orientation': (element as any).orientation || 'horizontal',
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* VolumeSlider Root component - Main container with pointer event handling
|
||||
*/
|
||||
interface VolumeSliderRootState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
core: CoreVolumeSlider | null;
|
||||
}
|
||||
|
||||
export class VolumeSliderRootBase extends HTMLElement {
|
||||
static readonly observedAttributes: readonly string[] = ['orientation'];
|
||||
|
||||
_state: VolumeSliderRootState | undefined;
|
||||
_core: CoreVolumeSlider | null = null;
|
||||
|
||||
get volume(): number | undefined {
|
||||
return this._state?.volume;
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this._state?.muted ?? false;
|
||||
}
|
||||
|
||||
get volumeLevel(): string {
|
||||
return this._state?.volumeLevel ?? 'high';
|
||||
}
|
||||
|
||||
get orientation(): 'horizontal' | 'vertical' {
|
||||
return (this.getAttribute('orientation') as 'horizontal' | 'vertical') || 'horizontal';
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
||||
if (name === 'orientation' && this._state) {
|
||||
this._render(getVolumeSliderRootProps(this._state, this), this._state);
|
||||
}
|
||||
}
|
||||
|
||||
_update(_props: any, state: any): void {
|
||||
this._state = state;
|
||||
|
||||
if (state && !this._core) {
|
||||
this._core = new CoreVolumeSlider();
|
||||
this._core.subscribe(() => this._render(getVolumeSliderRootProps(state, this), state));
|
||||
this._core.attach(this);
|
||||
state.core = this._core;
|
||||
}
|
||||
|
||||
this._core?.setState(state);
|
||||
}
|
||||
|
||||
_render(props: any, state: any): void {
|
||||
const coreState = state?.core?.getState();
|
||||
if (!coreState) return;
|
||||
|
||||
this.style.setProperty('--slider-fill', `${coreState._fillWidth.toFixed(3)}%`);
|
||||
this.style.setProperty('--slider-pointer', `${coreState._pointerWidth.toFixed(3)}%`);
|
||||
|
||||
props['aria-valuenow'] = coreState._fillWidth.toString();
|
||||
|
||||
setAttributes(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VolumeSlider Track component - Track element that captures pointer events
|
||||
*/
|
||||
export class VolumeSliderTrackBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
// Set this element as the track element in the core VolumeSlider
|
||||
const rootElement = this.closest('media-volume-slider-root') as any;
|
||||
if (rootElement?._state?.core) {
|
||||
rootElement._state.core.setState({ _trackElement: this });
|
||||
}
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.width = '100%';
|
||||
this.style.removeProperty('height');
|
||||
} else {
|
||||
this.style.height = '100%';
|
||||
this.style.removeProperty('width');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VolumeSlider Progress component - Shows current progress
|
||||
*/
|
||||
export class VolumeSliderProgressBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.style.position = 'absolute';
|
||||
this.style.width = 'var(--slider-fill, 0%)';
|
||||
this.style.height = '100%';
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.width = 'var(--slider-fill, 0%)';
|
||||
this.style.height = '100%';
|
||||
this.style.top = '0';
|
||||
this.style.removeProperty('bottom');
|
||||
} else {
|
||||
this.style.height = 'var(--slider-fill, 0%)';
|
||||
this.style.width = '100%';
|
||||
this.style.bottom = '0';
|
||||
this.style.removeProperty('top');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VolumeSlider Thumb component - Draggable thumb element
|
||||
*/
|
||||
export class VolumeSliderThumbBase extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.style.position = 'absolute';
|
||||
}
|
||||
|
||||
_update(props: any, _state: any): void {
|
||||
setAttributes(this, props);
|
||||
|
||||
// Set appropriate positioning based on orientation
|
||||
if (props['data-orientation'] === 'horizontal') {
|
||||
this.style.left = 'var(--slider-fill, 0%)';
|
||||
this.style.top = '50%';
|
||||
this.style.transform = 'translate(-50%, -50%)';
|
||||
} else {
|
||||
this.style.bottom = 'var(--slider-fill, 0%)';
|
||||
this.style.left = '50%';
|
||||
this.style.transform = 'translate(-50%, 50%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VolumeSlider Root state hook - equivalent to React's useVolumeSliderRootState
|
||||
* Handles media store state subscription and transformation
|
||||
*/
|
||||
export const useVolumeSliderRootState: StateHook<{
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
core: CoreVolumeSlider | null;
|
||||
}> = {
|
||||
keys: volumeSliderStateDefinition.keys,
|
||||
transform: (rawState, mediaStore) => ({
|
||||
...volumeSliderStateDefinition.stateTransform(rawState),
|
||||
...volumeSliderStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
core: null,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* VolumeSlider Track props hook
|
||||
*/
|
||||
export const getVolumeSliderTrackProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-volume-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* VolumeSlider Progress props hook
|
||||
*/
|
||||
export const getVolumeSliderProgressProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-volume-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* VolumeSlider Thumb props hook
|
||||
*/
|
||||
export const getVolumeSliderThumbProps: PropsHook<Record<string, never>> = (_state, element) => {
|
||||
const rootElement = element.closest('media-volume-slider-root') as any;
|
||||
return {
|
||||
'data-orientation': rootElement?.orientation || 'horizontal',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Connected VolumeSlider Root component using hook-style architecture
|
||||
*/
|
||||
export const VolumeSliderRoot: ConnectedComponentConstructor<{
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
core: CoreVolumeSlider | null;
|
||||
}> = toConnectedHTMLComponent(VolumeSliderRootBase, useVolumeSliderRootState, getVolumeSliderRootProps, 'VolumeSliderRoot');
|
||||
|
||||
/**
|
||||
* Connected VolumeSlider Track component
|
||||
*/
|
||||
export const VolumeSliderTrack: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
VolumeSliderTrackBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getVolumeSliderTrackProps,
|
||||
'VolumeSliderTrack',
|
||||
);
|
||||
|
||||
/**
|
||||
* Connected VolumeSlider Progress component
|
||||
*/
|
||||
export const VolumeSliderProgress: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
VolumeSliderProgressBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getVolumeSliderProgressProps,
|
||||
'VolumeSliderProgress',
|
||||
);
|
||||
|
||||
/**
|
||||
* Connected VolumeSlider Thumb component
|
||||
*/
|
||||
export const VolumeSliderThumb: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
|
||||
VolumeSliderThumbBase,
|
||||
{ keys: [], transform: () => ({}) },
|
||||
getVolumeSliderThumbProps,
|
||||
'VolumeSliderThumb',
|
||||
);
|
||||
|
||||
/**
|
||||
* Compound VolumeSlider component object
|
||||
*/
|
||||
export const VolumeSlider = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: VolumeSliderRoot,
|
||||
Track: VolumeSliderTrack,
|
||||
Progress: VolumeSliderProgress,
|
||||
Thumb: VolumeSliderThumb,
|
||||
},
|
||||
) as {
|
||||
Root: typeof VolumeSliderRoot;
|
||||
Track: typeof VolumeSliderTrack;
|
||||
Progress: typeof VolumeSliderProgress;
|
||||
Thumb: typeof VolumeSliderThumb;
|
||||
};
|
||||
|
||||
if (!globalThis.customElements.get('media-volume-slider-root')) {
|
||||
globalThis.customElements.define('media-volume-slider-root', VolumeSliderRoot);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-volume-slider-track')) {
|
||||
globalThis.customElements.define('media-volume-slider-track', VolumeSliderTrack);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-volume-slider-progress')) {
|
||||
globalThis.customElements.define('media-volume-slider-progress', VolumeSliderProgress);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-volume-slider-thumb')) {
|
||||
globalThis.customElements.define('media-volume-slider-thumb', VolumeSliderThumb);
|
||||
}
|
||||
|
||||
export default VolumeSlider;
|
||||
@@ -0,0 +1,7 @@
|
||||
export * as MediaFullscreenEnterIcon from './media-fullscreen-enter-icon';
|
||||
export * as MediaFullscreenExitIcon from './media-fullscreen-exit-icon';
|
||||
export * as MediaPauseIcon from './media-pause-icon';
|
||||
export * as MediaPlayIcon from './media-play-icon';
|
||||
export * as MediaVolumeHighIcon from './media-volume-high-icon';
|
||||
export * as MediaVolumeLowIcon from './media-volume-low-icon';
|
||||
export * as MediaVolumeOffIcon from './media-volume-off-icon';
|
||||
@@ -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: () => string = 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,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-fullscreen-enter-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.fullscreenEnter}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaFullscreenEnterIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-fullscreen-enter-icon', MediaFullscreenEnterIcon);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-fullscreen-exit-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.fullscreenExit}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaFullscreenExitIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-fullscreen-exit-icon', MediaFullscreenExitIcon);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
${SVG_ICONS.pause}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaPauseIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-pause-icon', MediaPauseIcon);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-play-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.play}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaPlayIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-play-icon', MediaPlayIcon);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-play-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.volumeHigh}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaVolumeHighIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-volume-high-icon', MediaVolumeHighIcon);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-play-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.volumeLow}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaVolumeLowIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-volume-low-icon', MediaVolumeLowIcon);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SVG_ICONS } from '@vjs-10/icons';
|
||||
|
||||
import { MediaChromeIcon } from './media-chrome-icon';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
${MediaChromeIcon.getTemplateHTML()}
|
||||
<style>
|
||||
:host {
|
||||
display: var(--media-play-icon-display, inline-flex);
|
||||
}
|
||||
</style>
|
||||
${SVG_ICONS.volumeOff}
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaVolumeOffIcon extends MediaChromeIcon {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-volume-off-icon', MediaVolumeOffIcon);
|
||||
@@ -0,0 +1,19 @@
|
||||
export { CurrentTimeDisplay } from './components/media-current-time-display';
|
||||
export { DurationDisplay } from './components/media-duration-display';
|
||||
export { FullscreenButton } from './components/media-fullscreen-button';
|
||||
export { MuteButton } from './components/media-mute-button';
|
||||
export { PlayButton } from './components/media-play-button';
|
||||
export { Popover } from './components/media-popover';
|
||||
export { TimeSlider } from './components/media-time-slider';
|
||||
export { Tooltip } from './components/media-tooltip';
|
||||
export { VolumeSlider } from './components/media-volume-slider';
|
||||
export { MediaContainer } from './media/media-container';
|
||||
export { MediaProvider } from './media/media-provider';
|
||||
export { MediaSkin } from './media/media-skin';
|
||||
|
||||
export function defineVjsPlayer(): void {
|
||||
/** @TODO - Reimplement me (at least as a POC) (CJP) */
|
||||
// defineVideoProvider();
|
||||
// defineVideoDefaultSkin();
|
||||
// <video> is native, no need to define
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Constructor, CustomElement } from '@open-wc/context-protocol';
|
||||
|
||||
import { ConsumerMixin } from '@open-wc/context-protocol';
|
||||
|
||||
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
|
||||
export function getTemplateHTML() {
|
||||
return /* html */ `
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
<div id="@default_portal_id" style={ position: absolute; zIndex: 10; }>
|
||||
<slot name="portal"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const CustomElementConsumer: Constructor<CustomElement> = ConsumerMixin(HTMLElement);
|
||||
|
||||
export class MediaContainer extends CustomElementConsumer {
|
||||
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
|
||||
_mediaStore: any;
|
||||
_mediaSlot: HTMLSlotElement;
|
||||
_paused: boolean = true;
|
||||
contexts = {
|
||||
mediaStore: (mediaStore: any): void => {
|
||||
this._mediaStore = mediaStore;
|
||||
this._handleMediaSlotChange();
|
||||
this._registerContainerStateOwner();
|
||||
this._subscribeToPlayState();
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Add click handler for play/pause functionality
|
||||
this.addEventListener('click', this._handleClick);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback?.();
|
||||
this._registerContainerStateOwner();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback?.();
|
||||
this._unregisterContainerStateOwner();
|
||||
}
|
||||
|
||||
_registerContainerStateOwner = (): void => {
|
||||
if (!this._mediaStore) return;
|
||||
this._mediaStore.dispatch({ type: 'containerstateownerchangerequest', detail: this });
|
||||
};
|
||||
|
||||
_unregisterContainerStateOwner = (): void => {
|
||||
if (!this._mediaStore) return;
|
||||
this._mediaStore.dispatch({ type: 'containerstateownerchangerequest', detail: null });
|
||||
};
|
||||
|
||||
_handleMediaSlotChange = (): void => {
|
||||
const media = this._mediaSlot.assignedElements({ flatten: true })[0];
|
||||
this._mediaStore.dispatch({ type: 'mediastateownerchangerequest', detail: media });
|
||||
};
|
||||
|
||||
_handleClick = (event: Event): void => {
|
||||
if (!this._mediaStore) return;
|
||||
|
||||
if (!['video', 'audio'].includes((event.target as HTMLElement).localName || '')) return;
|
||||
|
||||
if (this._paused) {
|
||||
this._mediaStore.dispatch({ type: 'playrequest' });
|
||||
} else {
|
||||
this._mediaStore.dispatch({ type: 'pauserequest' });
|
||||
}
|
||||
};
|
||||
|
||||
_subscribeToPlayState = (): void => {
|
||||
if (!this._mediaStore) return;
|
||||
|
||||
// Subscribe to paused state changes
|
||||
this._mediaStore.subscribe((state: any) => {
|
||||
this._paused = state.paused ?? true;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-container')) {
|
||||
// @ts-expect-error ts(2345)
|
||||
globalThis.customElements.define('media-container', MediaContainer);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Constructor, CustomElement } from '@open-wc/context-protocol';
|
||||
import type { MediaStore } from '@vjs-10/core/store';
|
||||
|
||||
import { ProviderMixin } from '@open-wc/context-protocol';
|
||||
import { createMediaStore } from '@vjs-10/core/store';
|
||||
|
||||
const ProviderHTMLElement: Constructor<CustomElement> = ProviderMixin(HTMLElement);
|
||||
|
||||
export class MediaProvider extends ProviderHTMLElement {
|
||||
contexts = {
|
||||
mediaStore: (): MediaStore => {
|
||||
return createMediaStore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error - fix types after (Rahim)
|
||||
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: () => string = 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,389 @@
|
||||
import { MediaSkin } from '@/media/media-skin';
|
||||
import '@/media/media-container';
|
||||
import '@/components/media-play-button';
|
||||
import '@/components/media-mute-button';
|
||||
import '@/components/media-volume-slider';
|
||||
import '@/components/media-time-slider';
|
||||
import '@/components/media-fullscreen-button';
|
||||
import '@/components/media-duration-display';
|
||||
import '@/components/media-current-time-display';
|
||||
import '@/components/media-preview-time-display';
|
||||
import '@/components/media-popover';
|
||||
import '@/components/media-tooltip';
|
||||
import '@/icons';
|
||||
|
||||
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 > ::slotted([slot=media]) {
|
||||
display: block;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Time Display Styling */
|
||||
media-current-time-display,
|
||||
media-duration-display {
|
||||
padding: 4px 8px;
|
||||
color: rgb(238 238 238);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
border-radius: 2px;
|
||||
min-width: 3em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Generic Media Button Styling */
|
||||
.button {
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: rgb(238 238 238);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.button .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Media Play Button UI/Styles */
|
||||
media-play-button:not([data-paused]) .pause-icon,
|
||||
media-play-button[data-paused] .play-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Media Fullscreen Button UI/Styles */
|
||||
media-fullscreen-button:not([data-fullscreen]) .fullscreen-enter-icon,
|
||||
media-fullscreen-button[data-fullscreen] .fullscreen-exit-icon {
|
||||
display: 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: block;
|
||||
}
|
||||
|
||||
/* Media Control Bar UI/Styles */
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgb(20 20 30 / .7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* TimeSlider Component Styles */
|
||||
media-time-slider-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
|
||||
/* Horizontal orientation styles */
|
||||
media-time-slider-root[data-orientation="horizontal"] {
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Vertical orientation styles */
|
||||
media-time-slider-root[data-orientation="vertical"] {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: 100px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
media-time-slider-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: .375rem;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Horizontal track styles */
|
||||
media-time-slider-track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: .375rem;
|
||||
}
|
||||
|
||||
/* Vertical track styles */
|
||||
media-time-slider-track[data-orientation="vertical"] {
|
||||
width: .375rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
media-time-slider-thumb {
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
media-time-slider-pointer {
|
||||
background-color: rgba(255, 255, 255, .5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
media-time-slider-progress {
|
||||
background-color: #007bff;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
media-popover-popup {
|
||||
background: rgb(20 20 30 / .7);
|
||||
padding: 14px 0;
|
||||
--transition: .15s ease-in-out;
|
||||
transition: transform var(--transition), scale var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
media-popover-popup[data-starting-style] {
|
||||
transition-duration: 0s;
|
||||
transform: scale(0.9) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
media-popover-popup[data-ending-style] {
|
||||
transform: scale(0.9) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* VolumeSlider Component Styles */
|
||||
media-volume-slider-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-width: 80px;
|
||||
width: 80px;
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
|
||||
/* Horizontal orientation styles */
|
||||
media-volume-slider-root[data-orientation="horizontal"] {
|
||||
min-width: 80px;
|
||||
width: 80px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Vertical orientation styles */
|
||||
media-volume-slider-root[data-orientation="vertical"] {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: 80px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
media-volume-slider-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: .375rem;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Horizontal track styles */
|
||||
media-volume-slider-track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: .375rem;
|
||||
}
|
||||
|
||||
/* Vertical track styles */
|
||||
media-volume-slider-track[data-orientation="vertical"] {
|
||||
width: .375rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
media-volume-slider-thumb {
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
media-volume-slider-progress {
|
||||
background-color: #007bff;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Tooltip Component Styles */
|
||||
media-tooltip-popup {
|
||||
background: rgb(20 20 30 / .9);
|
||||
color: rgb(238 238 238);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--transition: .15s ease-in-out;
|
||||
transition: transform var(--transition), scale var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
media-tooltip-popup[data-starting-style] {
|
||||
transition-duration: 0s;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
media-tooltip-popup[data-ending-style] {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
media-tooltip-popup[data-paused] .play-tooltip,
|
||||
media-tooltip-popup:not([data-paused]) .pause-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
media-tooltip-popup[data-fullscreen] .fullscreen-exit-tooltip,
|
||||
media-tooltip-popup:not([data-fullscreen]) .fullscreen-enter-tooltip {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<media-container>
|
||||
<slot name="media" slot="media"></slot>
|
||||
<div class="overlay"></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-tooltip-root delay="600" close-delay="0">
|
||||
<media-tooltip-trigger>
|
||||
<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-tooltip-trigger>
|
||||
<media-tooltip-portal>
|
||||
<media-tooltip-positioner side="top" side-offset="8" collision-padding="8">
|
||||
<media-tooltip-popup>
|
||||
<span class="tooltip play-tooltip">Play</span>
|
||||
<span class="tooltip pause-tooltip">Pause</span>
|
||||
</media-tooltip-popup>
|
||||
</media-tooltip-positioner>
|
||||
</media-tooltip-portal>
|
||||
</media-tooltip-root>
|
||||
<!-- Use the show-remaining attribute to show count down/remaining time -->
|
||||
<media-current-time-display show-remaining></media-current-time-display>
|
||||
|
||||
<media-tooltip-root track-cursor-axis="x">
|
||||
<media-tooltip-trigger>
|
||||
<media-time-slider-root>
|
||||
<media-time-slider-track>
|
||||
<media-time-slider-progress></media-time-slider-progress>
|
||||
<media-time-slider-pointer></media-time-slider-pointer>
|
||||
</media-time-slider-track>
|
||||
<media-time-slider-thumb></media-time-slider-thumb>
|
||||
</media-time-slider-root>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-portal>
|
||||
<media-tooltip-positioner side="top" side-offset="18" collision-padding="12">
|
||||
<media-tooltip-popup>
|
||||
<preview-time-display></preview-time-display>
|
||||
</media-tooltip-popup>
|
||||
</media-tooltip-positioner>
|
||||
</media-tooltip-portal>
|
||||
</media-tooltip-root>
|
||||
|
||||
<media-duration-display></media-duration-display>
|
||||
<media-popover-root open-on-hover delay="200" close-delay="100">
|
||||
<media-popover-trigger>
|
||||
<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>
|
||||
</media-popover-trigger>
|
||||
<media-popover-portal>
|
||||
<media-popover-positioner side="top">
|
||||
<media-popover-popup>
|
||||
<media-volume-slider-root orientation="vertical">
|
||||
<media-volume-slider-track>
|
||||
<media-volume-slider-progress></media-volume-slider-progress>
|
||||
</media-volume-slider-track>
|
||||
<media-volume-slider-thumb></media-volume-slider-thumb>
|
||||
</media-volume-slider-root>
|
||||
</media-popover-popup>
|
||||
</media-popover-positioner>
|
||||
</media-popover-portal>
|
||||
</media-popover-root>
|
||||
<media-tooltip-root delay="600" close-delay="0">
|
||||
<media-tooltip-trigger>
|
||||
<media-fullscreen-button class="button">
|
||||
<media-fullscreen-enter-icon class="icon fullscreen-enter-icon"></media-fullscreen-enter-icon>
|
||||
<media-fullscreen-exit-icon class="icon fullscreen-exit-icon"></media-fullscreen-exit-icon>
|
||||
</media-fullscreen-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-portal>
|
||||
<media-tooltip-positioner side="top" side-offset="8" collision-padding="8">
|
||||
<media-tooltip-popup>
|
||||
<span class="tooltip fullscreen-enter-tooltip">Enter Fullscreen</span>
|
||||
<span class="tooltip fullscreen-exit-tooltip">Exit Fullscreen</span>
|
||||
</media-tooltip-popup>
|
||||
</media-tooltip-positioner>
|
||||
</media-tooltip-portal>
|
||||
</media-tooltip-root>
|
||||
</div>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
export class MediaSkinDefault extends MediaSkin {
|
||||
static getTemplateHTML: () => string = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define('media-skin-default', MediaSkinDefault);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ConsumerMixin } from '@open-wc/context-protocol';
|
||||
|
||||
/**
|
||||
* Generic types for HTML component hooks pattern
|
||||
* Mirrors the React hooks architecture for consistency
|
||||
*/
|
||||
export interface StateHook<T = any> {
|
||||
keys: string[];
|
||||
transform: (rawState: any, mediaStore: any) => T;
|
||||
}
|
||||
|
||||
export type PropsHook<T = any, P = any> = (state: T, element: HTMLElement) => P;
|
||||
|
||||
export interface ConnectedComponentConstructor<State> {
|
||||
new (state: State): HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 function toConnectedHTMLComponent<State = any>(
|
||||
BaseClass: CustomElementConstructor,
|
||||
stateHook: StateHook<State>,
|
||||
propsHook: PropsHook<State>,
|
||||
displayName?: string,
|
||||
): ConnectedComponentConstructor<State> {
|
||||
const ConnectedComponent = class extends ConsumerMixin(BaseClass) {
|
||||
static get observedAttributes(): string[] {
|
||||
return [
|
||||
// @ts-expect-error ts(2339)
|
||||
...(super.observedAttributes ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
_mediaStore: any;
|
||||
|
||||
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)
|
||||
const state = stateHook.transform(rawState, mediaStore);
|
||||
|
||||
// Phase 2: Update element attributes/properties (props concern)
|
||||
const props = propsHook(state ?? {} as State, this);
|
||||
// @ts-expect-error any
|
||||
this._update(props, state, mediaStore);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback?.();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback?.();
|
||||
}
|
||||
|
||||
handleEvent(event: CustomEvent): void {
|
||||
// @ts-expect-error any
|
||||
super.handleEvent?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Set display name for debugging and dev tools
|
||||
if (displayName) {
|
||||
Object.defineProperty(ConnectedComponent, 'name', { value: displayName });
|
||||
}
|
||||
|
||||
return ConnectedComponent;
|
||||
}
|
||||
Reference in New Issue
Block a user