Files
mtdb_movie/common/resources/client/player/ui/video-player/video-player.tsx
maher 703f50a09d
Some checks failed
Build / run (push) Has been cancelled
first commit
2025-10-29 11:42:25 +01:00

255 lines
7.1 KiB
TypeScript
Executable File

import {
MutableRefObject,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react';
import {PlayerContext} from '@common/player/player-context';
import {MediaItem} from '@common/player/media-item';
import {PlayerOutlet} from '@common/player/ui/player-outlet';
import {
PlayerActions,
usePlayerActions,
} from '@common/player/hooks/use-player-actions';
import {usePlayerStore} from '@common/player/hooks/use-player-store';
import clsx from 'clsx';
import {PlayerPoster} from '@common/player/ui/controls/player-poster';
import {usePlayerClickHandler} from '@common/player/hooks/use-player-click-handler';
import {IconButton} from '@common/ui/buttons/icon-button';
import {MediaPlayIcon} from '@common/icons/media/media-play';
import {BufferingSpinner} from '@common/player/ui/controls/buffering-spinner';
import {guessPlayerProvider} from '@common/player/utils/guess-player-provider';
import {usePrevious} from '@common/utils/hooks/use-previous';
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
import {VideoPlayerControls} from '@common/player/ui/video-player/video-player-controls';
interface Props {
id: string;
queue?: MediaItem[];
cuedMediaId?: string;
autoPlay?: boolean;
src?: string;
listeners?: PlayerStoreOptions['listeners'];
onDestroy?: PlayerStoreOptions['onDestroy'];
onBeforePlayNext?: PlayerStoreOptions['onBeforePlayNext'];
onBeforePlayPrevious?: PlayerStoreOptions['onBeforePlayPrevious'];
apiRef?: MutableRefObject<PlayerActions>;
rightActions?: ReactNode;
}
export function VideoPlayer({
id,
queue,
cuedMediaId,
autoPlay,
src,
listeners,
onBeforePlayPrevious,
onBeforePlayNext,
onDestroy,
apiRef,
rightActions,
}: Props) {
return (
<PlayerContext
id={id}
options={{
autoPlay,
listeners,
onDestroy,
onBeforePlayNext,
onBeforePlayPrevious,
initialData: {
queue: queue ? queue : [mediaItemFromSrc(src!)],
cuedMediaId,
},
}}
>
<QueueOverrider src={src} queue={queue} />
<PlayerLayout apiRef={apiRef} rightActions={rightActions} />
</PlayerContext>
);
}
interface PlayerLayoutProps {
apiRef?: MutableRefObject<PlayerActions>;
rightActions?: ReactNode;
}
function PlayerLayout({apiRef, rightActions}: PlayerLayoutProps) {
const leaveTimerRef = useRef<number | null>();
const inactiveTimerRef = useRef<number | null>();
const pointerIsOverControls = useRef(false);
const actions = usePlayerActions();
const controlsVisible = usePlayerStore(s => s.controlsVisible);
const {setControlsVisible, getState} = actions;
const clickHandler = usePlayerClickHandler();
const clearTimers = () => {
if (leaveTimerRef.current) {
clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
if (inactiveTimerRef.current) {
clearTimeout(inactiveTimerRef.current);
inactiveTimerRef.current = null;
}
};
const startInactiveTimer = useCallback(() => {
if (getState().isPlaying) {
inactiveTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
}, 3500);
}
}, [getState, setControlsVisible]);
// show controls when any key is pressed
useEffect(() => {
const listener = () => {
clearTimers();
setControlsVisible(true);
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [setControlsVisible]);
useEffect(() => {
if (apiRef) {
apiRef.current = actions;
return actions.subscribe({
play: () => startInactiveTimer(),
});
}
}, [apiRef, actions, setControlsVisible, startInactiveTimer]);
return (
<div
className={clsx(
'fullscreen-host relative isolate aspect-video bg-black',
!controlsVisible && 'cursor-none',
)}
onClick={clickHandler}
onPointerEnter={() => {
setControlsVisible(true);
clearTimers();
}}
onPointerMove={() => {
if (pointerIsOverControls.current && controlsVisible) {
return;
}
if (inactiveTimerRef.current) {
setControlsVisible(true);
}
clearTimers();
startInactiveTimer();
}}
onPointerLeave={() => {
clearTimers();
if (!getState().isPlaying) {
return;
}
leaveTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
}, 2500);
}}
>
<PlayerOutlet className="z-50 h-full w-full" />
<Blocker />
<PlayerPoster className="absolute inset-0 z-30" />
<OverlayButtons />
<BufferingSpinner
className="spinner pointer-events-none absolute inset-0 z-40 m-auto h-50 w-50"
fillColor="border-white"
trackColor="border-white/30"
size="w-50 h-50"
/>
<BottomGradient />
<VideoPlayerControls
rightActions={rightActions}
onPointerEnter={() => {
pointerIsOverControls.current = true;
setControlsVisible(true);
clearTimers();
}}
onPointerLeave={() => {
pointerIsOverControls.current = false;
}}
/>
</div>
);
}
function OverlayButtons() {
const showPlayButton = usePlayerStore(s => !s.isPlaying && !s.isSeeking);
return (
<div
className={clsx(
'absolute left-0 top-0 z-40 flex h-full w-full items-center justify-center transition-opacity',
showPlayButton ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<IconButton
color="primary"
variant="raised"
size="lg"
radius="rounded-full"
>
<MediaPlayIcon />
</IconButton>
</div>
);
}
// required in order for "onPointerEnter" to fire consistently when player provider is iframe
function Blocker() {
return <div className="absolute inset-0 z-20" />;
}
function BottomGradient() {
const controlsVisible = usePlayerStore(s => s.controlsVisible);
return (
<div
className={clsx(
'player-bottom-gradient pointer-events-none absolute bottom-0 z-30 h-full w-full transition-opacity duration-300',
controlsVisible ? 'opacity-100' : 'opacity-0',
)}
/>
);
}
function mediaItemFromSrc(src: string): MediaItem {
return {
id: src,
src,
provider: guessPlayerProvider(src),
};
}
interface QueueOverriderProps {
src?: string;
queue?: MediaItem[];
}
function QueueOverrider({src, queue}: QueueOverriderProps) {
const {getState, overrideQueue} = usePlayerActions();
const queueKey = queue?.map(item => item.id).join('-') ?? '';
const previousKey = usePrevious(queueKey);
// override queue when any of specified queue item id or order changes
useEffect(() => {
if (queue && previousKey && queueKey && previousKey !== queueKey) {
overrideQueue(queue);
}
}, [queueKey, previousKey, queue, overrideQueue]);
// override queue when src changes
useEffect(() => {
if (src && getState().cuedMedia?.src !== src) {
overrideQueue([mediaItemFromSrc(src)]);
}
}, [src, getState, overrideQueue]);
return null;
}