|
@@ -1,12 +1,36 @@
|
|
import { debounce } from 'lodash'
|
|
import { debounce } from 'lodash'
|
|
import React, { useEffect, useRef, useState } from 'react'
|
|
import React, { useEffect, useRef, useState } from 'react'
|
|
|
|
+import { useCallback } from 'react'
|
|
|
|
|
|
import { usePersonalDataStore } from '@/providers'
|
|
import { usePersonalDataStore } from '@/providers'
|
|
-import { SvgOutlineVideo } from '@/shared/icons'
|
|
|
|
|
|
+import {
|
|
|
|
+ SvgOutlineVideo,
|
|
|
|
+ SvgPlayerFullScreen,
|
|
|
|
+ SvgPlayerPause,
|
|
|
|
+ SvgPlayerPip,
|
|
|
|
+ SvgPlayerPipDisable,
|
|
|
|
+ SvgPlayerPlay,
|
|
|
|
+ SvgPlayerSmallScreen,
|
|
|
|
+ SvgPlayerSoundHalf,
|
|
|
|
+ SvgPlayerSoundOn,
|
|
|
|
+} from '@/shared/icons'
|
|
import { Logger } from '@/utils/logger'
|
|
import { Logger } from '@/utils/logger'
|
|
|
|
+import { formatDurationShort } from '@/utils/time'
|
|
|
|
|
|
-import { Container, PlayOverlay } from './VideoPlayer.style'
|
|
|
|
-import { VideoJsConfig, useVideoJsPlayer } from './videoJsPlayer'
|
|
|
|
|
|
+import {
|
|
|
|
+ Container,
|
|
|
|
+ ControlButton,
|
|
|
|
+ CurrentTime,
|
|
|
|
+ CustomControls,
|
|
|
|
+ PlayOverlay,
|
|
|
|
+ ScreenControls,
|
|
|
|
+ StyledSvgPlayerSoundOff,
|
|
|
|
+ VolumeButton,
|
|
|
|
+ VolumeControl,
|
|
|
|
+ VolumeSlider,
|
|
|
|
+ VolumeSliderContainer,
|
|
|
|
+} from './VideoPlayer.style'
|
|
|
|
+import { VOLUME_STEP, VideoJsConfig, useVideoJsPlayer } from './videoJsPlayer'
|
|
|
|
|
|
export type VideoPlayerProps = {
|
|
export type VideoPlayerProps = {
|
|
className?: string
|
|
className?: string
|
|
@@ -15,41 +39,68 @@ export type VideoPlayerProps = {
|
|
playing?: boolean
|
|
playing?: boolean
|
|
} & VideoJsConfig
|
|
} & VideoJsConfig
|
|
|
|
|
|
|
|
+declare global {
|
|
|
|
+ interface Document {
|
|
|
|
+ pictureInPictureEnabled: boolean
|
|
|
|
+ pictureInPictureElement: Element
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const isPiPSupported = 'pictureInPictureEnabled' in document
|
|
|
|
+
|
|
const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, VideoPlayerProps> = (
|
|
const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, VideoPlayerProps> = (
|
|
{ className, autoplay, isInBackground, playing, ...videoJsConfig },
|
|
{ className, autoplay, isInBackground, playing, ...videoJsConfig },
|
|
externalRef
|
|
externalRef
|
|
) => {
|
|
) => {
|
|
const [player, playerRef] = useVideoJsPlayer(videoJsConfig)
|
|
const [player, playerRef] = useVideoJsPlayer(videoJsConfig)
|
|
|
|
+ const cachedPlayerVolume = usePersonalDataStore((state) => state.cachedPlayerVolume)
|
|
|
|
+ const updateCachedPlayerVolume = usePersonalDataStore((state) => state.actions.updateCachedPlayerVolume)
|
|
|
|
|
|
- const playerVolume = usePersonalDataStore((state) => state.playerVolume)
|
|
|
|
- const updatePlayerVolume = usePersonalDataStore((state) => state.actions.updatePlayerVolume)
|
|
|
|
-
|
|
|
|
|
|
+ const [volume, setVolume] = useState(cachedPlayerVolume)
|
|
|
|
+ const [isPlaying, setIsPlaying] = useState(false)
|
|
|
|
+ const [videoTime, setVideoTime] = useState(0)
|
|
|
|
+ const [isFullScreen, setIsFullScreen] = useState(false)
|
|
|
|
+ const [isPiPEnabled, setIsPiPEnabled] = useState(false)
|
|
const [playOverlayVisible, setPlayOverlayVisible] = useState(true)
|
|
const [playOverlayVisible, setPlayOverlayVisible] = useState(true)
|
|
const [initialized, setInitialized] = useState(false)
|
|
const [initialized, setInitialized] = useState(false)
|
|
|
|
|
|
const displayPlayOverlay = playOverlayVisible && !isInBackground
|
|
const displayPlayOverlay = playOverlayVisible && !isInBackground
|
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
|
+ const playVideo = useCallback(() => {
|
|
if (!player) {
|
|
if (!player) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
+ const playPromise = player.play()
|
|
|
|
+ if (playPromise) {
|
|
|
|
+ playPromise.catch((e) => {
|
|
|
|
+ if (e.name === 'NotAllowedError') {
|
|
|
|
+ Logger.warn('Video play failed:', e)
|
|
|
|
+ } else {
|
|
|
|
+ Logger.error('Video play failed:', e)
|
|
|
|
+ }
|
|
|
|
+ })
|
|
|
|
+ }
|
|
|
|
+ }, [player])
|
|
|
|
|
|
|
|
+ // handle loading video
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (!player) {
|
|
|
|
+ return
|
|
|
|
+ }
|
|
const handler = () => {
|
|
const handler = () => {
|
|
setInitialized(true)
|
|
setInitialized(true)
|
|
}
|
|
}
|
|
-
|
|
|
|
player.on('loadstart', handler)
|
|
player.on('loadstart', handler)
|
|
-
|
|
|
|
return () => {
|
|
return () => {
|
|
player.off('loadstart', handler)
|
|
player.off('loadstart', handler)
|
|
}
|
|
}
|
|
}, [player])
|
|
}, [player])
|
|
|
|
|
|
|
|
+ // handle autoplay
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
if (!player || !initialized || !autoplay) {
|
|
if (!player || !initialized || !autoplay) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
-
|
|
|
|
const playPromise = player.play()
|
|
const playPromise = player.play()
|
|
if (playPromise) {
|
|
if (playPromise) {
|
|
playPromise.catch((e) => {
|
|
playPromise.catch((e) => {
|
|
@@ -58,42 +109,35 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
|
|
}
|
|
}
|
|
}, [player, initialized, autoplay])
|
|
}, [player, initialized, autoplay])
|
|
|
|
|
|
|
|
+ // handle playing and pausing from outside the component
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
if (!player) {
|
|
if (!player) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
-
|
|
|
|
- if (playing != null) {
|
|
|
|
- if (playing) {
|
|
|
|
- const playPromise = player.play()
|
|
|
|
- if (playPromise) {
|
|
|
|
- playPromise.catch((e) => {
|
|
|
|
- if (e.name === 'NotAllowedError') {
|
|
|
|
- Logger.warn('Video play failed:', e)
|
|
|
|
- } else {
|
|
|
|
- Logger.error('Video play failed:', e)
|
|
|
|
- }
|
|
|
|
- })
|
|
|
|
- }
|
|
|
|
- } else {
|
|
|
|
- player.pause()
|
|
|
|
- }
|
|
|
|
|
|
+ if (playing) {
|
|
|
|
+ playVideo()
|
|
|
|
+ } else {
|
|
|
|
+ player.pause()
|
|
}
|
|
}
|
|
- }, [player, playing])
|
|
|
|
|
|
+ }, [playVideo, player, playing])
|
|
|
|
|
|
|
|
+ // handle playing and pausing
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
if (!player) {
|
|
if (!player) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
-
|
|
|
|
- const handler = () => {
|
|
|
|
- setPlayOverlayVisible(false)
|
|
|
|
|
|
+ const handler = (event: Event) => {
|
|
|
|
+ if (event.type === 'play') {
|
|
|
|
+ setPlayOverlayVisible(false)
|
|
|
|
+ setIsPlaying(true)
|
|
|
|
+ }
|
|
|
|
+ if (event.type === 'pause') {
|
|
|
|
+ setIsPlaying(false)
|
|
|
|
+ }
|
|
}
|
|
}
|
|
-
|
|
|
|
- player.on('play', handler)
|
|
|
|
-
|
|
|
|
|
|
+ player.on(['play', 'pause'], handler)
|
|
return () => {
|
|
return () => {
|
|
- player.off('play', handler)
|
|
|
|
|
|
+ player.off(['play', 'pause'], handler)
|
|
}
|
|
}
|
|
}, [player])
|
|
}, [player])
|
|
|
|
|
|
@@ -108,44 +152,177 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
|
|
}
|
|
}
|
|
}, [externalRef, playerRef])
|
|
}, [externalRef, playerRef])
|
|
|
|
|
|
- const handlePlayOverlayClick = () => {
|
|
|
|
|
|
+ // handle video timer
|
|
|
|
+ useEffect(() => {
|
|
if (!player) {
|
|
if (!player) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
- player.play()
|
|
|
|
- }
|
|
|
|
|
|
+ const handler = () => setVideoTime(Math.floor(player.currentTime()))
|
|
|
|
+ player.on('timeupdate', handler)
|
|
|
|
+ return () => {
|
|
|
|
+ player.off('timeupdate', handler)
|
|
|
|
+ }
|
|
|
|
+ }, [player])
|
|
|
|
+
|
|
|
|
+ // handle fullscreen mode
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (!player) {
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ const handler = () => setIsFullScreen(player.isFullscreen())
|
|
|
|
+ player.on('fullscreenchange', handler)
|
|
|
|
+ return () => {
|
|
|
|
+ player.off('fullscreenchange', handler)
|
|
|
|
+ }
|
|
|
|
+ }, [player])
|
|
|
|
+
|
|
|
|
+ // handle picture in picture
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (!player) {
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ const handler = (event: Event) => {
|
|
|
|
+ if (event.type === 'enterpictureinpicture') {
|
|
|
|
+ setIsPiPEnabled(true)
|
|
|
|
+ }
|
|
|
|
+ if (event.type === 'leavepictureinpicture') {
|
|
|
|
+ setIsPiPEnabled(false)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ player.on(['enterpictureinpicture', 'leavepictureinpicture'], handler)
|
|
|
|
+ return () => {
|
|
|
|
+ player.off(['enterpictureinpicture', 'leavepictureinpicture'], handler)
|
|
|
|
+ }
|
|
|
|
+ }, [player])
|
|
|
|
+
|
|
|
|
+ // update volume on keyboard input
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (!player) {
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const handler = (event: Event) => {
|
|
|
|
+ if (event.type === 'mute') {
|
|
|
|
+ setVolume(0)
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (event.type === 'unmute') {
|
|
|
|
+ setVolume(cachedPlayerVolume || VOLUME_STEP)
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (event.type === 'volumechange') {
|
|
|
|
+ setVolume(player.volume())
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ player.on(['volumechange', 'mute', 'unmute'], handler)
|
|
|
|
+ return () => {
|
|
|
|
+ player.off(['volumechange', 'mute', 'unmute'], handler)
|
|
|
|
+ }
|
|
|
|
+ }, [cachedPlayerVolume, volume, player])
|
|
|
|
|
|
const debouncedVolumeChange = useRef(
|
|
const debouncedVolumeChange = useRef(
|
|
debounce((volume: number) => {
|
|
debounce((volume: number) => {
|
|
- updatePlayerVolume(volume)
|
|
|
|
- }, 500)
|
|
|
|
|
|
+ updateCachedPlayerVolume(volume)
|
|
|
|
+ }, 125)
|
|
)
|
|
)
|
|
-
|
|
|
|
- const isInitialMount = useRef(true)
|
|
|
|
|
|
+ // update volume on mouse input
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
- if (!player || !isInitialMount) {
|
|
|
|
|
|
+ if (!player || isInBackground) {
|
|
return
|
|
return
|
|
}
|
|
}
|
|
- isInitialMount.current = false
|
|
|
|
|
|
+ player?.volume(volume)
|
|
|
|
|
|
- player.volume(playerVolume)
|
|
|
|
|
|
+ if (volume) {
|
|
|
|
+ player.muted(false)
|
|
|
|
+ debouncedVolumeChange.current(volume)
|
|
|
|
+ } else {
|
|
|
|
+ player.muted(true)
|
|
|
|
+ }
|
|
|
|
+ }, [isInBackground, player, volume])
|
|
|
|
|
|
- const handleVolumeChange = () => debouncedVolumeChange.current(player.volume())
|
|
|
|
- player.on('volumechange', handleVolumeChange)
|
|
|
|
- return () => {
|
|
|
|
- player.off('volumechange', handleVolumeChange)
|
|
|
|
|
|
+ // button/input handlers
|
|
|
|
+ const handlePlayPause = () => {
|
|
|
|
+ isPlaying ? player?.pause() : playVideo()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const handleChangeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
+ setVolume(Number(event.target.value))
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const handleMute = (event: React.MouseEvent) => {
|
|
|
|
+ event.stopPropagation()
|
|
|
|
+ if (volume === 0) {
|
|
|
|
+ setVolume(cachedPlayerVolume || 0.05)
|
|
|
|
+ } else {
|
|
|
|
+ setVolume(0)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const handlePictureInPicture = () => {
|
|
|
|
+ if (document.pictureInPictureElement) {
|
|
|
|
+ // @ts-ignore @types/video.js is outdated and doesn't provide types for some newer video.js features
|
|
|
|
+ player.exitPictureInPicture()
|
|
|
|
+ } else {
|
|
|
|
+ if (document.pictureInPictureEnabled) {
|
|
|
|
+ // @ts-ignore @types/video.js is outdated and doesn't provide types for some newer video.js features
|
|
|
|
+ player.requestPictureInPicture().catch((e) => {
|
|
|
|
+ Logger.warn('Picture in picture failed:', e)
|
|
|
|
+ })
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const handleFullScreen = () => {
|
|
|
|
+ if (player?.isFullscreen()) {
|
|
|
|
+ player?.exitFullscreen()
|
|
|
|
+ } else {
|
|
|
|
+ player?.requestFullscreen()
|
|
}
|
|
}
|
|
- }, [player, playerVolume])
|
|
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const renderVolumeButton = () => {
|
|
|
|
+ if (volume === 0) {
|
|
|
|
+ return <StyledSvgPlayerSoundOff />
|
|
|
|
+ } else {
|
|
|
|
+ return volume <= 0.5 ? <SvgPlayerSoundHalf /> : <SvgPlayerSoundOn />
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
|
|
return (
|
|
return (
|
|
- <Container className={className} isInBackground={isInBackground}>
|
|
|
|
|
|
+ <Container isFullScreen={isFullScreen} className={className} isInBackground={isInBackground}>
|
|
{displayPlayOverlay && (
|
|
{displayPlayOverlay && (
|
|
- <PlayOverlay onClick={handlePlayOverlayClick}>
|
|
|
|
|
|
+ <PlayOverlay onClick={handlePlayPause}>
|
|
<SvgOutlineVideo width={72} height={72} viewBox="0 0 24 24" />
|
|
<SvgOutlineVideo width={72} height={72} viewBox="0 0 24 24" />
|
|
</PlayOverlay>
|
|
</PlayOverlay>
|
|
)}
|
|
)}
|
|
<div data-vjs-player>
|
|
<div data-vjs-player>
|
|
<video ref={playerRef} className="video-js" />
|
|
<video ref={playerRef} className="video-js" />
|
|
|
|
+ {!isInBackground && !playOverlayVisible && (
|
|
|
|
+ <CustomControls isFullScreen={isFullScreen}>
|
|
|
|
+ <ControlButton onClick={handlePlayPause}>
|
|
|
|
+ {isPlaying ? <SvgPlayerPause /> : <SvgPlayerPlay />}
|
|
|
|
+ </ControlButton>
|
|
|
|
+ <VolumeControl>
|
|
|
|
+ <VolumeButton onClick={handleMute}>{renderVolumeButton()}</VolumeButton>
|
|
|
|
+ <VolumeSliderContainer>
|
|
|
|
+ <VolumeSlider step={0.01} max={1} min={0} value={volume} onChange={handleChangeVolume} type="range" />
|
|
|
|
+ </VolumeSliderContainer>
|
|
|
|
+ </VolumeControl>
|
|
|
|
+ <CurrentTime variant="body2">
|
|
|
|
+ {formatDurationShort(videoTime)} / {formatDurationShort(Math.floor(player?.duration() || 0))}
|
|
|
|
+ </CurrentTime>
|
|
|
|
+ <ScreenControls>
|
|
|
|
+ {isPiPSupported && (
|
|
|
|
+ <ControlButton onClick={handlePictureInPicture}>
|
|
|
|
+ {isPiPEnabled ? <SvgPlayerPipDisable /> : <SvgPlayerPip />}
|
|
|
|
+ </ControlButton>
|
|
|
|
+ )}
|
|
|
|
+ <ControlButton onClick={handleFullScreen}>
|
|
|
|
+ {isFullScreen ? <SvgPlayerSmallScreen /> : <SvgPlayerFullScreen />}
|
|
|
|
+ </ControlButton>
|
|
|
|
+ </ScreenControls>
|
|
|
|
+ </CustomControls>
|
|
|
|
+ )}
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
</Container>
|
|
)
|
|
)
|