diff --git a/packages/core/core/src/components/time-slider.ts b/packages/core/core/src/components/time-slider.ts index a814ce26..a83e6a15 100644 --- a/packages/core/core/src/components/time-slider.ts +++ b/packages/core/core/src/components/time-slider.ts @@ -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); } } diff --git a/packages/core/media-store/src/component-state-definitions/preview-time-display.ts b/packages/core/media-store/src/component-state-definitions/preview-time-display.ts new file mode 100644 index 00000000..2e6a0428 --- /dev/null +++ b/packages/core/media-store/src/component-state-definitions/preview-time-display.ts @@ -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, + }), +}; diff --git a/packages/core/media-store/src/component-state-definitions/time-slider.ts b/packages/core/media-store/src/component-state-definitions/time-slider.ts index 7a538b39..eb07ee0a 100644 --- a/packages/core/media-store/src/component-state-definitions/time-slider.ts +++ b/packages/core/media-store/src/component-state-definitions/time-slider.ts @@ -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 }); + }, }), }; diff --git a/packages/core/media-store/src/factory.ts b/packages/core/media-store/src/factory.ts index b4b81a3d..68909d56 100644 --- a/packages/core/media-store/src/factory.ts +++ b/packages/core/media-store/src/factory.ts @@ -41,6 +41,7 @@ export interface StateMediator { currentTime: FacadeProp; duration: ReadonlyFacadeProp; seekable: ReadonlyFacadeProp<[number, number] | undefined>; + previewTime: FacadeProp; fullscreen: FacadeProp; } @@ -58,7 +59,7 @@ export function createMediaStore({ }: { media?: any; container?: any; - stateMediator: Partial & Pick; + stateMediator: Partial; }): MediaStore { const stateOwners: StateOwners = {}; const store = map({}); @@ -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 => 'set' in stateMediatorEntry, - )) { - const { set, actions } = stateObject; - if (actions[type]) { + Object.entries(stateMediator).forEach(([stateName, stateObject]) => { + const { set, actions } = stateObject as FacadeProp; + + if (actions?.[type]) { const actionFn = actions[type]; const actionValue = actionFn(action); - (set as FacadeSetter)(actionValue, stateOwners); + if (set) { + (set as FacadeSetter)(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 & Pick, stateOwners: any) { +function getInitialState(stateMediator: Partial, stateOwners: any) { const initialState: any = {}; for (const [stateName, { get }] of Object.entries(stateMediator)) { + if (!get) continue; initialState[stateName] = get(stateOwners); } return initialState; diff --git a/packages/core/media-store/src/index.ts b/packages/core/media-store/src/index.ts index 786b8075..9c9e9c99 100644 --- a/packages/core/media-store/src/index.ts +++ b/packages/core/media-store/src/index.ts @@ -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'; diff --git a/packages/core/media-store/src/media-store.ts b/packages/core/media-store/src/media-store.ts index b8e5eb11..c6f8a2ee 100644 --- a/packages/core/media-store/src/media-store.ts +++ b/packages/core/media-store/src/media-store.ts @@ -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[0]>; diff --git a/packages/core/media-store/src/state-mediators/index.ts b/packages/core/media-store/src/state-mediators/index.ts index e0348ad3..92c41218 100644 --- a/packages/core/media-store/src/state-mediators/index.ts +++ b/packages/core/media-store/src/state-mediators/index.ts @@ -1,4 +1,5 @@ export { audible } from './audible'; export { fullscreenable } from './fullscreenable'; export { playable } from './playable'; +export { preview } from './preview'; export { temporal } from './temporal'; diff --git a/packages/core/media-store/src/state-mediators/preview.ts b/packages/core/media-store/src/state-mediators/preview.ts new file mode 100644 index 00000000..e0958275 --- /dev/null +++ b/packages/core/media-store/src/state-mediators/preview.ts @@ -0,0 +1,7 @@ +export const preview = { + previewTime: { + actions: { + previewrequest: ({ detail }: Pick, 'detail'> = { detail: 0 }): number => +detail, + }, + }, +}; diff --git a/packages/react/react/src/components/CurrentTimeDisplay.tsx b/packages/react/react/src/components/CurrentTimeDisplay.tsx index ee89f588..42229b34 100644 --- a/packages/react/react/src/components/CurrentTimeDisplay.tsx +++ b/packages/react/react/src/components/CurrentTimeDisplay.tsx @@ -20,8 +20,11 @@ export function useCurrentTimeDisplayState(_props: any): { }; } -export type useCurrentTimeDisplayState = typeof useCurrentTimeDisplayState; -export type CurrentTimeDisplayState = ReturnType; +export type CurrentTimeDisplayState = ReturnType; + +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; - export function renderCurrentTimeDisplay(props: CurrentTimeDisplayProps, state: CurrentTimeDisplayState): JSX.Element { const { showRemaining, ...restProps } = props; @@ -50,8 +50,6 @@ export function renderCurrentTimeDisplay(props: CurrentTimeDisplayProps, state: return {timeLabel}; } -export type renderCurrentTimeDisplay = typeof renderCurrentTimeDisplay; - export const CurrentTimeDisplay: ConnectedComponent = toConnectedComponent( useCurrentTimeDisplayState, diff --git a/packages/react/react/src/components/PreviewTimeDisplay.tsx b/packages/react/react/src/components/PreviewTimeDisplay.tsx new file mode 100644 index 00000000..538b98b4 --- /dev/null +++ b/packages/react/react/src/components/PreviewTimeDisplay.tsx @@ -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; + +export interface PreviewTimeDisplayProps extends React.ComponentProps<'span'> { + showRemaining?: boolean; +} + +export function getPreviewTimeDisplayProps( + props: PropsWithChildren, + _state: ReturnType, +): PropsWithChildren> { + const baseProps: Record = { + /** 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 {timeLabel}; +} + +export const PreviewTimeDisplay: ConnectedComponent + = toConnectedComponent( + usePreviewTimeDisplayState, + getPreviewTimeDisplayProps, + renderPreviewTimeDisplay, + 'PreviewTimeDisplay', + ); + +export default PreviewTimeDisplay; diff --git a/packages/react/react/src/components/TimeSlider.tsx b/packages/react/react/src/components/TimeSlider.tsx index 1eaacb61..920dd387 100644 --- a/packages/react/react/src/components/TimeSlider.tsx +++ b/packages/react/react/src/components/TimeSlider.tsx @@ -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: { diff --git a/packages/react/react/src/components/Tooltip.tsx b/packages/react/react/src/components/Tooltip.tsx index 35457957..cfc60273 100644 --- a/packages/react/react/src/components/Tooltip.tsx +++ b/packages/react/react/src/components/Tooltip.tsx @@ -8,6 +8,7 @@ import { FloatingPortal, offset, shift, + useClientPoint, useDismiss, useFloating, useFocus, @@ -42,12 +43,14 @@ interface TooltipContextType { getReferenceProps: ReturnType['getReferenceProps']; getFloatingProps: ReturnType['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('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 {children}; } @@ -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 ( -
+
{children}
@@ -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-')) diff --git a/packages/react/react/src/index.ts b/packages/react/react/src/index.ts index 4f43cfe5..f9c8314d 100644 --- a/packages/react/react/src/index.ts +++ b/packages/react/react/src/index.ts @@ -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'; diff --git a/packages/react/react/src/skins/default/MediaSkinDefault.tsx b/packages/react/react/src/skins/default/MediaSkinDefault.tsx index e03c930e..5f1639ea 100644 --- a/packages/react/react/src/skins/default/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/default/MediaSkinDefault.tsx @@ -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