mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add user activity logic (#278)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user