feat(core): add user activity logic (#278)

This commit is contained in:
Sam Potts
2025-12-22 09:27:27 +11:00
committed by GitHub
parent b89fef56dd
commit b5a3f551f0
14 changed files with 406 additions and 199 deletions
@@ -41,6 +41,7 @@ export function getFullscreenButtonProps(
'aria-label': state.fullscreen ? 'exit fullscreen' : 'enter fullscreen',
/** tooltip */
'data-tooltip': state.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
'data-button': 'fullscreen',
/** external props spread last to allow for overriding */
...props,
};
@@ -1,12 +1,24 @@
import type { FC, HTMLProps, PropsWithChildren, RefCallback } from 'react';
import type { FC, HTMLProps, PointerEventHandler, PropsWithChildren, RefCallback } from 'react';
import { playButtonStateDefinition } from '@videojs/core-preview/store';
import { shallowEqual } from '@videojs/utils-preview';
import { forwardRef, useCallback, useMemo } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import { useMediaSelector, useMediaStore } from '@/store';
import { useComposedRefs } from '../utils/use-composed-refs';
export interface MediaContainerProps extends PropsWithChildren<HTMLProps<HTMLDivElement>> {
/**
* Time in milliseconds before the controls autohide. -1 to disable autohide.
* @default 2000
*/
autohide?: number;
/**
* Whether to autohide the controls when hovering over them.
* @default false
*/
autohideOverControls?: boolean;
}
/**
* 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.
@@ -54,16 +66,32 @@ export function useMediaContainerRef(): RefCallback<HTMLElement | null> {
* </MediaContainer>
* );
*/
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = forwardRef(
({ children, ...props }, ref) => {
export const MediaContainer: FC<MediaContainerProps> = forwardRef(
({ children, autohide = 2000, autohideOverControls = false, ...props }, ref) => {
const containerRef = useMediaContainerRef();
const composedRef = useComposedRefs(ref, containerRef);
const internalRef = useRef<HTMLDivElement | null>(null);
const composedRef = useComposedRefs(ref, containerRef, internalRef);
const mediaStore = useMediaStore();
const mediaState = useMediaSelector(playButtonStateDefinition.stateTransform, shallowEqual);
const methods = useMemo(() => playButtonStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const [isUserActive, setIsUserActive] = useState(true);
const [pointerDownTimeStamp, setPointerDownTimeStamp] = useState(0);
const inactiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const getMediaElement = useCallback((): HTMLMediaElement | null => {
const media = internalRef.current?.querySelector('video, audio');
if (media && (media instanceof HTMLMediaElement)) {
return media;
}
return null;
}, []);
const handleClick: PointerEventHandler<HTMLDivElement> = useCallback((event) => {
// Ignore clicks from touch/pen devices
if (navigator.maxTouchPoints > 0 || event.nativeEvent.pointerType !== 'mouse') return;
// Ignore clicks not on media elements
if (!['video', 'audio'].includes((event.target as HTMLElement).localName || '')) return;
if (mediaState.paused) {
@@ -73,12 +101,84 @@ export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> =
}
}, [mediaState.paused, methods]);
const setUserInactive = useCallback(() => {
if (autohide < 0 || !internalRef.current) return;
setIsUserActive(false);
}, [autohide]);
const scheduleUserInactive = useCallback(() => {
setIsUserActive(true);
clearTimeout(inactiveTimeoutRef.current);
// Setting autohide to -1 turns off autohide
if (autohide < 0) return;
inactiveTimeoutRef.current = setTimeout(() => {
setUserInactive();
}, autohide);
}, [autohide, setUserInactive]);
const handlePointerMove: PointerEventHandler<HTMLDivElement> = useCallback((event) => {
if (event.nativeEvent.pointerType !== 'mouse') {
// On mobile we toggle the controls on a tap which is handled in pointerup,
// but Android fires pointermove events even when the user is just tapping.
// Prevent calling setActive() on tap because it will mess with the toggle logic.
const MAX_TAP_DURATION = 250;
// If the move duration exceeds 250ms then it's a drag and we should show the controls.
if (event.timeStamp - pointerDownTimeStamp < MAX_TAP_DURATION) return;
}
setIsUserActive(true);
// Stay visible if hovered over control bar
clearTimeout(inactiveTimeoutRef.current);
const media = getMediaElement();
// If hovering over something other than controls, we're free to make inactive
if ([internalRef.current, media].includes(event.target as HTMLMediaElement) || autohideOverControls) {
scheduleUserInactive();
}
}, [autohideOverControls, getMediaElement, pointerDownTimeStamp, scheduleUserInactive]);
const handlePointerUp: PointerEventHandler<HTMLDivElement> = useCallback((event) => {
if (navigator.maxTouchPoints > 0 || event.nativeEvent.pointerType !== 'mouse') {
const media = getMediaElement();
if (
[internalRef.current, media].includes(event.target as HTMLMediaElement)
&& isUserActive
) {
setIsUserActive(false);
} else {
scheduleUserInactive();
}
} else if (
event.nativeEvent
.composedPath()
.some((element) => {
if (!(element instanceof HTMLElement)) return false;
const type = element.getAttribute('data-button');
if (!type) return false;
return ['play', 'fullscreen'].includes(type);
},
)
) {
scheduleUserInactive();
}
}, [getMediaElement, isUserActive, scheduleUserInactive]);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={composedRef}
onClick={handleClick}
onPointerMove={handlePointerMove}
onPointerDown={event => setPointerDownTimeStamp(event.timeStamp)}
onPointerUp={handlePointerUp}
onMouseLeave={() => setUserInactive()}
onKeyUp={() => scheduleUserInactive()}
data-media-container
data-controls={isUserActive || mediaState.paused ? 'visible' : 'hidden'}
{...props}
>
{children}
@@ -42,6 +42,7 @@ export function getMuteButtonProps(
'aria-label': state.muted ? 'unmute' : 'mute',
/** tooltip */
'data-tooltip': state.muted ? 'Unmute' : 'Mute',
'data-button': 'mute',
/** external props spread last to allow for overriding */
...props,
};
@@ -38,6 +38,7 @@ export function getPlayButtonProps(
'aria-label': state.paused ? 'play' : 'pause',
/** tooltip */
'data-tooltip': state.paused ? 'Play' : 'Pause',
'data-button': 'play',
/** external props spread last to allow for overriding */
...props,
};
@@ -24,18 +24,16 @@ const styles: FrostedSkinStyles = {
'vjs:[&_video]:w-full vjs:[&_video]:h-full',
),
Overlay: cn(
'vjs:opacity-0 vjs:delay-500 vjs:duration-300 vjs:rounded-[inherit] vjs:absolute vjs:inset-0 vjs:pointer-events-none vjs:bg-linear-to-t vjs:from-black/50 vjs:via-black/20 vjs:to-transparent vjs:transition-opacity vjs:backdrop-saturate-150 vjs:backdrop-brightness-90',
// ------------------------------------
// FIXME: This is crude temporary logic, we'll improve it later I guess with a [data-show-controls] attribute or something?
'vjs:has-[+.controls_[data-paused]]:opacity-100 vjs:has-[+.controls_[data-paused]]:delay-0 vjs:has-[+.controls_[data-paused]]:duration-100',
'vjs:has-[+.controls_[aria-expanded="true"]]:opacity-100 vjs:has-[+.controls_[aria-expanded="true"]]:delay-0 vjs:has-[+.controls_[aria-expanded="true"]]:duration-100',
'vjs:group-hover/root:opacity-100 vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100',
// ------------------------------------
'vjs:rounded-[inherit] vjs:absolute vjs:inset-0 vjs:pointer-events-none vjs:bg-linear-to-t vjs:from-black/50 vjs:via-black/20 vjs:to-transparent vjs:transition-opacity vjs:backdrop-saturate-150 vjs:backdrop-brightness-90',
// No delay and faster duration when showing
'vjs:delay-0 vjs:duration-100',
// Hidden state
'vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-300 vjs:group-data-[controls=hidden]/root:opacity-0',
),
Surface: cn(
'vjs:bg-white/10 vjs:backdrop-blur-3xl vjs:backdrop-saturate-150 vjs:backdrop-brightness-90',
// Ring and shadow
'vjs:ring vjs:ring-white/10 vjs:ring-inset vjs:shadow-sm vjs:shadow-black/15',
'vjs:ring vjs:ring-white/5 vjs:ring-inset vjs:shadow-sm vjs:shadow-black/15',
// Border to enhance contrast on lighter videos
'vjs:after:absolute vjs:after:inset-0 vjs:after:ring vjs:after:rounded-[inherit] vjs:after:ring-black/15 vjs:after:pointer-events-none vjs:after:z-10',
// Reduced transparency for users with preference
@@ -45,20 +43,13 @@ const styles: FrostedSkinStyles = {
'vjs:contrast-more:bg-black/90 vjs:contrast-more:ring-black vjs:contrast-more:after:ring-white/20',
),
Controls: cn(
// ------------------------------------
// FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility.
'controls',
// ------------------------------------
'vjs:@container/controls vjs:absolute vjs:inset-x-3 vjs:bottom-3 vjs:rounded-full vjs:flex vjs:items-center vjs:p-1 vjs:gap-0.5 vjs:text-white',
// Animation
'vjs:transition vjs:will-change-[transform,scale,filter,opacity] vjs:origin-bottom vjs:ease-out',
// ------------------------------------
// FIXME: Temporary hide/show logic, related to above.
'vjs:scale-90 vjs:opacity-0 vjs:blur-sm vjs:delay-500 vjs:duration-300',
'vjs:has-data-paused:scale-100 vjs:has-data-paused:opacity-100 vjs:has-data-paused:blur-none vjs:has-data-paused:delay-0 vjs:has-data-paused:duration-100',
'vjs:has-[[aria-expanded="true"]]:scale-100 vjs:has-[[aria-expanded="true"]]:opacity-100 vjs:has-[[aria-expanded="true"]]:blur-none vjs:has-[[aria-expanded="true"]]:delay-0 vjs:has-[[aria-expanded="true"]]:duration-100',
'vjs:group-hover/root:scale-100 vjs:group-hover/root:opacity-100 vjs:group-hover/root:blur-none vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100',
// ------------------------------------
// No delay and faster duration when showing
'vjs:delay-0 vjs:duration-100',
// Hidden state
'vjs:group-data-[controls=hidden]/root:scale-90 vjs:group-data-[controls=hidden]/root:opacity-0 vjs:group-data-[controls=hidden]/root:blur-sm vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-300 vjs:group-data-[controls=hidden]/root:pointer-events-none',
),
Icon: cn('icon vjs:[&_path]:transition-transform vjs:[&_path]:ease-out'),
Button: cn(
@@ -25,31 +25,25 @@ const styles: MinimalSkinStyles = {
Overlay: cn(
'vjs:absolute vjs:inset-0 vjs:rounded-[inherit] vjs:pointer-events-none',
'vjs:bg-linear-to-t vjs:from-black/70 vjs:via-black/50 vjs:via-[7.5rem] vjs:to-transparent',
'vjs:opacity-0 vjs:delay-500 vjs:duration-300',
// High contrast mode
'vjs:contrast-more:from-black/75',
// ------------------------------------
// FIXME: Temporary hide/show logic
'vjs:has-[+.controls_[data-paused]]:opacity-100 vjs:has-[+.controls_[data-paused]]:delay-0 vjs:has-[+.controls_[data-paused]]:duration-75',
'vjs:has-[+.controls_[aria-expanded="true"]]:opacity-100 vjs:has-[+.controls_[aria-expanded="true"]]:delay-0 vjs:has-[+.controls_[aria-expanded="true"]]:duration-75',
'vjs:group-hover/root:opacity-100 vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-75',
// ------------------------------------
// Animation
'vjs:transition vjs:will-change-[opacity] vjs:ease-out',
// No delay and faster duration when showing
'vjs:delay-0 vjs:duration-75',
// Hidden state
'vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-500 vjs:group-data-[controls=hidden]/root:opacity-0',
),
Controls: cn(
// ------------------------------------
// FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility.
'controls',
// ------------------------------------
'vjs:@container/controls vjs:absolute vjs:inset-x-0 vjs:bottom-0 vjs:flex vjs:items-center vjs:gap-3.5 vjs:z-20 vjs:px-3 vjs:pb-3 vjs:pt-10 vjs:text-white',
'vjs:@container/controls vjs:absolute vjs:inset-x-0 vjs:bottom-0 vjs:flex vjs:items-center vjs:gap-2 vjs:z-20 vjs:px-1.5 vjs:pb-1.5 vjs:pt-8 vjs:text-white',
// Animation
'vjs:transition vjs:will-change-[transform,filter,opacity] vjs:ease-out',
// ------------------------------------
// FIXME: Temporary hide/show logic
'vjs:translate-y-full vjs:opacity-0 vjs:blur-sm vjs:delay-500 vjs:duration-300',
'vjs:has-data-paused:translate-y-0 vjs:has-data-paused:opacity-100 vjs:has-data-paused:blur-none vjs:has-data-paused:delay-0 vjs:has-data-paused:duration-75',
'vjs:has-[[aria-expanded="true"]]:translate-y-0 vjs:has-[[aria-expanded="true"]]:opacity-100 vjs:has-[[aria-expanded="true"]]:blur-none vjs:has-[[aria-expanded="true"]]:delay-0 vjs:has-[[aria-expanded="true"]]:duration-75',
'vjs:group-hover/root:translate-y-0 vjs:group-hover/root:opacity-100 vjs:group-hover/root:blur-none vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-75',
// ------------------------------------
// No delay and faster duration when showing
'vjs:delay-0 vjs:duration-75',
// Hidden state
'vjs:group-data-[controls=hidden]/root:translate-y-full vjs:group-data-[controls=hidden]/root:opacity-0 vjs:group-data-[controls=hidden]/root:blur-sm vjs:group-data-[controls=hidden]/root:delay-500 vjs:group-data-[controls=hidden]/root:duration-500 vjs:group-data-[controls=hidden]/root:pointer-events-none',
// Looser spacing on larger screens
'vjs:@sm/root:gap-3.5 vjs:@sm/root:px-3 vjs:@sm/root:pb-3 vjs:@sm/root:pt-10',
),
Icon: cn('icon vjs:[&_path]:transition-transform vjs:[&_path]:ease-out'),
TimeDisplayGroup: cn('flex items-center gap-1'),