Преглед изворни кода

Custom timeline & timeline tooltip (#1021)

* fix issue with out of bound tooltip on the right

* patch the timetooltip and mousetimedisplay, style timeline tooltip

* rebase

* remove videojs patch

* add custom timeline

* refactor

* add shadow and animation to tooltip

* extract CustomTimeline to separate component

* refactor

* reduce useRefs, use css clamp

* use clamp for tooltip

* mobile improvements

* buffer fix, cr fixes

* improve scrubbing

* add transition delay

* fixes

* expand ProgressControl area when scrubbing

* fixed scrubbing on fullscreen, add pointer to thumb, use setInterval instead of event listener
Bartosz Dryl пре 3 година
родитељ
комит
8a91d5c772

+ 142 - 0
src/shared/components/VideoPlayer/CustomTimeline.style.ts

@@ -0,0 +1,142 @@
+import { css } from '@emotion/react'
+import styled from '@emotion/styled'
+
+import { colors, transitions, zIndex } from '@/shared/theme'
+
+import { CustomControls, TRANSITION_DELAY } from './VideoPlayer.style'
+
+import { Text } from '../Text'
+
+type ProgressControlProps = {
+  isFullScreen?: boolean
+  isScrubbing?: boolean
+}
+
+// expand ProgressControl area when scrubbing
+const scrubbingStyles = (isFullScreen?: boolean) => css`
+  height: 100vh;
+  bottom: ${isFullScreen ? 0 : '-200px'};
+  padding-bottom: ${isFullScreen ? `1.5em 1.5em` : '200px'};
+`
+
+export const ProgressControl = styled.div<ProgressControlProps>`
+  padding: ${({ isFullScreen }) => (isFullScreen ? `1.5em 1.5em` : `0`)};
+  position: absolute;
+  height: 1.5em;
+  z-index: ${zIndex.nearOverlay};
+  left: 0;
+  bottom: 0;
+  width: 100%;
+  cursor: pointer;
+  display: flex;
+  align-items: flex-end;
+  ${({ isScrubbing, isFullScreen }) => isScrubbing && scrubbingStyles(isFullScreen)};
+  :hover ${() => SeekBar} {
+    height: 0.5em;
+  }
+  :hover ${() => PlayProgressThumb} {
+    opacity: 1;
+  }
+  :hover ${() => MouseDisplayWrapper} {
+    opacity: 1;
+  }
+  :hover ${() => MouseDisplayTooltip} {
+    transform: translateY(-0.5em) !important;
+    opacity: 1;
+  }
+  :hover ~ ${CustomControls} {
+    opacity: 0;
+    transform: translateY(0.5em) !important;
+  }
+
+  ${() => SeekBar} {
+    ${({ isScrubbing }) => isScrubbing && `height: 0.5em`}
+  }
+
+  ${() => MouseDisplayWrapper}, ${() => PlayProgressThumb} {
+    ${({ isScrubbing }) => isScrubbing && `opacity: 1`}
+  }
+  ${() => MouseDisplayTooltip} {
+    ${({ isScrubbing }) => isScrubbing && `transform: translateY(-0.5em) !important`}
+  }
+  ~ ${CustomControls} {
+    ${({ isScrubbing }) => isScrubbing && `opacity: 0; transform: translateY(0.5em) !important`};
+  }
+`
+
+export const SeekBar = styled.div`
+  position: relative;
+  width: 100%;
+  height: 0.25em;
+  background-color: ${colors.transparentWhite[32]};
+  transition: height ${transitions.timings.player} ${TRANSITION_DELAY} ${transitions.easing};
+`
+
+export const LoadProgress = styled.div`
+  height: 100%;
+  background-color: ${colors.transparentWhite[32]};
+`
+
+export const MouseDisplayWrapper = styled.div`
+  width: 100%;
+  opacity: 0;
+  transition: opacity 200ms ${transitions.easing};
+`
+
+export const MouseDisplay = styled.div`
+  height: 100%;
+  position: absolute;
+  top: 0;
+  background-color: ${colors.transparentWhite[32]};
+`
+
+type MouseDisplayTooltipProps = {
+  isFullScreen?: boolean
+}
+
+export const MouseDisplayTooltip = styled.div<MouseDisplayTooltipProps>`
+  pointer-events: none;
+  user-select: none;
+  opacity: 0;
+  position: absolute;
+  padding: ${({ isFullScreen }) => (isFullScreen ? `0` : `0 1em`)};
+  bottom: 1.5em;
+  transition: transform ${transitions.timings.player} ${TRANSITION_DELAY} ${transitions.easing},
+    opacity ${transitions.timings.player} ${TRANSITION_DELAY} ${transitions.easing};
+`
+
+export const StyledTooltipText = styled(Text)`
+  /* 14px */
+  font-size: 0.875em;
+  pointer-events: none;
+  text-shadow: 0 1px 2px ${colors.transparentBlack[32]};
+  font-feature-settings: 'tnum' on, 'lnum' on;
+`
+
+export const PlayProgressWrapper = styled.div`
+  width: 100%;
+`
+
+export const PlayProgress = styled.div`
+  position: absolute;
+  top: 0;
+  height: 100%;
+  background-color: ${colors.blue[500]};
+  z-index: 1;
+`
+
+export const PlayProgressThumb = styled.button`
+  cursor: pointer;
+  border: none;
+  opacity: 0;
+  z-index: 1;
+  content: '';
+  height: 1em;
+  width: 1em;
+  top: -0.25em;
+  position: absolute;
+  box-shadow: 0 1px 2px ${colors.transparentBlack[32]};
+  border-radius: 100%;
+  background: ${colors.white} !important;
+  transition: opacity ${transitions.timings.player} ${TRANSITION_DELAY} ${transitions.easing} !important;
+`

+ 175 - 0
src/shared/components/VideoPlayer/CustomTimeline.tsx

@@ -0,0 +1,175 @@
+import { clamp, round } from 'lodash'
+import React, { useEffect, useRef } from 'react'
+import { useState } from 'react'
+import { VideoJsPlayer } from 'video.js'
+
+import { formatDurationShort } from '@/utils/time'
+
+import {
+  LoadProgress,
+  MouseDisplay,
+  MouseDisplayTooltip,
+  MouseDisplayWrapper,
+  PlayProgress,
+  PlayProgressThumb,
+  PlayProgressWrapper,
+  ProgressControl,
+  SeekBar,
+  StyledTooltipText,
+} from './CustomTimeline.style'
+import { PlayerState } from './VideoPlayer'
+
+type CustomTimelineProps = {
+  player?: VideoJsPlayer | null
+  isFullScreen?: boolean
+  playerState: PlayerState
+}
+
+const UPDATE_INTERVAL = 30
+
+export const CustomTimeline: React.FC<CustomTimelineProps> = ({ player, isFullScreen, playerState }) => {
+  const playProgressRef = useRef<HTMLDivElement>(null)
+  const seekBarRef = useRef<HTMLDivElement>(null)
+  const mouseDisplayTooltipRef = useRef<HTMLDivElement>(null)
+
+  const [playProgressWidth, setPlayProgressWidth] = useState(0)
+  const [loadProgressWidth, setLoadProgressWidth] = useState(0)
+  const [mouseDisplayWidth, setMouseDisplayWidth] = useState(0)
+  const [mouseDisplayTooltipTime, setMouseDisplayTooltipTime] = useState('0:00')
+  const [mouseDisplayTooltipWidth, setMouseDisplayTooltipWidth] = useState(0)
+  const [isScrubbing, setIsScrubbing] = useState(false)
+  const [playedBefore, setPlayedBefore] = useState(false)
+
+  useEffect(() => {
+    if (!player || !playedBefore) {
+      return
+    }
+    if (isScrubbing) {
+      player.pause()
+    } else {
+      player.play()
+      setPlayedBefore(false)
+    }
+  }, [isScrubbing, player, playedBefore])
+
+  useEffect(() => {
+    const playProgress = playProgressRef.current
+    if (!player || !playerState || playerState === 'ended' || playerState === 'error' || !playProgress || isScrubbing) {
+      return
+    }
+
+    const interval = window.setInterval(() => {
+      const duration = player.duration()
+      const currentTime = player.currentTime()
+      const buffered = player.buffered()
+
+      // set playProgress
+
+      const progressPercentage = round((currentTime / duration) * 100, 2)
+      setPlayProgressWidth(progressPercentage)
+
+      // set loadProgress
+
+      // get all buffered time ranges
+      const bufferedTimeRanges = Array.from({ length: buffered.length }).map((_, idx) => ({
+        bufferedStart: buffered.start(idx),
+        bufferedEnd: buffered.end(idx),
+      }))
+
+      const currentBufferedTimeRange = bufferedTimeRanges.find(
+        (el) => el.bufferedEnd > currentTime && el.bufferedStart < currentTime
+      )
+
+      if (currentBufferedTimeRange) {
+        const loadProgressPercentage = round((currentBufferedTimeRange.bufferedEnd / duration) * 100, 2)
+        setLoadProgressWidth(loadProgressPercentage)
+      } else {
+        setLoadProgressWidth(0)
+      }
+    }, UPDATE_INTERVAL)
+    return () => {
+      clearInterval(interval)
+    }
+  }, [isScrubbing, player, playerState])
+
+  const handleMouseAndTouchMove = (e: React.MouseEvent | React.TouchEvent) => {
+    const seekBar = seekBarRef.current
+    const mouseDisplayTooltip = mouseDisplayTooltipRef.current
+    if (!seekBar || !mouseDisplayTooltip || !player) {
+      return
+    }
+    // this will prevent hiding controls when scrubbing on mobile
+    player.enableTouchActivity()
+
+    const duration = player.duration()
+
+    // position of seekBar
+    const { x: seekBarPosition, width: seekBarWidth } = seekBar.getBoundingClientRect()
+    const position = 'clientX' in e ? e.clientX - seekBarPosition : e.touches[0].clientX - seekBarPosition
+    const percentage = clamp(round((position / seekBarWidth) * 100, 2), 0, 100)
+    setMouseDisplayWidth(percentage)
+    setMouseDisplayTooltipWidth(mouseDisplayTooltip.clientWidth)
+
+    // tooltip text
+    if (duration) {
+      setMouseDisplayTooltipTime(formatDurationShort(round((percentage / 100) * duration)))
+    }
+    if (isScrubbing) {
+      if (!player.paused()) {
+        setPlayedBefore(true)
+      }
+      setPlayProgressWidth(percentage)
+    }
+  }
+
+  const handleJumpToTime = (e: React.MouseEvent) => {
+    const seekBar = seekBarRef.current
+    if (!seekBar || isScrubbing) {
+      return
+    }
+
+    const { x: seekBarPosition, width: seekBarWidth } = seekBar.getBoundingClientRect()
+    const mousePosition = e.clientX - seekBarPosition
+
+    const percentage = clamp(round(mousePosition / seekBarWidth, 4), 0, 100)
+    const newTime = percentage * (player?.duration() || 0)
+    player?.currentTime(newTime)
+  }
+
+  return (
+    <ProgressControl
+      isScrubbing={isScrubbing}
+      isFullScreen={isFullScreen}
+      onMouseMove={handleMouseAndTouchMove}
+      onTouchMove={handleMouseAndTouchMove}
+      onMouseLeave={() => setIsScrubbing(false)}
+      onClick={handleJumpToTime}
+      onMouseDown={() => setIsScrubbing(true)}
+      onTouchStart={() => setIsScrubbing(true)}
+      onMouseUp={() => setIsScrubbing(false)}
+      onTouchEnd={() => setIsScrubbing(false)}
+    >
+      <SeekBar ref={seekBarRef}>
+        <LoadProgress style={{ width: loadProgressWidth + '%' }} />
+        <MouseDisplayWrapper>
+          <MouseDisplay style={{ width: mouseDisplayWidth + '%' }} />
+          <MouseDisplayTooltip
+            ref={mouseDisplayTooltipRef}
+            style={{
+              left: `clamp(0px, calc(${mouseDisplayWidth}% - ${
+                mouseDisplayTooltipWidth / 2
+              }px), calc(100% - ${mouseDisplayTooltipWidth}px))`,
+            }}
+            isFullScreen={isFullScreen}
+          >
+            <StyledTooltipText variant="body2">{mouseDisplayTooltipTime}</StyledTooltipText>
+          </MouseDisplayTooltip>
+        </MouseDisplayWrapper>
+        <PlayProgressWrapper>
+          <PlayProgress style={{ width: playProgressWidth + '%' }} ref={playProgressRef} />
+          <PlayProgressThumb style={{ left: `clamp(0px, calc(${playProgressWidth}% - 0.5em), calc(100% - 1em))` }} />
+        </PlayProgressWrapper>
+      </SeekBar>
+    </ProgressControl>
+  )
+}

+ 9 - 104
src/shared/components/VideoPlayer/VideoPlayer.style.tsx

@@ -18,6 +18,8 @@ type CustomControlsProps = {
   isEnded?: boolean
 }
 
+export const TRANSITION_DELAY = '50ms'
+
 export const ControlsOverlay = styled.div<CustomControlsProps>`
   font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))};
   opacity: 0;
@@ -26,7 +28,8 @@ export const ControlsOverlay = styled.div<CustomControlsProps>`
   width: 100%;
   background: linear-gradient(180deg, transparent 0%, ${colors.gray[900]} 100%);
   height: 8em;
-  transition: opacity 200ms ${transitions.easing}, visibility 200ms ${transitions.easing};
+  transition: opacity 200ms ${TRANSITION_DELAY} ${transitions.easing},
+    visibility 200ms ${TRANSITION_DELAY} ${transitions.easing};
 `
 
 export const CustomControls = styled.div<CustomControlsProps>`
@@ -40,7 +43,8 @@ export const CustomControls = styled.div<CustomControlsProps>`
   align-items: center;
   z-index: ${zIndex.nearOverlay - 1};
   width: 100%;
-  transition: transform 200ms ${transitions.easing}, opacity 200ms ${transitions.easing};
+  transition: transform 200ms ${TRANSITION_DELAY} ${transitions.easing},
+    opacity 200ms ${TRANSITION_DELAY} ${transitions.easing};
 `
 
 export const VolumeSliderContainer = styled.div`
@@ -132,6 +136,7 @@ export const CurrentTimeWrapper = styled.div`
 export const CurrentTime = styled(Text)`
   /* 14px */
   font-size: 0.875em;
+  user-select: none;
   color: ${colors.white};
   text-shadow: 0 1px 2px ${colors.transparentBlack[32]};
   font-feature-settings: 'tnum' on, 'lnum' on;
@@ -153,10 +158,6 @@ const backgroundContainerCss = css`
     display: none;
   }
 
-  .vjs-control-bar {
-    display: none;
-  }
-
   .vjs-error-display {
     display: block;
   }
@@ -186,7 +187,8 @@ export const Container = styled.div<ContainerProps>`
     background-color: ${colors.gray[900]};
   }
 
-  .vjs-error-display {
+  .vjs-error-display,
+  .vjs-control-bar {
     display: none;
   }
 
@@ -221,103 +223,6 @@ export const Container = styled.div<ContainerProps>`
     background-size: cover;
   }
 
-  .vjs-control-bar {
-    opacity: 0;
-    background: none;
-    align-items: flex-end;
-    height: 2em;
-    transition: opacity 200ms ${transitions.easing} !important;
-    z-index: ${zIndex.nearOverlay};
-
-    :hover {
-      & ~ ${ControlsOverlay} ${CustomControls} {
-        opacity: 0;
-        transform: translateY(0.5em);
-      }
-    }
-
-    .vjs-progress-control {
-      height: 2em;
-      z-index: ${zIndex.overlay};
-      position: absolute;
-      top: initial;
-      left: 0;
-      bottom: 0;
-      width: 100%;
-      padding: ${({ isFullScreen }) => (isFullScreen ? `1.5em 1.5em` : `0 0.5em`)} !important;
-
-      .vjs-slider {
-        align-self: flex-end;
-        height: 0.25em;
-        margin: 0;
-        background-color: ${colors.transparentWhite[32]};
-        transition: height ${transitions.timings.player} ${transitions.easing} !important;
-
-        :focus {
-          box-shadow: inset 0 0 0 3px ${colors.transparentPrimary[18]};
-        }
-
-        :focus-visible {
-          box-shadow: inset 0 0 0 3px ${colors.transparentPrimary[18]};
-        }
-
-        :focus:not(:focus-visible) {
-          box-shadow: unset;
-        }
-
-        .vjs-slider-bar {
-          background-color: ${colors.blue[500]};
-        }
-
-        /* ::before is progress timeline thumb */
-
-        .vjs-play-progress::before {
-          content: '';
-          position: absolute;
-          box-shadow: 0 1px 2px ${colors.transparentBlack[32]};
-          opacity: 0;
-          border-radius: 100%;
-          background: ${colors.white};
-          right: -0.5em;
-          height: 1em;
-          width: 1em;
-          top: -0.25em;
-          transition: opacity ${transitions.timings.player} ${transitions.easing};
-        }
-
-        .vjs-play-progress {
-          .vjs-time-tooltip {
-            display: none;
-          }
-        }
-
-        .vjs-load-progress {
-          background-color: ${colors.transparentWhite[32]};
-
-          > div {
-            display: none;
-          }
-        }
-      }
-
-      :hover .vjs-play-progress::before {
-        opacity: 1;
-      }
-
-      :hover .vjs-slider {
-        height: 0.5em;
-      }
-    }
-  }
-
-  :hover .vjs-control-bar {
-    opacity: 1;
-  }
-
-  .vjs-paused .vjs-control-bar {
-    opacity: 1;
-  }
-
   ${({ isInBackground }) => isInBackground && backgroundContainerCss};
 `
 

+ 10 - 5
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -1,4 +1,4 @@
-import { debounce } from 'lodash'
+import { debounce, round } from 'lodash'
 import React, { useCallback, useEffect, useRef, useState } from 'react'
 
 import { VideoFieldsFragment } from '@/api/queries'
@@ -18,6 +18,7 @@ import { Logger } from '@/utils/logger'
 import { formatDurationShort } from '@/utils/time'
 
 import { ControlsIndicator } from './ControlsIndicator'
+import { CustomTimeline } from './CustomTimeline'
 import { PlayerControlButton } from './PlayerControlButton'
 import { VideoOverlay } from './VideoOverlay'
 import {
@@ -212,9 +213,9 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
     const handler = (event: Event) => {
       if (event.type === 'play') {
+        setIsPlaying(true)
         if (playerState !== 'loading') {
           setPlayerState('playing')
-          setIsPlaying(true)
         }
       }
       if (event.type === 'pause') {
@@ -243,7 +244,10 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     if (!player) {
       return
     }
-    const handler = () => setVideoTime(Math.floor(player.currentTime()))
+    const handler = () => {
+      const currentTime = round(player.currentTime())
+      setVideoTime(currentTime)
+    }
     player.on('timeupdate', handler)
     return () => {
       player.off('timeupdate', handler)
@@ -425,6 +429,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
         {showPlayerControls && (
           <>
             <ControlsOverlay isFullScreen={isFullScreen}>
+              <CustomTimeline player={player} isFullScreen={isFullScreen} playerState={playerState} />
               <CustomControls isFullScreen={isFullScreen} isEnded={playerState === 'ended'}>
                 <PlayerControlButton
                   onClick={handlePlayPause}
@@ -450,7 +455,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
                 </VolumeControl>
                 <CurrentTimeWrapper>
                   <CurrentTime variant="body2">
-                    {formatDurationShort(videoTime)} / {formatDurationShort(Math.floor(player?.duration() || 0))}
+                    {formatDurationShort(videoTime)} / {formatDurationShort(round(player?.duration() || 0))}
                   </CurrentTime>
                 </CurrentTimeWrapper>
                 <ScreenControls>
@@ -478,8 +483,8 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
             />
           </>
         )}
+        {!isInBackground && <ControlsIndicator player={player} />}
       </div>
-      {!isInBackground && <ControlsIndicator player={player} />}
     </Container>
   )
 }

+ 1 - 7
src/shared/components/VideoPlayer/videoJsPlayer.ts

@@ -47,13 +47,7 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
       playsinline: true,
       loadingSpinner: false,
       bigPlayButton: false,
-      controlBar: {
-        // hide all videojs controls besides progress bar
-        children: [],
-        progressControl: {
-          seekBar: true,
-        },
-      },
+      controlBar: false,
     }
 
     const playerInstance = videojs(playerRef.current as Element, videoJsOptions)