Ver código fonte

Update player controls (#977)

* update videojs and videojs types

* move progress bar to the bottom

* add missing icons

* add custom controls

* refactor

* timeline fixes

* update full screen mode, update videojs background

* fix flickering hover animation

* styling fixes based on designer feedback

* conflict fix

* hotkeys improvements, add font-feature-settings

* fix issue with not updating volume slider when using arrows

* fix issues with mute hotkey

* refactor

* fix issue with not muted video on homepage

* refactor

* update volume step

* cr fixes

* cleaning, refactor
Bartosz Dryl 3 anos atrás
pai
commit
e61de04b8f

+ 7 - 7
src/providers/personalData/store.tsx

@@ -15,7 +15,7 @@ export type PersonalDataStoreState = {
   followedChannels: FollowedChannel[]
   recentSearches: RecentSearch[]
   dismissedMessages: DismissedMessage[]
-  playerVolume: number
+  cachedPlayerVolume: number
 }
 
 const WHITELIST = [
@@ -23,7 +23,7 @@ const WHITELIST = [
   'followedChannels',
   'recentSearches',
   'dismissedMessages',
-  'playerVolume',
+  'cachedPlayerVolume',
 ] as (keyof PersonalDataStoreState)[]
 
 export type PersonalDataStoreActions = {
@@ -31,14 +31,14 @@ export type PersonalDataStoreActions = {
   updateChannelFollowing: (id: string, follow: boolean) => void
   updateRecentSearches: (id: string, type: RecentSearchType) => void
   updateDismissedMessages: (id: string, add?: boolean) => void
-  updatePlayerVolume: (volume: number) => void
+  updateCachedPlayerVolume: (volume: number) => void
 }
 
 const watchedVideos = readFromLocalStorage<WatchedVideo[]>('watchedVideos') ?? []
 const followedChannels = readFromLocalStorage<FollowedChannel[]>('followedChannels') ?? []
 const recentSearches = readFromLocalStorage<RecentSearch[]>('recentSearches') ?? []
 const dismissedMessages = readFromLocalStorage<DismissedMessage[]>('dismissedMessages') ?? []
-const playerVolume = readFromLocalStorage<number>('playerVolume') ?? 1
+const cachedPlayerVolume = readFromLocalStorage<number>('playerVolume') ?? 1
 
 export const usePersonalDataStore = createStore<PersonalDataStoreState, PersonalDataStoreActions>(
   {
@@ -47,7 +47,7 @@ export const usePersonalDataStore = createStore<PersonalDataStoreState, Personal
       followedChannels,
       recentSearches,
       dismissedMessages,
-      playerVolume,
+      cachedPlayerVolume,
     },
     actionsFactory: (set) => ({
       updateWatchedVideos: (__typename, id, timestamp) => {
@@ -87,9 +87,9 @@ export const usePersonalDataStore = createStore<PersonalDataStoreState, Personal
           }
         })
       },
-      updatePlayerVolume: (volume) =>
+      updateCachedPlayerVolume: (volume) =>
         set((state) => {
-          state.playerVolume = volume
+          state.cachedPlayerVolume = volume
         }),
     }),
   },

+ 235 - 141
src/shared/components/VideoPlayer/VideoPlayer.style.tsx

@@ -1,12 +1,176 @@
 import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 
-import { colors, media, sizes, transitions, typography, zIndex } from '../../theme'
+import { SvgPlayerSoundOff } from '@/shared/icons'
+
+import { colors, sizes, transitions, zIndex } from '../../theme'
+import { Text } from '../Text'
 
 type ContainerProps = {
   isInBackground?: boolean
+  isFullScreen?: boolean
+}
+type CustomControlsProps = {
+  isFullScreen?: boolean
 }
 
+const focusStyles = css`
+  :focus {
+    /* Provide a fallback style for browsers
+     that don't support :focus-visible e.g safari */
+    box-shadow: inset 0 0 0 3px ${colors.transparentPrimary[18]};
+  }
+
+  /* https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */
+
+  :focus-visible {
+    box-shadow: inset 0 0 0 3px ${colors.transparentPrimary[18]};
+  }
+
+  :focus:not(:focus-visible) {
+    box-shadow: unset;
+  }
+`
+
+export const CustomControls = styled.div<CustomControlsProps>`
+  position: absolute;
+  height: 100%;
+  bottom: ${({ isFullScreen }) => (isFullScreen ? sizes(10) : sizes(4))};
+  padding: 0 ${sizes(4)};
+  left: 0;
+  z-index: ${zIndex.overlay};
+  display: flex;
+  align-items: flex-end;
+  width: 100%;
+  opacity: 0;
+  transition: transform 200ms ${transitions.easing}, opacity 200ms ${transitions.easing};
+`
+
+export const ControlButton = styled.button`
+  margin-right: ${sizes(2)};
+  cursor: pointer;
+  border: none;
+  border-radius: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: ${sizes(2)};
+  transition: background-color ${transitions.timings.player} ${transitions.easing} !important;
+
+  & > svg {
+    filter: drop-shadow(0 1px 2px ${colors.transparentBlack[32]});
+  }
+
+  :hover {
+    background-color: ${colors.transparentPrimary[18]};
+    backdrop-filter: blur(${sizes(8)});
+  }
+
+  :active {
+    background-color: ${colors.transparentPrimary[10]};
+  }
+
+  ${focusStyles}
+`
+
+export const VolumeSliderContainer = styled.div`
+  display: flex;
+  align-items: center;
+`
+
+export const thumbStyles = css`
+  appearance: none;
+  border: none;
+  background: ${colors.white};
+  width: ${sizes(3)};
+  height: ${sizes(3)};
+  border-radius: 100%;
+  cursor: pointer;
+`
+
+export const VolumeSlider = styled.input`
+  appearance: none;
+  border-radius: 2px;
+  margin: 0;
+  padding: 0;
+  width: ${sizes(16)};
+  height: ${sizes(1)};
+  background: linear-gradient(
+    to right,
+    ${colors.white} 0%,
+    ${colors.white} ${({ value }) => (value ? Number(value) * 100 : 0)}%,
+    ${colors.transparentWhite[32]} 30%,
+    ${colors.transparentWhite[32]} 100%
+  );
+  outline: none;
+  opacity: 0;
+  transform-origin: left;
+  transform: scaleX(0);
+  transition: transform ${transitions.timings.player} ${transitions.easing},
+    opacity ${transitions.timings.player} ${transitions.easing};
+
+  ::-moz-range-thumb {
+    ${thumbStyles};
+  }
+
+  ::-webkit-slider-thumb {
+    ${thumbStyles};
+  }
+`
+
+export const VolumeControl = styled.div`
+  display: flex;
+  border-radius: 20px;
+  width: ${sizes(10)};
+  transition: background-color ${transitions.timings.sharp} ${transitions.easing},
+    width ${transitions.timings.sharp} ${transitions.easing};
+
+  :hover {
+    background-color: ${colors.transparentPrimary[18]};
+    backdrop-filter: blur(${sizes(8)});
+    width: ${sizes(30)};
+    ${VolumeSlider} {
+      opacity: 1;
+      transform: scaleX(1);
+    }
+  }
+`
+export const VolumeButton = styled(ControlButton)`
+  cursor: pointer;
+  margin-right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :hover {
+    /* already set by VolumeControl */
+    background-color: unset;
+    backdrop-filter: unset;
+  }
+`
+
+export const StyledSvgPlayerSoundOff = styled(SvgPlayerSoundOff)`
+  opacity: 0.5;
+`
+
+export const CurrentTime = styled(Text)`
+  display: flex;
+  height: ${sizes(10)};
+  color: ${colors.white};
+  margin-left: ${sizes(4)};
+  text-shadow: 0 1px 2px ${colors.transparentBlack[32]};
+  align-items: center;
+  font-feature-settings: 'tnum' on, 'lnum' on;
+`
+
+export const ScreenControls = styled.div`
+  margin-left: auto;
+
+  ${ControlButton}:last-of-type {
+    margin-right: 0;
+  }
+`
+
 const backgroundContainerCss = css`
   .vjs-waiting .vjs-loading-spinner {
     display: none;
@@ -32,8 +196,21 @@ export const Container = styled.div<ContainerProps>`
   position: relative;
   height: 100%;
 
-  *:focus {
-    outline: none;
+  .video-js {
+    background-color: ${colors.gray[900]};
+  }
+
+  .vjs-playing:hover ${CustomControls} {
+    transform: translateY(-${sizes(2)});
+    opacity: 1;
+  }
+  .vjs-paused ${CustomControls} {
+    transform: translateY(-${sizes(2)});
+    opacity: 1;
+  }
+
+  .vjs-user-inactive.vjs-playing > ${CustomControls} {
+    opacity: 0;
   }
 
   .vjs-poster {
@@ -41,172 +218,89 @@ export const Container = styled.div<ContainerProps>`
   }
 
   .vjs-control-bar {
-    font-family: ${typography.fonts.base};
-    background: none;
-    margin-top: auto;
-    z-index: ${zIndex.overlay + 1};
-    align-items: center;
-    height: ${sizes(16)} !important;
-
-    ${media.small} {
-      padding: 5px ${sizes(8)} 0;
-      background-color: rgba(0, 0, 0, 0.3);
-    }
-
-    .vjs-control {
-      height: 30px;
-
-      .vjs-icon-placeholder ::before {
-        line-height: 1.25;
-        font-size: ${typography.sizes.icon.xlarge};
-      }
-    }
-
-    .vjs-time-control {
-      display: inline-block;
-      font-size: ${typography.sizes.caption};
-      user-select: none;
-      height: unset;
-    }
-
-    .vjs-play-control {
-      order: -5;
-    }
-
-    .vjs-current-time {
-      order: -4;
-      padding-right: 0;
-    }
-
-    .vjs-time-divider {
-      order: -3;
-      padding: 0 4px;
-      min-width: 0;
-    }
-
-    .vjs-duration {
-      order: -2;
-      padding-left: 0;
-    }
-
-    .vjs-volume-panel {
-      order: -1;
-    }
-
-    .vjs-remaining-time {
-      display: none;
-    }
-
-    .vjs-picture-in-picture-control {
-      display: none;
-
-      ${media.small} {
-        display: block;
-        margin-left: auto;
-      }
-    }
-
-    .vjs-fullscreen-control {
-      margin-left: auto;
-      ${media.small} {
-        margin-left: 0;
-      }
-    }
-
-    /* 
-  Targeting firefox, picture-in-picture in firefox is not placed in the bar,
-  thus we need margin-left to move button to the right side
-   */
-    @-moz-document url-prefix() {
-      .vjs-fullscreen-control {
-        margin-left: auto;
-      }
-    }
-
-    .vjs-slider {
-      background-color: ${colors.gray[400]};
-
-      .vjs-slider-bar,
-      .vjs-volume-level {
-        background-color: ${colors.blue[500]};
+    opacity: 0;
+    background: linear-gradient(180deg, rgba(11, 12, 15, 0) 0%, #0b0c0f 100%);
+    align-items: flex-end;
+    height: ${({ isFullScreen }) => (isFullScreen ? sizes(40) : sizes(32))} !important;
+    transition: opacity ${transitions.timings.player} ${transitions.easing};
+
+    :hover {
+      & ~ ${CustomControls} {
+        opacity: 0;
+        transform: translateY(${sizes(2)});
       }
     }
 
     .vjs-progress-control {
+      height: ${sizes(8)};
+      z-index: ${zIndex.nearOverlay};
       position: absolute;
-      transition: none !important;
       top: initial;
-      height: 2px;
       left: 0;
+      bottom: 0;
       width: 100%;
-      bottom: -2px;
-
-      ${media.small} {
-        top: 0;
-        left: ${sizes(8)};
-        width: calc(100% - 2 * ${sizes(8)});
-        height: 5px;
-      }
+      padding: ${({ isFullScreen }) => (isFullScreen ? `${sizes(6)} ${sizes(6)}` : `0 ${sizes(2)}`)} !important;
 
-      .vjs-progress-holder {
-        height: 100%;
+      .vjs-slider {
+        align-self: flex-end;
+        height: ${sizes(1)};
         margin: 0;
+        background-color: ${colors.transparentWhite[32]};
+        transition: height ${transitions.timings.player} ${transitions.easing} !important;
+
+        ${focusStyles}
+
+        .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: -${sizes(2)};
+          width: ${sizes(4)};
+          height: ${sizes(4)};
+          top: -${sizes(1)};
+          transition: opacity ${transitions.timings.player} ${transitions.easing};
+        }
 
         .vjs-play-progress {
           .vjs-time-tooltip {
             display: none;
           }
-
-          ::before {
-            position: absolute;
-            top: -5px;
-            content: '';
-            display: initial;
-            width: 14px;
-            height: 14px;
-            background: ${colors.blue[500]};
-            border-radius: 100%;
-            border: 2px solid ${colors.white};
-
-            ${media.small} {
-              display: none;
-            }
-          }
         }
 
         .vjs-load-progress {
-          background-color: ${colors.gray[200]};
+          background-color: ${colors.transparentWhite[32]};
 
-          div {
-            background: none;
+          > div {
+            display: none;
           }
         }
       }
-    }
-
-    .vjs-volume-control {
-      width: 72px !important;
-
-      .vjs-volume-bar {
-        width: 72px;
-        margin-left: 0;
-        margin-right: 0;
-        height: 4px;
 
-        .vjs-volume-level {
-          height: 4px;
+      :hover .vjs-play-progress::before {
+        opacity: 1;
+      }
 
-          ::before {
-            font-size: ${typography.sizes.icon.small};
-            top: -0.25em;
-          }
-        }
+      :hover .vjs-slider {
+        height: ${sizes(2)};
       }
     }
   }
 
-  .vjs-big-play-button {
-    display: none !important;
+  :hover .vjs-control-bar {
+    opacity: 1;
+  }
+
+  .vjs-paused .vjs-control-bar {
+    opacity: 1;
   }
 
   ${({ isInBackground }) => isInBackground && backgroundContainerCss};

+ 228 - 51
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -1,12 +1,36 @@
 import { debounce } from 'lodash'
 import React, { useEffect, useRef, useState } from 'react'
+import { useCallback } from 'react'
 
 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 { 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 = {
   className?: string
@@ -15,41 +39,68 @@ export type VideoPlayerProps = {
   playing?: boolean
 } & VideoJsConfig
 
+declare global {
+  interface Document {
+    pictureInPictureEnabled: boolean
+    pictureInPictureElement: Element
+  }
+}
+
+const isPiPSupported = 'pictureInPictureEnabled' in document
+
 const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, VideoPlayerProps> = (
   { className, autoplay, isInBackground, playing, ...videoJsConfig },
   externalRef
 ) => {
   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 [initialized, setInitialized] = useState(false)
 
   const displayPlayOverlay = playOverlayVisible && !isInBackground
 
-  useEffect(() => {
+  const playVideo = useCallback(() => {
     if (!player) {
       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 = () => {
       setInitialized(true)
     }
-
     player.on('loadstart', handler)
-
     return () => {
       player.off('loadstart', handler)
     }
   }, [player])
 
+  // handle autoplay
   useEffect(() => {
     if (!player || !initialized || !autoplay) {
       return
     }
-
     const playPromise = player.play()
     if (playPromise) {
       playPromise.catch((e) => {
@@ -58,42 +109,35 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
   }, [player, initialized, autoplay])
 
+  // handle playing and pausing from outside the component
   useEffect(() => {
     if (!player) {
       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(() => {
     if (!player) {
       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 () => {
-      player.off('play', handler)
+      player.off(['play', 'pause'], handler)
     }
   }, [player])
 
@@ -108,44 +152,177 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
   }, [externalRef, playerRef])
 
-  const handlePlayOverlayClick = () => {
+  // handle video timer
+  useEffect(() => {
     if (!player) {
       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(
     debounce((volume: number) => {
-      updatePlayerVolume(volume)
-    }, 500)
+      updateCachedPlayerVolume(volume)
+    }, 125)
   )
-
-  const isInitialMount = useRef(true)
+  // update volume on mouse input
   useEffect(() => {
-    if (!player || !isInitialMount) {
+    if (!player || isInBackground) {
       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 (
-    <Container className={className} isInBackground={isInBackground}>
+    <Container isFullScreen={isFullScreen} className={className} isInBackground={isInBackground}>
       {displayPlayOverlay && (
-        <PlayOverlay onClick={handlePlayOverlayClick}>
+        <PlayOverlay onClick={handlePlayPause}>
           <SvgOutlineVideo width={72} height={72} viewBox="0 0 24 24" />
         </PlayOverlay>
       )}
       <div data-vjs-player>
         <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>
     </Container>
   )

+ 20 - 9
src/shared/components/VideoPlayer/videoJsPlayer.ts

@@ -18,9 +18,11 @@ export type VideoJsConfig = {
   onTimeUpdated?: (time: number) => void
 }
 
+export const VOLUME_STEP = 0.1
+
 const hotkeysHandler = (event: videojs.KeyboardEvent, playerInstance: VideoJsPlayer) => {
   const currentTime = playerInstance.currentTime()
-  const currentVolume = playerInstance.volume()
+  const currentVolume = Number(playerInstance.volume().toFixed(2))
   const isMuted = playerInstance.muted()
   const isFullscreen = playerInstance.isFullscreen()
   const isPaused = playerInstance.paused()
@@ -47,23 +49,24 @@ const hotkeysHandler = (event: videojs.KeyboardEvent, playerInstance: VideoJsPla
       playerInstance.currentTime(currentTime + 10)
       return
     case 'ArrowUp':
-      if (currentVolume <= 0.95) {
-        playerInstance.volume(currentVolume + 0.05)
-      } else {
-        playerInstance.volume(1)
+      if (playerInstance.muted()) {
+        playerInstance.muted(false)
+      }
+      if (currentVolume <= 1) {
+        playerInstance.volume(Math.min(currentVolume + VOLUME_STEP, 1))
       }
       return
     case 'ArrowDown':
-      if (currentVolume >= 0.05) {
-        playerInstance.volume(currentVolume - 0.05)
-      } else {
-        playerInstance.volume(0)
+      if (currentVolume >= 0) {
+        playerInstance.volume(Math.max(currentVolume - VOLUME_STEP, 0))
       }
       return
     case 'KeyM':
       if (isMuted) {
+        playerInstance.trigger('unmute')
         playerInstance.muted(false)
       } else {
+        playerInstance.trigger('mute')
         playerInstance.muted(true)
       }
       return
@@ -106,9 +109,17 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
       controls: true,
       // @ts-ignore @types/video.js is outdated and doesn't provide types for some newer video.js features
       playsinline: true,
+      bigPlayButton: false,
       userActions: {
         hotkeys: (event) => hotkeysHandler(event, playerInstance),
       },
+      controlBar: {
+        // hide all videojs controls besides progress bar
+        children: [],
+        progressControl: {
+          seekBar: true,
+        },
+      },
     }
 
     const playerInstance = videojs(playerRef.current as Element, videoJsOptions)

+ 34 - 0
src/shared/icons/PlayerPipDisable.tsx

@@ -0,0 +1,34 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerPipDisable = (props: React.SVGProps<SVGSVGElement>) => (
+  <svg width={24} height={25} viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <g filter="url(#player-pip-disable_svg__filter0_d)" fill="#F4F6F8">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M21.192 19.777l-1.101-1.101c.547-.357.909-.975.909-1.677V7a2 2 0 00-2-2H6.414L4.222 2.807 2.808 4.22l16.97 16.97 1.414-1.414zM19 17h-.586l-2-2H17v-4h-4.586l-4-4H19v10z"
+      />
+      <path d="M3 17V7l2 2v8h8l2 2H5a2 2 0 01-2-2z" />
+    </g>
+    <defs>
+      <filter
+        id="player-pip-disable_svg__filter0_d"
+        x={-2}
+        y={-1}
+        width={28}
+        height={28}
+        filterUnits="userSpaceOnUse"
+        colorInterpolationFilters="sRGB"
+      >
+        <feFlood floodOpacity={0} result="BackgroundImageFix" />
+        <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+        <feOffset dy={1} />
+        <feGaussianBlur stdDeviation={1} />
+        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0" />
+        <feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
+        <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
+      </filter>
+    </defs>
+  </svg>
+)

+ 13 - 0
src/shared/icons/PlayerSoundHalf.tsx

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerSoundHalf = (props: React.SVGProps<SVGSVGElement>) => (
+  <svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M10 4L6 9H3a1 1 0 00-1 1v4a1 1 0 001 1h3l4 5h2V4h-2zm4 6a2 2 0 110 4v2a4 4 0 000-8v2z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

+ 2 - 0
src/shared/icons/index.tsx

@@ -77,10 +77,12 @@ export * from './OutlineZoomOut'
 export * from './PlayerFullScreen'
 export * from './PlayerNext'
 export * from './PlayerPause'
+export * from './PlayerPipDisable'
 export * from './PlayerPip'
 export * from './PlayerPlay'
 export * from './PlayerPlaylistAdd'
 export * from './PlayerPlaylist'
 export * from './PlayerSmallScreen'
+export * from './PlayerSoundHalf'
 export * from './PlayerSoundOff'
 export * from './PlayerSoundOn'

+ 17 - 0
src/shared/icons/svgs/player-pip-disable.svg

@@ -0,0 +1,17 @@
+<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g filter="url(#filter0_d)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21.1924 19.7772L20.0909 18.6757C20.6382 18.3188 21 17.7012 21 16.9991V6.9991C21 5.89453 20.1045 4.9991 19 4.9991H6.41429L4.22183 2.80664L2.80762 4.22085L19.7782 21.1914L21.1924 19.7772ZM19 16.9991H18.4143L16.4143 14.9991H17V10.9991H12.4143L8.41429 6.9991H19V16.9991Z" fill="#F4F6F8"/>
+<path d="M2.99995 16.9991V6.9991L4.99995 8.9991V16.9991H13L15 18.9991H4.99995C3.89538 18.9991 2.99995 18.1037 2.99995 16.9991Z" fill="#F4F6F8"/>
+</g>
+<defs>
+<filter id="filter0_d" x="-2" y="-1" width="28" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
+<feOffset dy="1"/>
+<feGaussianBlur stdDeviation="1"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
+</filter>
+</defs>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-sound-half.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10 4L6 9H3C2.44772 9 2 9.44772 2 10V14C2 14.5523 2.44772 15 3 15H6L10 20H12V4H10ZM14 10C15.1046 10 16 10.8954 16 12C16 13.1046 15.1046 14 14 14V16C16.2091 16 18 14.2091 18 12C18 9.79086 16.2091 8 14 8V10Z" fill="#F4F6F8"/>
+</svg>

+ 1 - 0
src/shared/theme/colors.ts

@@ -61,6 +61,7 @@ export default {
   },
   transparentBlack: {
     24: 'rgba(0,0,0, 0.24)',
+    32: 'rgba(0,0,0, 0.32)',
     54: 'rgba(0,0,0, 0.54)',
     66: 'rgba(0,0,0, 0.66)',
     86: 'rgba(0,0,0, 0.86)',

+ 1 - 0
src/shared/theme/transitions.ts

@@ -6,6 +6,7 @@ const transitions = {
     regular: '400ms',
     routingSearchOverlay: '400ms',
     routing: '300ms',
+    player: '150ms',
     sharp: '125ms',
   },
   names: {