mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
chore(root): prepare workspace for alpha (#276)
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { currentTimeDisplayStateDefinition } from '@videojs/core-preview/store';
|
||||
import { formatDisplayTime, shallowEqual } from '@videojs/utils-preview';
|
||||
|
||||
import { useMediaSelector } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function useCurrentTimeDisplayState(_props?: any): {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
} {
|
||||
/** @TODO Fix type issues with hooks (CJP) */
|
||||
const mediaState = useMediaSelector(currentTimeDisplayStateDefinition.stateTransform, shallowEqual);
|
||||
|
||||
// Current time display is read-only, no request methods needed
|
||||
return {
|
||||
currentTime: mediaState.currentTime ?? 0,
|
||||
duration: mediaState.duration ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type CurrentTimeDisplayState = ReturnType<typeof useCurrentTimeDisplayState>;
|
||||
|
||||
export interface CurrentTimeDisplayProps extends React.ComponentProps<'span'> {
|
||||
showRemaining?: boolean;
|
||||
}
|
||||
|
||||
export function useCurrentTimeDisplayProps(
|
||||
props: PropsWithChildren,
|
||||
_state: ReturnType<typeof useCurrentTimeDisplayState>,
|
||||
): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export function renderCurrentTimeDisplay(props: CurrentTimeDisplayProps, state: CurrentTimeDisplayState): JSX.Element {
|
||||
const { showRemaining, ...restProps } = props;
|
||||
|
||||
/** @TODO Should this live here or elsewhere? (CJP) */
|
||||
const timeLabel
|
||||
= showRemaining && state.duration != null && state.currentTime != null
|
||||
? formatDisplayTime(-(state.duration - state.currentTime))
|
||||
: formatDisplayTime(state.currentTime);
|
||||
|
||||
return <span {...restProps}>{timeLabel}</span>;
|
||||
}
|
||||
|
||||
export const CurrentTimeDisplay: ConnectedComponent<CurrentTimeDisplayProps, typeof renderCurrentTimeDisplay>
|
||||
= toConnectedComponent(
|
||||
useCurrentTimeDisplayState,
|
||||
useCurrentTimeDisplayProps,
|
||||
renderCurrentTimeDisplay,
|
||||
'CurrentTimeDisplay',
|
||||
);
|
||||
|
||||
export default CurrentTimeDisplay;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { durationDisplayStateDefinition } from '@videojs/core-preview/store';
|
||||
import { formatDisplayTime, shallowEqual } from '@videojs/utils-preview';
|
||||
|
||||
import { useMediaSelector } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function useDurationDisplayState(_props: any): {
|
||||
duration: number;
|
||||
} {
|
||||
/** @TODO Fix type issues with hooks (CJP) */
|
||||
const mediaState = useMediaSelector(durationDisplayStateDefinition.stateTransform, shallowEqual);
|
||||
|
||||
// Duration display is read-only, no request methods needed
|
||||
return {
|
||||
duration: mediaState.duration ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type useDurationDisplayState = typeof useDurationDisplayState;
|
||||
export type DurationDisplayState = ReturnType<useDurationDisplayState>;
|
||||
|
||||
export function useDurationDisplayProps(props: PropsWithChildren): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export type useDurationDisplayProps = typeof useDurationDisplayProps;
|
||||
type DurationDisplayProps = ReturnType<useDurationDisplayProps>;
|
||||
|
||||
export function renderDurationDisplay(props: DurationDisplayProps, state: DurationDisplayState): JSX.Element {
|
||||
return <span {...props}>{formatDisplayTime(state.duration)}</span>;
|
||||
}
|
||||
|
||||
export type renderDurationDisplay = typeof renderDurationDisplay;
|
||||
|
||||
export const DurationDisplay: ConnectedComponent<DurationDisplayProps, typeof renderDurationDisplay>
|
||||
= toConnectedComponent(useDurationDisplayState, useDurationDisplayProps, renderDurationDisplay, 'DurationDisplay');
|
||||
|
||||
export default DurationDisplay;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { fullscreenButtonStateDefinition } from '@videojs/core-preview/store';
|
||||
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function useFullscreenButtonState(_props?: any): {
|
||||
fullscreen: boolean;
|
||||
requestEnterFullscreen: () => void;
|
||||
requestExitFullscreen: () => void;
|
||||
} {
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(fullscreenButtonStateDefinition.stateTransform, shallowEqual);
|
||||
const methods = useMemo(
|
||||
() => fullscreenButtonStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
[mediaStore],
|
||||
);
|
||||
|
||||
return {
|
||||
fullscreen: mediaState.fullscreen,
|
||||
requestEnterFullscreen: methods.requestEnterFullscreen,
|
||||
requestExitFullscreen: methods.requestExitFullscreen,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export type FullscreenButtonState = ReturnType<typeof useFullscreenButtonState>;
|
||||
|
||||
export function getFullscreenButtonProps(
|
||||
props: PropsWithChildren,
|
||||
state: FullscreenButtonState,
|
||||
): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
'aria-label': state.fullscreen ? 'exit fullscreen' : 'enter fullscreen',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
// Handle boolean data attribute: present with empty string when true, absent when false
|
||||
if (state.fullscreen) {
|
||||
baseProps['data-fullscreen'] = '';
|
||||
}
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export type FullscreenButtonProps = ReturnType<typeof getFullscreenButtonProps>;
|
||||
|
||||
export function renderFullscreenButton(props: FullscreenButtonProps, state: FullscreenButtonState): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
if (state.fullscreen) {
|
||||
state.requestExitFullscreen();
|
||||
} else {
|
||||
state.requestEnterFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const FullscreenButton: ConnectedComponent<FullscreenButtonProps, typeof renderFullscreenButton>
|
||||
= toConnectedComponent(useFullscreenButtonState, getFullscreenButtonProps, renderFullscreenButton, 'FullscreenButton');
|
||||
|
||||
export default FullscreenButton;
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
DetailedHTMLProps,
|
||||
ElementType,
|
||||
PropsWithChildren,
|
||||
Ref,
|
||||
VideoHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import { createMediaPlaybackController } from '@videojs/core-preview/media';
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { useMediaRef } from '@/store';
|
||||
|
||||
export interface MuxVideoProps {
|
||||
'playback-id'?: string;
|
||||
}
|
||||
|
||||
type MediaStateOwner = NonNullable<Parameters<ReturnType<typeof useMediaRef>>[0]>;
|
||||
|
||||
/** @TODO Improve type inference and narrowing/widening for different use cases (CJP) */
|
||||
type ComponentType = ElementType<
|
||||
Omit<DetailedHTMLProps<VideoHTMLAttributes<HTMLVideoElement>, HTMLVideoElement>, 'ref'> & {
|
||||
ref: Ref<MediaStateOwner>;
|
||||
}
|
||||
>;
|
||||
|
||||
// These are the first steps/WIP POC of decoupling the Media State Owner from the DOM.
|
||||
// Note that everything will still work if you use:
|
||||
// 1. an audio/video element directly
|
||||
// 2. a custom element a la media-elements
|
||||
type CreateMediaStateOwner = typeof createMediaPlaybackController;
|
||||
|
||||
function useMediaStateOwner(ref: Ref<any>, createMediaPlaybackController: CreateMediaStateOwner) {
|
||||
const mediaStateOwnerRef = useRef(createMediaPlaybackController(/* props? */));
|
||||
useImperativeHandle(ref, () => mediaStateOwnerRef.current, []);
|
||||
/** @TODO Parameterize this (CJP) */
|
||||
type ComponentProps = DetailedHTMLProps<VideoHTMLAttributes<HTMLVideoElement>, HTMLVideoElement>;
|
||||
return {
|
||||
updateMediaElement(mediaEl: HTMLMediaElement | null, props: ComponentProps) {
|
||||
// NOTE: The details here will almost definitely change for a less "bare bones"/"POC" implementation of Media State Owner impl. (CJP)
|
||||
mediaStateOwnerRef.current.mediaElement = mediaEl ?? undefined;
|
||||
mediaStateOwnerRef.current.src = props.src as string;
|
||||
if (props.muted) {
|
||||
mediaStateOwnerRef.current.muted = props.muted;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DefaultVideoComponent: ElementType<
|
||||
Omit<DetailedHTMLProps<VideoHTMLAttributes<HTMLVideoElement>, HTMLVideoElement>, 'ref'> & { ref: Ref<any> }
|
||||
> = forwardRef<any, any>(({ children, ...props }, ref) => {
|
||||
const { updateMediaElement } = useMediaStateOwner(ref, createMediaPlaybackController);
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
{...props}
|
||||
ref={(mediaEl) => {
|
||||
/** @TODO In later iterations/non-POC, we should be able to have a function that can be used directly for the `ref` prop (CJP) */
|
||||
updateMediaElement(mediaEl, props);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</video>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This is a "thin wrapper" around the media component whose primary responsibility is to wire up the element
|
||||
* to the <VideoProvider/>'s MediaStore for the media state.
|
||||
* @param props - Identical to both a <video/>'s props and the <Player/> props, with one addition that may be familiar to
|
||||
* MUI users: a `component` prop that allows you to use something other than the <video/> element under the hood.
|
||||
* @returns A media react component (e.g. <video/>), wired up as the media element.
|
||||
*/
|
||||
function ConnectedVideo({
|
||||
component,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<{
|
||||
component: ComponentType;
|
||||
className?: string | undefined;
|
||||
style?: CSSProperties | undefined;
|
||||
}>) {
|
||||
const Component = component;
|
||||
const mediaRefCallback = useMediaRef();
|
||||
// NOTE: While this may feel like magic to folks, in the "default" use case, you can think of it as:
|
||||
// return (<video ref={mediaRefCallback} {...restProps} >{children}</video>);
|
||||
return (
|
||||
<Component {...props} ref={mediaRefCallback}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
export type VideoProps = PropsWithChildren<{
|
||||
component?: ComponentType;
|
||||
className?: string | undefined;
|
||||
style?: CSSProperties | undefined;
|
||||
}>;
|
||||
|
||||
// HlsVideo component with default component
|
||||
export function HlsVideo({ component = DefaultVideoComponent, children, ...props }: VideoProps): JSX.Element {
|
||||
return (
|
||||
<ConnectedVideo {...props} component={component}>
|
||||
{children}
|
||||
</ConnectedVideo>
|
||||
);
|
||||
}
|
||||
|
||||
export default HlsVideo;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FC, HTMLProps, PropsWithChildren, RefCallback } from 'react';
|
||||
|
||||
import { playButtonStateDefinition } from '@videojs/core-preview/store';
|
||||
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { forwardRef, useCallback, useMemo } from 'react';
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
/**
|
||||
* Hook to associate a React element as the fullscreen container for the media store.
|
||||
* This is equivalent to Media Chrome's useMediaFullscreenRef but for VJS-10.
|
||||
*
|
||||
* The ref callback will register the element as the container state owner
|
||||
* in the media store, enabling fullscreen functionality.
|
||||
*
|
||||
* @example
|
||||
* import { useMediaContainerRef } from '@videojs/react-preview';
|
||||
*
|
||||
* const PlayerContainer = ({ children }) => {
|
||||
* const containerRef = useMediaContainerRef();
|
||||
* return <div ref={containerRef}>{children}</div>;
|
||||
* };
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useMediaContainerRef(): RefCallback<HTMLElement | null> {
|
||||
const mediaStore = useMediaStore();
|
||||
|
||||
return useCallback(
|
||||
(containerElement: HTMLElement | null) => {
|
||||
if (!mediaStore) return;
|
||||
|
||||
// Register or unregister the container element as the container state owner
|
||||
mediaStore.dispatch({
|
||||
type: 'containerstateownerchangerequest',
|
||||
detail: containerElement,
|
||||
});
|
||||
},
|
||||
[mediaStore],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaContainer component that automatically registers itself as the fullscreen container.
|
||||
* This provides a simple wrapper component for fullscreen functionality.
|
||||
*
|
||||
* @example
|
||||
* import { MediaContainer } from '@videojs/react-preview';
|
||||
*
|
||||
* const MyPlayer = () => (
|
||||
* <MediaContainer>
|
||||
* <video src="video.mp4" />
|
||||
* <div>Controls here</div>
|
||||
* </MediaContainer>
|
||||
* );
|
||||
*/
|
||||
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = forwardRef(
|
||||
({ children, ...props }, ref) => {
|
||||
const containerRef = useMediaContainerRef();
|
||||
const composedRef = useComposedRefs(ref, containerRef);
|
||||
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(playButtonStateDefinition.stateTransform, shallowEqual);
|
||||
const methods = useMemo(() => playButtonStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!['video', 'audio'].includes((event.target as HTMLElement).localName || '')) return;
|
||||
|
||||
if (mediaState.paused) {
|
||||
methods.requestPlay();
|
||||
} else {
|
||||
methods.requestPause();
|
||||
}
|
||||
}, [mediaState.paused, methods]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={composedRef}
|
||||
onClick={handleClick}
|
||||
data-media-container
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { muteButtonStateDefinition } from '@videojs/core-preview/store';
|
||||
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function useMuteButtonState(_props?: any): {
|
||||
volumeLevel: string;
|
||||
muted: boolean;
|
||||
requestMute: () => void;
|
||||
requestUnmute: () => void;
|
||||
} {
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(muteButtonStateDefinition.stateTransform, shallowEqual);
|
||||
const methods = useMemo(() => muteButtonStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
|
||||
|
||||
return {
|
||||
volumeLevel: mediaState.volumeLevel,
|
||||
muted: mediaState.muted,
|
||||
requestMute: methods.requestMute,
|
||||
requestUnmute: methods.requestUnmute,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export type MuteButtonState = ReturnType<typeof useMuteButtonState>;
|
||||
|
||||
export function getMuteButtonProps(
|
||||
props: PropsWithChildren,
|
||||
state: MuteButtonState,
|
||||
): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** data attributes/props - non-boolean */
|
||||
'data-volume-level': state.volumeLevel,
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
'aria-label': state.muted ? 'unmute' : 'mute',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.muted ? 'Unmute' : 'Mute',
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
// Handle boolean data attribute: present with empty string when true, absent when false
|
||||
if (state.muted) {
|
||||
baseProps['data-muted'] = '';
|
||||
}
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export type MuteButtonProps = ReturnType<typeof getMuteButtonProps>;
|
||||
|
||||
export function renderMuteButton(props: MuteButtonProps, state: MuteButtonState): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
if (state.volumeLevel === 'off') {
|
||||
state.requestUnmute();
|
||||
} else {
|
||||
state.requestMute();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const MuteButton: ConnectedComponent<MuteButtonProps, typeof renderMuteButton> = toConnectedComponent(
|
||||
useMuteButtonState,
|
||||
getMuteButtonProps,
|
||||
renderMuteButton,
|
||||
'MuteButton',
|
||||
);
|
||||
|
||||
export default MuteButton;
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { playButtonStateDefinition } from '@videojs/core-preview/store';
|
||||
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function usePlayButtonState(_props?: any): {
|
||||
paused: boolean;
|
||||
requestPlay: () => void;
|
||||
requestPause: () => void;
|
||||
} {
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(playButtonStateDefinition.stateTransform, shallowEqual);
|
||||
const methods = useMemo(() => playButtonStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
|
||||
|
||||
return {
|
||||
paused: mediaState.paused,
|
||||
requestPlay: methods.requestPlay,
|
||||
requestPause: methods.requestPause,
|
||||
};
|
||||
}
|
||||
|
||||
export type PlayButtonState = ReturnType<typeof usePlayButtonState>;
|
||||
|
||||
export function getPlayButtonProps(
|
||||
props: Record<string, unknown>,
|
||||
state: PlayButtonState,
|
||||
): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** @TODO Need another state provider in core for i18n (CJP) */
|
||||
/** aria attributes/props */
|
||||
role: 'button',
|
||||
'aria-label': state.paused ? 'play' : 'pause',
|
||||
/** tooltip */
|
||||
'data-tooltip': state.paused ? 'Play' : 'Pause',
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
// Handle boolean data attribute: present with empty string when true, absent when false
|
||||
if (state.paused) {
|
||||
baseProps['data-paused'] = '';
|
||||
}
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export type PlayButtonProps = ReturnType<typeof getPlayButtonProps>;
|
||||
|
||||
export function renderPlayButton(props: PlayButtonProps, state: PlayButtonState): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
if (state.paused) {
|
||||
state.requestPlay();
|
||||
} else {
|
||||
state.requestPause();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlayButton: ConnectedComponent<PlayButtonProps, typeof renderPlayButton> = toConnectedComponent(
|
||||
usePlayButtonState,
|
||||
getPlayButtonProps,
|
||||
renderPlayButton,
|
||||
'PlayButton',
|
||||
);
|
||||
|
||||
export default PlayButton;
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { PopoverState as CorePopoverState } from '@videojs/core-preview';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Prettify } from '../types';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { Popover as CorePopover } from '@videojs/core-preview';
|
||||
import { Children, cloneElement, useCallback, useEffect, useId, useState } from 'react';
|
||||
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
|
||||
import { useMutationObserver } from '../utils/use-mutation-observer';
|
||||
|
||||
type Placement = CorePopoverState['placement'];
|
||||
|
||||
export type PopoverState = Prettify<
|
||||
CorePopoverState & {
|
||||
popupId: string | undefined;
|
||||
updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void;
|
||||
}
|
||||
>;
|
||||
|
||||
// ============================================================================
|
||||
// ROOT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export interface PopoverRootProps {
|
||||
openOnHover?: boolean;
|
||||
delay?: number;
|
||||
closeDelay?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function usePopoverRootState(props: PopoverRootProps): PopoverState {
|
||||
const { openOnHover = false, delay = 0, closeDelay = 0 } = props;
|
||||
const [placement, setPlacement] = useState<Placement>('top');
|
||||
const [sideOffset, setSideOffset] = useState(5);
|
||||
const [collisionPadding, setCollisionPadding] = useState(0);
|
||||
const uniqueId = useId();
|
||||
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
|
||||
|
||||
const coreState = useCore<CorePopover>(CorePopover, {
|
||||
openOnHover,
|
||||
delay,
|
||||
closeDelay,
|
||||
placement,
|
||||
sideOffset,
|
||||
collisionPadding,
|
||||
});
|
||||
|
||||
const updatePositioning = useCallback(
|
||||
(newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => {
|
||||
setPlacement(newPlacement);
|
||||
setSideOffset(newSideOffset);
|
||||
setCollisionPadding(newCollisionPadding);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...coreState,
|
||||
popupId,
|
||||
updatePositioning,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePopoverRootProps(props: PopoverRootProps, _state: PopoverState): { children: ReactNode } {
|
||||
return {
|
||||
children: props.children,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPopoverRoot(props: { children: ReactNode }): JSX.Element {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
const PopoverRoot: ConnectedComponent<PopoverRootProps, typeof renderPopoverRoot> = toConnectedComponent(
|
||||
usePopoverRootState,
|
||||
usePopoverRootProps,
|
||||
renderPopoverRoot,
|
||||
'Popover.Root',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// TRIGGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export interface PopoverTriggerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function usePopoverTriggerProps(
|
||||
props: PopoverTriggerProps,
|
||||
context: PopoverState,
|
||||
): { child: JSX.Element; triggerProps: Record<string, any> } {
|
||||
const { children } = props;
|
||||
const { _setTriggerElement, _open, popupId } = context;
|
||||
|
||||
const child = Children.only(children) as JSX.Element;
|
||||
const existingStyle = (child.props as { style?: React.CSSProperties })?.style || {};
|
||||
|
||||
return {
|
||||
child,
|
||||
triggerProps: {
|
||||
ref: _setTriggerElement,
|
||||
'data-popup-open': _open ? '' : undefined,
|
||||
commandfor: popupId ?? undefined,
|
||||
style: {
|
||||
...existingStyle,
|
||||
...(popupId ? { anchorName: `--${popupId}` as any } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPopoverTrigger(props: { child: JSX.Element; triggerProps: Record<string, any> }): JSX.Element {
|
||||
// eslint-disable-next-line react/no-clone-element
|
||||
return cloneElement(props.child, props.triggerProps);
|
||||
}
|
||||
|
||||
const PopoverTrigger: ConnectedComponent<PopoverTriggerProps, typeof renderPopoverTrigger> = toContextComponent(
|
||||
usePopoverTriggerProps,
|
||||
renderPopoverTrigger,
|
||||
'Popover.Trigger',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// POSITIONER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export interface PopoverPositionerProps {
|
||||
side?: Placement;
|
||||
sideOffset?: number;
|
||||
collisionPadding?: number;
|
||||
collisionBoundary?: Element;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function usePopoverPositionerProps(
|
||||
props: PopoverPositionerProps,
|
||||
context: PopoverState,
|
||||
): { children: ReactNode } {
|
||||
const { side = 'top', sideOffset = 5, collisionPadding = 0, collisionBoundary, children } = props;
|
||||
const { updatePositioning, _setCollisionBoundaryElement } = context;
|
||||
|
||||
useEffect(() => {
|
||||
updatePositioning(side, sideOffset, collisionPadding);
|
||||
}, [side, sideOffset, collisionPadding, updatePositioning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collisionBoundary) {
|
||||
_setCollisionBoundaryElement(collisionBoundary as HTMLElement);
|
||||
}
|
||||
}, [collisionBoundary, _setCollisionBoundaryElement]);
|
||||
|
||||
return { children };
|
||||
}
|
||||
|
||||
export function renderPopoverPositioner(props: { children: ReactNode }): JSX.Element {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
const PopoverPositioner: ConnectedComponent<PopoverPositionerProps, typeof renderPopoverPositioner>
|
||||
= toContextComponent(usePopoverPositionerProps, renderPopoverPositioner, 'Popover.Positioner');
|
||||
|
||||
// ============================================================================
|
||||
// POPUP COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export interface PopoverPopupProps {
|
||||
id?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface PopoverPopupRenderProps extends React.ComponentProps<'div'> {
|
||||
children: ReactNode;
|
||||
'data-side': Placement;
|
||||
'data-starting-style': string | undefined;
|
||||
'data-open': string | undefined;
|
||||
'data-ending-style': string | undefined;
|
||||
'data-closed': string | undefined;
|
||||
}
|
||||
|
||||
export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverState): PopoverPopupRenderProps {
|
||||
const { className, style, children, id } = props;
|
||||
const {
|
||||
_setPopoverElement,
|
||||
_transitionStatus,
|
||||
placement,
|
||||
popupId,
|
||||
_popoverStyle,
|
||||
_triggerElement,
|
||||
_popoverElement,
|
||||
_setCollisionBoundaryElement,
|
||||
_collisionBoundaryElement,
|
||||
} = context;
|
||||
|
||||
// Set bounding box element for collision detection
|
||||
useEffect(() => {
|
||||
if (!_popoverElement) return;
|
||||
|
||||
// Only set bounding box if it's not already set (to allow collisionBoundary from Positioner to take precedence)
|
||||
if (!_collisionBoundaryElement) {
|
||||
const mediaContainer = _popoverElement.closest('[data-media-container]') as HTMLElement | null;
|
||||
_setCollisionBoundaryElement(mediaContainer);
|
||||
}
|
||||
}, [_popoverElement, _collisionBoundaryElement, _setCollisionBoundaryElement]);
|
||||
|
||||
// Track data attributes from trigger element, updating when element or attributes change
|
||||
const [dataAttrs, setDataAttrs] = useState<Record<string, string> | undefined>(() =>
|
||||
getDataAttributes(_triggerElement),
|
||||
);
|
||||
|
||||
// Update data attributes when trigger element changes
|
||||
useEffect(() => {
|
||||
setDataAttrs(getDataAttributes(_triggerElement));
|
||||
}, [_triggerElement]);
|
||||
|
||||
// Update data attributes when attributes mutate
|
||||
useMutationObserver(
|
||||
_triggerElement,
|
||||
() => {
|
||||
setDataAttrs(getDataAttributes(_triggerElement));
|
||||
},
|
||||
{ attributes: true },
|
||||
);
|
||||
|
||||
return {
|
||||
ref: _setPopoverElement,
|
||||
id: id ?? popupId ?? undefined,
|
||||
className,
|
||||
popover: 'manual' as const,
|
||||
style: {
|
||||
..._popoverStyle,
|
||||
...style,
|
||||
} as React.CSSProperties,
|
||||
...dataAttrs,
|
||||
'data-side': placement,
|
||||
'data-starting-style': _transitionStatus === 'initial' ? '' : undefined,
|
||||
'data-open': _transitionStatus === 'initial' || _transitionStatus === 'open' ? '' : undefined,
|
||||
'data-ending-style': _transitionStatus === 'close' || _transitionStatus === 'unmounted' ? '' : undefined,
|
||||
'data-closed': _transitionStatus === 'close' || _transitionStatus === 'unmounted' ? '' : undefined,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function getDataAttributes(element?: HTMLElement | null): Record<string, string> | undefined {
|
||||
if (!element) return undefined;
|
||||
return Object.fromEntries(
|
||||
Array.from(element.attributes)
|
||||
.filter(attr => attr.name.startsWith('data-'))
|
||||
.map(attr => [attr.name, attr.value]),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderPopoverPopup(props: PopoverPopupRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const PopoverPopup: ConnectedComponent<PopoverPopupProps, typeof renderPopoverPopup> = toContextComponent(
|
||||
usePopoverPopupProps,
|
||||
renderPopoverPopup,
|
||||
'Popover.Popup',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export const Popover = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: PopoverRoot,
|
||||
Trigger: PopoverTrigger,
|
||||
Positioner: PopoverPositioner,
|
||||
Popup: PopoverPopup,
|
||||
},
|
||||
) as {
|
||||
Root: typeof PopoverRoot;
|
||||
Trigger: typeof PopoverTrigger;
|
||||
Positioner: typeof PopoverPositioner;
|
||||
Popup: typeof PopoverPopup;
|
||||
};
|
||||
|
||||
export default Popover;
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { previewTimeDisplayStateDefinition } from '@videojs/core-preview/store';
|
||||
import { formatDisplayTime, shallowEqual } from '@videojs/utils-preview';
|
||||
|
||||
import { useMediaSelector } from '@/store';
|
||||
import { toConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
export function usePreviewTimeDisplayState(_props: any): {
|
||||
previewTime: number;
|
||||
} {
|
||||
/** @TODO Fix type issues with hooks (CJP) */
|
||||
const mediaState = useMediaSelector(previewTimeDisplayStateDefinition.stateTransform, shallowEqual);
|
||||
|
||||
// Preview time display is read-only, no request methods needed
|
||||
return {
|
||||
previewTime: mediaState.previewTime ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type PreviewTimeDisplayState = ReturnType<typeof usePreviewTimeDisplayState>;
|
||||
|
||||
export interface PreviewTimeDisplayProps extends React.ComponentProps<'span'> {
|
||||
showRemaining?: boolean;
|
||||
}
|
||||
|
||||
export function getPreviewTimeDisplayProps(
|
||||
props: PropsWithChildren,
|
||||
_state: ReturnType<typeof usePreviewTimeDisplayState>,
|
||||
): PropsWithChildren<Record<string, unknown>> {
|
||||
const baseProps: Record<string, any> = {
|
||||
/** external props spread last to allow for overriding */
|
||||
...props,
|
||||
};
|
||||
|
||||
return baseProps;
|
||||
}
|
||||
|
||||
export function renderPreviewTimeDisplay(props: PreviewTimeDisplayProps, state: PreviewTimeDisplayState): JSX.Element {
|
||||
const { showRemaining, ...restProps } = props;
|
||||
|
||||
/** @TODO Should this live here or elsewhere? (CJP) */
|
||||
const timeLabel = formatDisplayTime(state.previewTime);
|
||||
|
||||
return <span {...restProps}>{timeLabel}</span>;
|
||||
}
|
||||
|
||||
export const PreviewTimeDisplay: ConnectedComponent<PreviewTimeDisplayProps, typeof renderPreviewTimeDisplay>
|
||||
= toConnectedComponent(
|
||||
usePreviewTimeDisplayState,
|
||||
getPreviewTimeDisplayProps,
|
||||
renderPreviewTimeDisplay,
|
||||
'PreviewTimeDisplay',
|
||||
);
|
||||
|
||||
export default PreviewTimeDisplay;
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Prettify } from '../types';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { TimeSlider as CoreTimeSlider } from '@videojs/core-preview';
|
||||
import { timeSliderStateDefinition } from '@videojs/core-preview/store';
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { useMemo } from 'react';
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
export type TimeSliderState = Prettify<ReturnType<typeof useCore<CoreTimeSlider>>> & {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
};
|
||||
|
||||
export interface TimeSliderProps extends React.ComponentPropsWithRef<'div'> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
interface TimeSliderRenderProps extends React.ComponentProps<'div'> {
|
||||
'data-orientation'?: 'horizontal' | 'vertical';
|
||||
'data-current-time'?: number;
|
||||
'data-duration'?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROOT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function useTimeSliderRootState(props?: TimeSliderProps): TimeSliderState {
|
||||
const { orientation = 'horizontal' } = props ?? {};
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(timeSliderStateDefinition.stateTransform, shallowEqual);
|
||||
const mediaMethods = useMemo(() => timeSliderStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
|
||||
const coreState = useCore(CoreTimeSlider, { ...mediaState, ...mediaMethods });
|
||||
return {
|
||||
...coreState,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimeSliderRootProps(props: TimeSliderProps, state: TimeSliderState): TimeSliderRenderProps {
|
||||
const { children, className, id, style, orientation = 'horizontal', ref } = props;
|
||||
const composedRef = useComposedRefs(ref, state._setRootElement);
|
||||
|
||||
return {
|
||||
ref: composedRef,
|
||||
id,
|
||||
role: 'slider',
|
||||
tabIndex: 0,
|
||||
'aria-label': 'Seek',
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': Math.round(state.duration),
|
||||
'aria-valuenow': Math.round(state.currentTime),
|
||||
'aria-valuetext': `${state._currentTimeText} of ${state._durationText}`,
|
||||
'aria-orientation': orientation,
|
||||
'data-orientation': orientation,
|
||||
'data-current-time': state.currentTime,
|
||||
'data-duration': state.duration,
|
||||
className,
|
||||
style: {
|
||||
...style,
|
||||
'--slider-fill': `${state._fillWidth.toFixed(3)}%`,
|
||||
'--slider-pointer': `${(state._pointerWidth * 100).toFixed(3)}%`,
|
||||
} as React.CSSProperties,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTimeSliderRoot(props: TimeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TimeSliderRoot: ConnectedComponent<TimeSliderProps, typeof renderTimeSliderRoot> = toConnectedComponent(
|
||||
useTimeSliderRootState,
|
||||
useTimeSliderRootProps,
|
||||
renderTimeSliderRoot,
|
||||
'TimeSlider.Root',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// TRACK COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function useTimeSliderTrackProps(props: React.ComponentProps<'div'>, context: TimeSliderState): TimeSliderRenderProps {
|
||||
return {
|
||||
ref: context._setTrackElement,
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'width' : 'height']: '100%',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTimeSliderTrack(props: TimeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TimeSliderTrack: ConnectedComponent<React.ComponentProps<'div'>, typeof renderTimeSliderTrack> = toContextComponent(
|
||||
useTimeSliderTrackProps,
|
||||
renderTimeSliderTrack,
|
||||
'TimeSlider.Track',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// THUMB COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function getTimeSliderThumbProps(props: React.ComponentProps<'div'>, context: TimeSliderState): TimeSliderRenderProps {
|
||||
return {
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'insetInlineStart' : 'insetBlockEnd']: 'var(--slider-fill)',
|
||||
[context.orientation === 'horizontal' ? 'top' : 'left']: '50%',
|
||||
translate: context.orientation === 'horizontal' ? '-50% -50%' : '-50% 50%',
|
||||
position: 'absolute' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTimeSliderThumb(props: TimeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TimeSliderThumb: ConnectedComponent<React.ComponentProps<'div'>, typeof renderTimeSliderThumb> = toContextComponent(
|
||||
getTimeSliderThumbProps,
|
||||
renderTimeSliderThumb,
|
||||
'TimeSlider.Thumb',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// POINTER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function getTimeSliderPointerProps(props: React.ComponentProps<'div'>, context: TimeSliderState): TimeSliderRenderProps {
|
||||
return {
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-pointer, 0%)',
|
||||
[context.orientation === 'horizontal' ? 'height' : 'width']: '100%',
|
||||
position: 'absolute' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTimeSliderPointer(props: TimeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TimeSliderPointer: ConnectedComponent<
|
||||
React.ComponentProps<'div'>,
|
||||
typeof renderTimeSliderPointer
|
||||
> = toContextComponent(getTimeSliderPointerProps, renderTimeSliderPointer, 'TimeSlider.Pointer');
|
||||
|
||||
// ============================================================================
|
||||
// PROGRESS COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function getTimeSliderProgressProps(props: React.ComponentProps<'div'>, context: TimeSliderState): TimeSliderRenderProps {
|
||||
return {
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-fill, 0%)',
|
||||
[context.orientation === 'horizontal' ? 'height' : 'width']: '100%',
|
||||
[context.orientation === 'horizontal' ? 'top' : 'bottom']: '0',
|
||||
position: 'absolute' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTimeSliderProgress(props: TimeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TimeSliderProgress: ConnectedComponent<
|
||||
React.ComponentProps<'div'>,
|
||||
typeof renderTimeSliderProgress
|
||||
> = toContextComponent(getTimeSliderProgressProps, renderTimeSliderProgress, 'TimeSlider.Progress');
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export const TimeSlider = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: TimeSliderRoot,
|
||||
Track: TimeSliderTrack,
|
||||
Thumb: TimeSliderThumb,
|
||||
Pointer: TimeSliderPointer,
|
||||
Progress: TimeSliderProgress,
|
||||
},
|
||||
) as {
|
||||
Root: typeof TimeSliderRoot;
|
||||
Track: typeof TimeSliderTrack;
|
||||
Thumb: typeof TimeSliderThumb;
|
||||
Pointer: typeof TimeSliderPointer;
|
||||
Progress: typeof TimeSliderProgress;
|
||||
};
|
||||
|
||||
export default TimeSlider;
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { TooltipState as CoreTooltipState } from '@videojs/core-preview';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Prettify } from '../types';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
import type { PopoverPopupProps, PopoverPopupRenderProps, PopoverPositionerProps, PopoverRootProps } from './Popover';
|
||||
|
||||
import { Tooltip as CoreTooltip } from '@videojs/core-preview';
|
||||
import { cloneElement, useCallback, useId, useState } from 'react';
|
||||
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
|
||||
import { usePopoverPopupProps, usePopoverPositionerProps, usePopoverTriggerProps } from './Popover';
|
||||
|
||||
type Placement = CoreTooltipState['placement'];
|
||||
|
||||
export type TooltipState = Prettify<
|
||||
CoreTooltipState & {
|
||||
popupId: string | undefined;
|
||||
updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void;
|
||||
}
|
||||
>;
|
||||
|
||||
// ============================================================================
|
||||
// ROOT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TooltipRootProps extends PopoverRootProps {
|
||||
trackCursorAxis?: 'x';
|
||||
}
|
||||
|
||||
export function useTooltipRootState(props: TooltipRootProps): TooltipState {
|
||||
const { delay = 0, closeDelay = 0, trackCursorAxis } = props;
|
||||
const [placement, setPlacement] = useState<Placement>('top');
|
||||
const [sideOffset, setSideOffset] = useState(0);
|
||||
const [collisionPadding, setCollisionPadding] = useState(0);
|
||||
const uniqueId = useId();
|
||||
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
|
||||
|
||||
const coreState = useCore<CoreTooltip>(CoreTooltip, {
|
||||
delay,
|
||||
closeDelay,
|
||||
placement,
|
||||
sideOffset,
|
||||
collisionPadding,
|
||||
trackCursorAxis,
|
||||
});
|
||||
|
||||
const updatePositioning = useCallback(
|
||||
(newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => {
|
||||
setPlacement(newPlacement);
|
||||
setSideOffset(newSideOffset);
|
||||
setCollisionPadding(newCollisionPadding);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...coreState,
|
||||
popupId,
|
||||
updatePositioning,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTooltipRootProps(props: TooltipRootProps, _state: TooltipState): { children: ReactNode } {
|
||||
return {
|
||||
children: props.children,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTooltipRoot(props: { children: ReactNode }): JSX.Element {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
const TooltipRoot: ConnectedComponent<TooltipRootProps, typeof renderTooltipRoot> = toConnectedComponent(
|
||||
useTooltipRootState,
|
||||
useTooltipRootProps,
|
||||
renderTooltipRoot,
|
||||
'Tooltip.Root',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// TRIGGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TooltipTriggerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function useTooltipTriggerProps(
|
||||
props: TooltipTriggerProps,
|
||||
context: TooltipState,
|
||||
): { child: JSX.Element; triggerProps: Record<string, any> } {
|
||||
return usePopoverTriggerProps(props, context);
|
||||
}
|
||||
|
||||
export function renderTooltipTrigger(props: { child: JSX.Element; triggerProps: Record<string, any> }): JSX.Element {
|
||||
// eslint-disable-next-line react/no-clone-element
|
||||
return cloneElement(props.child, props.triggerProps);
|
||||
}
|
||||
|
||||
const TooltipTrigger: ConnectedComponent<TooltipTriggerProps, typeof renderTooltipTrigger> = toContextComponent(
|
||||
useTooltipTriggerProps,
|
||||
renderTooltipTrigger,
|
||||
'Tooltip.Trigger',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// POSITIONER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TooltipPositionerProps extends PopoverPositionerProps {}
|
||||
|
||||
export function useTooltipPositionerProps(
|
||||
props: TooltipPositionerProps,
|
||||
context: TooltipState,
|
||||
): { children: ReactNode } {
|
||||
return usePopoverPositionerProps(props, context);
|
||||
}
|
||||
|
||||
export function renderTooltipPositioner(props: { children: ReactNode }): JSX.Element {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
const TooltipPositioner: ConnectedComponent<TooltipPositionerProps, typeof renderTooltipPositioner>
|
||||
= toContextComponent(useTooltipPositionerProps, renderTooltipPositioner, 'Tooltip.Positioner');
|
||||
|
||||
// ============================================================================
|
||||
// POPUP COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TooltipPopupProps extends PopoverPopupProps {}
|
||||
|
||||
interface TooltipPopupRenderProps extends PopoverPopupRenderProps {}
|
||||
|
||||
export function useTooltipPopupProps(props: TooltipPopupProps, context: TooltipState): TooltipPopupRenderProps {
|
||||
return usePopoverPopupProps(props, context);
|
||||
}
|
||||
|
||||
export function renderTooltipPopup(props: TooltipPopupRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const TooltipPopup: ConnectedComponent<TooltipPopupProps, typeof renderTooltipPopup> = toContextComponent(
|
||||
useTooltipPopupProps,
|
||||
renderTooltipPopup,
|
||||
'Tooltip.Popup',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export const Tooltip = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: TooltipRoot,
|
||||
Trigger: TooltipTrigger,
|
||||
Positioner: TooltipPositioner,
|
||||
Popup: TooltipPopup,
|
||||
},
|
||||
) as {
|
||||
Root: typeof TooltipRoot;
|
||||
Trigger: typeof TooltipTrigger;
|
||||
Positioner: typeof TooltipPositioner;
|
||||
Popup: typeof TooltipPopup;
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
DetailedHTMLProps,
|
||||
PropsWithChildren,
|
||||
VideoHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useMediaRef } from '@/store';
|
||||
|
||||
export type VideoProps = PropsWithChildren<
|
||||
DetailedHTMLProps<VideoHTMLAttributes<HTMLVideoElement>, HTMLVideoElement> & {
|
||||
className?: string | undefined;
|
||||
style?: CSSProperties | undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Video - A basic video component that works with native HTML5 video formats (MP4, WebM, etc.)
|
||||
* without using a playback engine. Use this for simple MP4 files. For HLS/DASH streaming, use the
|
||||
* regular Video component instead.
|
||||
*
|
||||
* This component connects to VideoProvider for play/pause state but sets the src directly on the
|
||||
* video element without going through HLS.js or other playback engines.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <VideoProvider>
|
||||
* <MediaSkin>
|
||||
* <Video src="video.mp4" />
|
||||
* </MediaSkin>
|
||||
* </VideoProvider>
|
||||
* ```
|
||||
*/
|
||||
export const Video: React.ForwardRefExoticComponent<
|
||||
VideoProps & React.RefAttributes<HTMLVideoElement>
|
||||
> = forwardRef<HTMLVideoElement, VideoProps>(
|
||||
({ children, ...props }, _ref) => {
|
||||
const mediaRefCallback = useMediaRef();
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video {...props} ref={mediaRefCallback}>
|
||||
{children}
|
||||
</video>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Video.displayName = 'Video';
|
||||
|
||||
export default Video;
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Prettify } from '../types';
|
||||
import type { ConnectedComponent } from '../utils/component-factory';
|
||||
|
||||
import { VolumeSlider as CoreVolumeSlider } from '@videojs/core-preview';
|
||||
import { volumeSliderStateDefinition } from '@videojs/core-preview/store';
|
||||
import { shallowEqual } from '@videojs/utils-preview';
|
||||
import { useMemo } from 'react';
|
||||
import { useMediaSelector, useMediaStore } from '@/store';
|
||||
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
|
||||
import { useComposedRefs } from '../utils/use-composed-refs';
|
||||
|
||||
export type VolumeSliderState = Prettify<ReturnType<typeof useCore<CoreVolumeSlider>>> & {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
};
|
||||
|
||||
export interface VolumeSliderProps extends React.ComponentPropsWithRef<'div'> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
interface VolumeSliderRenderProps extends React.ComponentProps<'div'> {
|
||||
'data-orientation'?: 'horizontal' | 'vertical';
|
||||
'data-muted'?: boolean;
|
||||
'data-volume-level'?: string;
|
||||
'data-volume'?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROOT COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function useVolumeSliderRootState(props?: VolumeSliderProps): VolumeSliderState {
|
||||
const { orientation = 'horizontal' } = props ?? {};
|
||||
const mediaStore = useMediaStore();
|
||||
const mediaState = useMediaSelector(volumeSliderStateDefinition.stateTransform, shallowEqual);
|
||||
const mediaMethods = useMemo(() => volumeSliderStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
|
||||
const coreState = useCore(CoreVolumeSlider, { ...mediaState, ...mediaMethods });
|
||||
return {
|
||||
...coreState,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
|
||||
export function useVolumeSliderRootProps(props: VolumeSliderProps, state: VolumeSliderState): VolumeSliderRenderProps {
|
||||
const { children, className, id, style, orientation = 'horizontal', ref } = props;
|
||||
const composedRef = useComposedRefs(ref, state._setRootElement);
|
||||
|
||||
return {
|
||||
ref: composedRef,
|
||||
id,
|
||||
role: 'slider',
|
||||
tabIndex: 0,
|
||||
'aria-label': 'Volume',
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
'aria-valuenow': Math.round(state.volume * 100),
|
||||
'aria-valuetext': state._volumeText,
|
||||
'aria-orientation': orientation,
|
||||
'data-orientation': orientation,
|
||||
'data-muted': state.muted,
|
||||
'data-volume-level': state.volumeLevel,
|
||||
'data-volume': state.volume,
|
||||
className,
|
||||
style: {
|
||||
...style,
|
||||
'--slider-fill': `${state._fillWidth.toFixed(3)}%`,
|
||||
'--slider-pointer': `${(state._pointerWidth * 100).toFixed(3)}%`,
|
||||
} as React.CSSProperties,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderVolumeSliderRoot(props: VolumeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const VolumeSliderRoot: ConnectedComponent<VolumeSliderProps, typeof renderVolumeSliderRoot> = toConnectedComponent(
|
||||
useVolumeSliderRootState,
|
||||
useVolumeSliderRootProps,
|
||||
renderVolumeSliderRoot,
|
||||
'VolumeSlider.Root',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// TRACK COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function useVolumeSliderTrackProps(props: React.ComponentProps<'div'>, context: VolumeSliderState): VolumeSliderRenderProps {
|
||||
return {
|
||||
ref: context._setTrackElement,
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'width' : 'height']: '100%',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderVolumeSliderTrack(props: VolumeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const VolumeSliderTrack: ConnectedComponent<
|
||||
React.ComponentProps<'div'>,
|
||||
typeof renderVolumeSliderTrack
|
||||
> = toContextComponent(useVolumeSliderTrackProps, renderVolumeSliderTrack, 'VolumeSlider.Track');
|
||||
|
||||
// ============================================================================
|
||||
// THUMB COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function getVolumeSliderThumbProps(props: React.ComponentProps<'div'>, context: VolumeSliderState): VolumeSliderRenderProps {
|
||||
return {
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'insetInlineStart' : 'insetBlockEnd']: 'var(--slider-fill)',
|
||||
[context.orientation === 'horizontal' ? 'top' : 'left']: '50%',
|
||||
translate: context.orientation === 'horizontal' ? '-50% -50%' : '-50% 50%',
|
||||
position: 'absolute' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderVolumeSliderThumb(props: VolumeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const VolumeSliderThumb: ConnectedComponent<
|
||||
React.ComponentProps<'div'>,
|
||||
typeof renderVolumeSliderThumb
|
||||
> = toContextComponent(getVolumeSliderThumbProps, renderVolumeSliderThumb, 'VolumeSlider.Thumb');
|
||||
|
||||
// ============================================================================
|
||||
// PROGRESS COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function getVolumeSliderProgressProps(props: React.ComponentProps<'div'>, context: VolumeSliderState): VolumeSliderRenderProps {
|
||||
return {
|
||||
'data-orientation': context.orientation,
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
[context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-fill, 0%)',
|
||||
[context.orientation === 'horizontal' ? 'height' : 'width']: '100%',
|
||||
[context.orientation === 'horizontal' ? 'top' : 'bottom']: '0',
|
||||
position: 'absolute' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderVolumeSliderProgress(props: VolumeSliderRenderProps): JSX.Element {
|
||||
return <div {...props} />;
|
||||
}
|
||||
|
||||
const VolumeSliderProgress: ConnectedComponent<
|
||||
React.ComponentProps<'div'>,
|
||||
typeof renderVolumeSliderProgress
|
||||
> = toContextComponent(getVolumeSliderProgressProps, renderVolumeSliderProgress, 'VolumeSlider.Progress');
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export const VolumeSlider = Object.assign(
|
||||
{},
|
||||
{
|
||||
Root: VolumeSliderRoot,
|
||||
Track: VolumeSliderTrack,
|
||||
Thumb: VolumeSliderThumb,
|
||||
Progress: VolumeSliderProgress,
|
||||
},
|
||||
) as {
|
||||
Root: typeof VolumeSliderRoot;
|
||||
Track: typeof VolumeSliderTrack;
|
||||
Thumb: typeof VolumeSliderThumb;
|
||||
Progress: typeof VolumeSliderProgress;
|
||||
};
|
||||
|
||||
export default VolumeSlider;
|
||||
Reference in New Issue
Block a user