chore(root): prepare workspace for alpha (#276)

This commit is contained in:
rahim
2025-12-21 20:26:13 +11:00
committed by GitHub
parent 5ddf02278d
commit b89fef56dd
247 changed files with 1197 additions and 446 deletions
@@ -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;