feat: add a solution for React preview time display (#50)

This commit is contained in:
Wesley Luyten
2025-10-13 13:07:10 -05:00
committed by GitHub
parent 75a54e9b5e
commit f78b09fd16
16 changed files with 215 additions and 59 deletions
@@ -6,6 +6,7 @@ export interface TimeSliderState extends SliderState {
currentTime: number;
duration: number;
requestSeek: (time: number) => void;
requestPreview: (time: number) => void;
_currentTimeText: string;
_durationText: string;
}
@@ -70,10 +71,13 @@ export class TimeSlider extends Slider {
#handlePointerMove(event: PointerEvent) {
super.handleEvent(event);
const { _dragging, _pointerRatio, duration, requestSeek } = super.getState() as TimeSliderState;
const { _dragging, _pointerRatio, duration, requestSeek, requestPreview } = super.getState() as TimeSliderState;
const previewTime = _pointerRatio * duration;
requestPreview(previewTime);
if (_dragging) {
this.#seekingTime = _pointerRatio * duration;
this.#seekingTime = previewTime;
requestSeek(this.#seekingTime);
}
}
@@ -0,0 +1,36 @@
/**
* @fileoverview Preview time display component state definition
*
* This module provides the component state definition for preview time display
* components across HTML, React, and React Native platforms. The preview time
* display is a read-only component that shows the preview time when hovering
* over the time slider.
*/
/**
* State interface for preview time display components
*/
export interface PreviewTimeDisplayState {
/** The preview time value in seconds */
previewTime: number | undefined;
}
/**
* Preview time display component state definition following VJS-10 patterns.
* This provides a read-only display component that shows the preview time.
*/
export interface PreviewTimeDisplayStateDefinition {
keys: (keyof PreviewTimeDisplayState)[];
stateTransform: (rawState: any) => PreviewTimeDisplayState;
}
/**
* Preview time display state definition
* Defines the core state logic that can be shared between implementations
*/
export const previewTimeDisplayStateDefinition: PreviewTimeDisplayStateDefinition = {
keys: ['previewTime'],
stateTransform: (rawState: any) => ({
previewTime: rawState.previewTime ?? 0,
}),
};
@@ -1,10 +1,12 @@
export interface TimeSliderState {
currentTime: number;
duration: number;
previewTime: number;
}
export interface TimeSliderMethods {
requestSeek: (time: number) => void;
requestPreview: (time: number) => void;
}
export interface TimeSliderStateDefinition {
@@ -18,14 +20,18 @@ export interface TimeSliderStateDefinition {
* Defines the core state logic that can be shared between implementations
*/
export const timeSliderStateDefinition: TimeSliderStateDefinition = {
keys: ['currentTime', 'duration'],
keys: ['currentTime', 'duration', 'previewTime'],
stateTransform: (rawState: any) => ({
currentTime: rawState.currentTime ?? 0,
duration: rawState.duration ?? 0,
previewTime: rawState.previewTime ?? 0,
}),
createRequestMethods: dispatch => ({
requestSeek: (time: number) => {
dispatch({ type: 'seekrequest', detail: time });
},
requestPreview: (time: number) => {
dispatch({ type: 'previewrequest', detail: time });
},
}),
};
+16 -9
View File
@@ -41,6 +41,7 @@ export interface StateMediator {
currentTime: FacadeProp<HTMLMediaElement['currentTime']>;
duration: ReadonlyFacadeProp<HTMLMediaElement['duration']>;
seekable: ReadonlyFacadeProp<[number, number] | undefined>;
previewTime: FacadeProp<number>;
fullscreen: FacadeProp<boolean>;
}
@@ -58,7 +59,7 @@ export function createMediaStore({
}: {
media?: any;
container?: any;
stateMediator: Partial<StateMediator> & Pick<StateMediator, 'paused'>;
stateMediator: Partial<StateMediator>;
}): MediaStore {
const stateOwners: StateOwners = {};
const store = map<any>({});
@@ -117,16 +118,21 @@ export function createMediaStore({
} else if (type === 'containerstateownerchangerequest') {
updateStateOwners({ container: detail });
} else {
for (const stateObject of Object.values(stateMediator).filter(
(stateMediatorEntry): stateMediatorEntry is FacadeProp<any, any, any> => 'set' in stateMediatorEntry,
)) {
const { set, actions } = stateObject;
if (actions[type]) {
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
const { set, actions } = stateObject as FacadeProp<any, any, any>;
if (actions?.[type]) {
const actionFn = actions[type];
const actionValue = actionFn(action);
(set as FacadeSetter<any>)(actionValue, stateOwners);
if (set) {
(set as FacadeSetter<any>)(actionValue, stateOwners);
} else {
// If there is no setter, store the action value in the store.
// Might need revisiting.
store.setKey(stateName, actionValue);
}
}
}
});
}
},
@@ -155,9 +161,10 @@ export function createMediaStore({
};
}
function getInitialState(stateMediator: Partial<StateMediator> & Pick<StateMediator, 'paused'>, stateOwners: any) {
function getInitialState(stateMediator: Partial<StateMediator>, stateOwners: any) {
const initialState: any = {};
for (const [stateName, { get }] of Object.entries(stateMediator)) {
if (!get) continue;
initialState[stateName] = get(stateOwners);
}
return initialState;
+1
View File
@@ -3,6 +3,7 @@ export * from './component-state-definitions/duration-display';
export * from './component-state-definitions/fullscreen-button';
export * from './component-state-definitions/mute-button';
export * from './component-state-definitions/play-button';
export * from './component-state-definitions/preview-time-display';
export * from './component-state-definitions/time-slider';
export * from './component-state-definitions/volume-slider';
export * from './factory';
+2 -1
View File
@@ -4,11 +4,12 @@ import { createMediaStore as factory } from './factory';
import { audible } from './state-mediators/audible';
import { fullscreenable } from './state-mediators/fullscreenable';
import { playable } from './state-mediators/playable';
import { preview } from './state-mediators/preview';
import { temporal } from './state-mediators/temporal';
// Example of default media store with default state mediator definitions. (CJP)
// NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP)
const stateMediator = { ...playable, ...audible, ...temporal, ...fullscreenable };
const stateMediator = { ...playable, ...audible, ...temporal, ...fullscreenable, ...preview };
type Params = Partial<Parameters<typeof factory>[0]>;
@@ -1,4 +1,5 @@
export { audible } from './audible';
export { fullscreenable } from './fullscreenable';
export { playable } from './playable';
export { preview } from './preview';
export { temporal } from './temporal';
@@ -0,0 +1,7 @@
export const preview = {
previewTime: {
actions: {
previewrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
},
},
};
@@ -20,8 +20,11 @@ export function useCurrentTimeDisplayState(_props: any): {
};
}
export type useCurrentTimeDisplayState = typeof useCurrentTimeDisplayState;
export type CurrentTimeDisplayState = ReturnType<useCurrentTimeDisplayState>;
export type CurrentTimeDisplayState = ReturnType<typeof useCurrentTimeDisplayState>;
export interface CurrentTimeDisplayProps extends React.ComponentProps<'span'> {
showRemaining?: boolean;
}
export function useCurrentTimeDisplayProps(
props: PropsWithChildren,
@@ -35,9 +38,6 @@ export function useCurrentTimeDisplayProps(
return baseProps;
}
export type useCurrentTimeDisplayProps = typeof useCurrentTimeDisplayProps;
type CurrentTimeDisplayProps = ReturnType<useCurrentTimeDisplayProps>;
export function renderCurrentTimeDisplay(props: CurrentTimeDisplayProps, state: CurrentTimeDisplayState): JSX.Element {
const { showRemaining, ...restProps } = props;
@@ -50,8 +50,6 @@ export function renderCurrentTimeDisplay(props: CurrentTimeDisplayProps, state:
return <span {...restProps}>{timeLabel}</span>;
}
export type renderCurrentTimeDisplay = typeof renderCurrentTimeDisplay;
export const CurrentTimeDisplay: ConnectedComponent<CurrentTimeDisplayProps, typeof renderCurrentTimeDisplay>
= toConnectedComponent(
useCurrentTimeDisplayState,
@@ -0,0 +1,56 @@
import type { PropsWithChildren } from 'react';
import type { ConnectedComponent } from '../utils/component-factory';
import { formatDisplayTime, previewTimeDisplayStateDefinition } from '@vjs-10/media-store';
import { shallowEqual, useMediaSelector } from '@vjs-10/react-media-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;
@@ -7,6 +7,7 @@ import { shallowEqual, useMediaSelector, useMediaStore } from '@vjs-10/react-med
import { useCallback, useMemo } from 'react';
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
import { useComposedRefs } from '../utils/useComposedRefs';
export interface TimeSliderState {
currentTime: number;
@@ -16,7 +17,7 @@ export interface TimeSliderState {
orientation: 'horizontal' | 'vertical';
}
export interface TimeSliderProps extends React.ComponentProps<'div'> {
export interface TimeSliderProps extends React.ComponentPropsWithRef<'div'> {
orientation?: 'horizontal' | 'vertical';
}
@@ -48,13 +49,17 @@ export function useTimeSliderRootState(props: TimeSliderProps): TimeSliderState
export function useTimeSliderRootProps(props: TimeSliderProps, state: TimeSliderState): TimeSliderRenderProps {
const { _fillWidth, _pointerWidth, _currentTimeText, _durationText } = state.core.getState();
const { children, className, id, style, orientation = 'horizontal' } = props;
const { children, className, id, style, orientation = 'horizontal', ref } = props;
const internalRef = useCallback((el: HTMLDivElement) => {
if (!el) return;
state.core?.attach(el);
}, [state.core]);
const composedRef = useComposedRefs(ref, internalRef);
return {
ref: useCallback((el: HTMLDivElement) => {
if (!el) return;
state.core?.attach(el);
}, []),
ref: composedRef,
id,
role: 'slider',
'aria-label': 'Seek',
@@ -95,7 +100,7 @@ export function useTimeSliderTrackProps(props: React.ComponentProps<'div'>, cont
return {
ref: useCallback((el: HTMLDivElement) => {
context.core?.setState({ _trackElement: el });
}, []),
}, [context.core]),
'data-orientation': context.orientation,
...props,
style: {
@@ -8,6 +8,7 @@ import {
FloatingPortal,
offset,
shift,
useClientPoint,
useDismiss,
useFloating,
useFocus,
@@ -42,12 +43,14 @@ interface TooltipContextType {
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps'];
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps'];
updatePositioning: (props: UpdatePositioningProps) => void;
trackCursorAxis?: 'x' | 'y' | 'both' | undefined;
}
interface TooltipRootProps {
delay?: number;
closeDelay?: number;
children: ReactNode;
trackCursorAxis?: 'x' | 'y' | 'both';
}
interface TooltipTriggerProps {
@@ -101,7 +104,7 @@ function useTooltipPositionerContext(): TooltipPositionerContextType {
return context;
}
function TooltipRoot({ delay = 600, closeDelay = 0, children }: TooltipRootProps): JSX.Element {
function TooltipRoot({ delay = 0, closeDelay = 0, trackCursorAxis, children }: TooltipRootProps): JSX.Element {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<Placement>('top');
const [sideOffset, setSideOffset] = useState(0);
@@ -136,7 +139,18 @@ function TooltipRoot({ delay = 600, closeDelay = 0, children }: TooltipRootProps
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
// Use client point hook when trackCursorAxis is enabled
const clientPoint = useClientPoint(context, {
axis: trackCursorAxis || 'both',
enabled: !!trackCursorAxis,
});
// Combine interactions based on whether cursor tracking is enabled
const interactions = trackCursorAxis
? [hover, focus, dismiss, role, clientPoint]
: [hover, focus, dismiss, role];
const { getReferenceProps, getFloatingProps } = useInteractions(interactions);
const updatePositioning = useCallback(({ side, sideOffset, collisionPadding }: UpdatePositioningProps) => {
setPlacement(side);
@@ -151,7 +165,9 @@ function TooltipRoot({ delay = 600, closeDelay = 0, children }: TooltipRootProps
updatePositioning,
arrowRef,
transitionStatus,
}), [getReferenceProps, getFloatingProps, context, updatePositioning, transitionStatus]);
trackCursorAxis,
};
}), [getReferenceProps, getFloatingProps, context, updatePositioning, transitionStatus, trackCursorAxis]);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
@@ -174,7 +190,7 @@ function TooltipPositioner({
collisionPadding = 0,
children,
}: TooltipPositionerProps): JSX.Element | null {
const { context, updatePositioning } = useTooltipContext();
const { context, updatePositioning, trackCursorAxis } = useTooltipContext();
const { refs, floatingStyles } = context;
// Update positioning when props change
@@ -188,7 +204,13 @@ function TooltipPositioner({
return (
<TooltipPositionerContext.Provider value={positionerContextValue}>
<div ref={refs.setFloating} style={floatingStyles}>
<div
ref={refs.setFloating}
style={{
...floatingStyles,
pointerEvents: trackCursorAxis ? 'none' : undefined,
}}
>
{children}
</div>
</TooltipPositionerContext.Provider>
@@ -201,7 +223,7 @@ function TooltipPopup({ className = '', children }: TooltipPopupProps): JSX.Elem
const triggerElement = refs.reference.current as HTMLElement | null;
// Copy data attributes from trigger element
const dataAttributes = triggerElement
const dataAttributes = triggerElement?.attributes
? Object.fromEntries(
Array.from(triggerElement.attributes)
.filter(attr => attr.name.startsWith('data-'))
+1
View File
@@ -5,6 +5,7 @@ export { MediaContainer, useMediaContainerRef } from './components/MediaContaine
export { MuteButton } from './components/MuteButton';
export { PlayButton } from './components/PlayButton';
export { Popover } from './components/Popover';
export { PreviewTimeDisplay } from './components/PreviewTimeDisplay';
export { TimeSlider } from './components/TimeSlider';
export { Tooltip } from './components/Tooltip';
export { MediaElementVideo, Video } from './components/Video';
@@ -11,13 +11,13 @@ import {
} from '@vjs-10/react-icons';
import { CurrentTimeDisplay } from '../../components/CurrentTimeDisplay';
import { DurationDisplay } from '../../components/DurationDisplay';
import { FullscreenButton } from '../../components/FullscreenButton';
import { MediaContainer } from '../../components/MediaContainer';
import MuteButton from '../../components/MuteButton';
import PlayButton from '../../components/PlayButton';
import { Popover } from '../../components/Popover';
import { PreviewTimeDisplay } from '../../components/PreviewTimeDisplay';
import { TimeSlider } from '../../components/TimeSlider';
import { Tooltip } from '../../components/Tooltip';
import { VolumeSlider } from '../../components/VolumeSlider';
@@ -36,7 +36,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
<div className={styles.Overlay} aria-hidden="true" />
<div className={styles.Controls} data-testid="media-controls">
<Tooltip.Root delay={500} closeDelay={0}>
<Tooltip.Root delay={500}>
<Tooltip.Trigger>
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={`${styles.PlayIcon} ${styles.Icon}`} />
@@ -60,13 +60,24 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
className={styles.TimeDisplay}
/>
<TimeSlider.Root className={styles.SliderRoot}>
<TimeSlider.Track className={styles.SliderTrack}>
<TimeSlider.Progress className={styles.SliderProgress} />
<TimeSlider.Pointer className={styles.SliderPointer} />
</TimeSlider.Track>
<TimeSlider.Thumb className={styles.SliderThumb} />
</TimeSlider.Root>
<Tooltip.Root trackCursorAxis="x">
<Tooltip.Trigger>
<TimeSlider.Root className={styles.SliderRoot}>
<TimeSlider.Track className={styles.SliderTrack}>
<TimeSlider.Progress className={styles.SliderProgress} />
<TimeSlider.Pointer className={styles.SliderPointer} />
</TimeSlider.Track>
<TimeSlider.Thumb className={styles.SliderThumb} />
</TimeSlider.Root>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Positioner side="top" sideOffset={18} collisionPadding={12}>
<Tooltip.Popup className={`${styles.TooltipPopup}`}>
<PreviewTimeDisplay />
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
<DurationDisplay className={styles.TimeDisplay} />
</div>
@@ -93,7 +104,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
</Popover.Portal>
</Popover.Root>
<Tooltip.Root delay={500} closeDelay={0}>
<Tooltip.Root delay={500}>
<Tooltip.Trigger>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullscreenButton}`}>
<FullscreenEnterIcon className={`${styles.FullscreenEnterIcon} ${styles.Icon}`} />
@@ -143,7 +143,7 @@ const styles: MediaDefaultSkinStyles = {
),
SliderProgress: cn('bg-white rounded-[inherit]'),
// TODO: Work out what we want to do here.
SliderPointer: cn('rounded-[inherit]'),
SliderPointer: cn('bg-white/20 rounded-[inherit]'),
SliderThumb: cn(
'bg-white z-10 select-none ring ring-black/10 rounded-full shadow-sm shadow-black/15',
'opacity-0 transition-[opacity,height,width] ease-in-out',
@@ -1,4 +1,4 @@
import type { FC, ReactElement } from 'react';
import type { ReactElement } from 'react';
import { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useSyncExternalStore } from 'react';
@@ -34,13 +34,12 @@ export function toConnectedComponent<
defaultRender: TRenderFn,
displayName: string,
): ConnectedComponent<TProps, TRenderFn> {
const ConnectedComponent = forwardRef<any, TProps & { render?: TRenderFn }>(
const ConnectedComponent = forwardRef<HTMLElement, TProps & { render?: TRenderFn }>(
({ render = defaultRender, ...props }, ref) => {
const connectedState = useStateHook(props as TProps);
const connectedProps = usePropsHook(props as TProps, connectedState);
// Add ref to connectedProps if it exists
const propsWithRef = ref ? { ...connectedProps, ref } : connectedProps;
return <Context.Provider value={connectedState}>{render(propsWithRef, connectedState)}</Context.Provider>;
const propsWithRef = ref ? { ...props, ref } : props;
const connectedState = useStateHook(propsWithRef as unknown as TProps);
const connectedProps = usePropsHook(propsWithRef as unknown as TProps, connectedState);
return <Context.Provider value={connectedState}>{render(connectedProps, connectedState)}</Context.Provider>;
},
);
@@ -52,8 +51,8 @@ export function toConnectedComponent<
/**
* Type helper to infer the component type from the factory
*/
export type ConnectedComponent<TProps extends Record<string, any>, TRenderFn extends RenderFn<any, any>> = FC<
TProps & { render?: TRenderFn }
export type ConnectedComponent<TProps extends Record<string, any>, TRenderFn extends RenderFn<any, any>> = React.ForwardRefExoticComponent<
React.PropsWithoutRef<TProps & { render?: TRenderFn }> & React.RefAttributes<HTMLElement>
>;
/**
@@ -74,13 +73,12 @@ export function toContextComponent<
defaultRender: TRenderFn,
displayName: string,
): ContextComponent<TProps, TRenderFn> {
const ContextComponent = forwardRef<any, TProps & { render?: TRenderFn }>(
const ContextComponent = forwardRef<HTMLElement, TProps & { render?: TRenderFn }>(
({ render = defaultRender, ...props }, ref) => {
const context = useContext(Context);
const contextProps = usePropsHook(props as TProps, context);
// Add ref to contextProps if it exists
const propsWithRef = ref ? { ...contextProps, ref } : contextProps;
return render(propsWithRef, context);
const propsWithRef = ref ? { ...props, ref } : props;
const contextProps = usePropsHook(propsWithRef as unknown as TProps, context);
return render(contextProps, context);
},
);
@@ -95,7 +93,9 @@ export function toContextComponent<
export type ContextComponent<
TProps extends Record<string, any>,
TRenderFn extends (props: any, context: any) => ReactElement,
> = FC<TProps & { render?: TRenderFn }>;
> = React.ForwardRefExoticComponent<
React.PropsWithoutRef<TProps & { render?: TRenderFn }> & React.RefAttributes<any>
>;
/**
* Hook that manages a CoreClass instance and triggers re-renders when state changes.