Browse Source

Ending, loading & error player screen (#996)

* add missing restart icon

* update CirculaarProgressbar

* add player ending screen

* install lottie and add lottie animation and fallback

* add loading overlay

* add BigPlayButtonOverlay

* add restart glyph icon

* add EndingOverlay

* rebase

* fix loading fallback image

* add ErrorOverlay, fix position issues

* fix player container height

* fix indicator sizes for mobile

* fix error animation

* add current thumbnail

* overlays improvements

* refactor, move overlays to separate folder

* create ControlsIndicatorManager, bring back indicator on video click

* add short transition for videos overlay

* remove previous animation files, use Loaded and AnimatedError

* remove player loader fallback

* remove videojs default error screen

* cr fixes

* timeout type
Bartosz Dryl 3 years ago
parent
commit
c5a29d6302

+ 2 - 0
src/config/urls.ts

@@ -12,3 +12,5 @@ export const WEB3_APP_NAME = 'Joystream Atlas'
 export const STORAGE_URL_PATH = 'asset/v0'
 
 export const COVER_VIDEO_INFO_URL = 'https://eu-central-1.linodeobjects.com/atlas-hero/cover-info.json'
+
+export const JOYSTREAM_DISCORD_URL = 'https://discord.gg/DE9UN3YpRP'

+ 22 - 5
src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx

@@ -1,20 +1,37 @@
 import styled from '@emotion/styled'
 
-import theme from '@/shared/theme'
+import { colors } from '@/shared/theme'
 
 import { Path } from './Path'
 
+export type TrailVariant = 'default' | 'player'
+
+type TrailProps = {
+  variant?: TrailVariant
+}
+
+const getStrokeColor = (variant?: TrailVariant) => {
+  switch (variant) {
+    case 'default':
+      return colors.gray[700]
+    case 'player':
+      return colors.transparentWhite[32]
+    default:
+      return colors.gray[700]
+  }
+}
+
 export const SVG = styled.svg`
   /* needed when parent container has display: flex */
   width: 100%;
 `
-export const Trail = styled(Path)`
-  stroke: ${theme.colors.gray[700]};
+export const Trail = styled(Path)<TrailProps>`
+  stroke: ${({ variant }) => getStrokeColor(variant)};
 `
 export const StyledPath = styled(Path)`
-  stroke: ${theme.colors.blue[500]};
+  stroke: ${colors.blue[500]};
   transition: stroke-dashoffset 0.5s ease 0s;
 `
 export const Background = styled.circle`
-  fill: ${theme.colors.gray[800]};
+  fill: ${colors.gray[800]};
 `

+ 14 - 7
src/shared/components/CircularProgressbar/CircularProgressbar.tsx

@@ -1,6 +1,6 @@
 import * as React from 'react'
 
-import { Background, SVG, StyledPath, Trail } from './CircularProgressbar.style'
+import { Background, SVG, StyledPath, Trail, TrailVariant } from './CircularProgressbar.style'
 
 export const VIEWBOX_WIDTH = 100
 export const VIEWBOX_HEIGHT = 100
@@ -18,6 +18,8 @@ export type CircularProgressbarProps = {
   background?: boolean
   backgroundPadding?: number
   className?: string
+  variant?: TrailVariant
+  noTrail?: boolean
 }
 
 export const CircularProgressbar: React.FC<CircularProgressbarProps> = ({
@@ -29,6 +31,8 @@ export const CircularProgressbar: React.FC<CircularProgressbarProps> = ({
   maxValue = 100,
   minValue = 0,
   strokeWidth = 15,
+  variant = 'default',
+  noTrail,
   className,
 }) => {
   const getBackgroundPadding = () => (background ? backgroundPadding : 0)
@@ -46,12 +50,15 @@ export const CircularProgressbar: React.FC<CircularProgressbarProps> = ({
     <>
       <SVG viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`} className={className}>
         {background ? <Background cx={VIEWBOX_CENTER_X} cy={VIEWBOX_CENTER_Y} r={VIEWBOX_HEIGHT_HALF} /> : null}
-        <Trail
-          counterClockwise={counterClockwise}
-          dashRatio={circleRatio}
-          pathRadius={pathRadius}
-          strokeWidth={strokeWidth}
-        />
+        {noTrail && (
+          <Trail
+            counterClockwise={counterClockwise}
+            dashRatio={circleRatio}
+            pathRadius={pathRadius}
+            variant={variant}
+            strokeWidth={strokeWidth}
+          />
+        )}
         <StyledPath
           counterClockwise={counterClockwise}
           dashRatio={pathRatio * circleRatio}

+ 83 - 0
src/shared/components/VideoPlayer/ControlsIndicator.style.ts

@@ -0,0 +1,83 @@
+import styled from '@emotion/styled'
+
+import { colors, media, sizes } from '@/shared/theme'
+
+export const ControlsIndicatorWrapper = styled.div`
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  top: calc(50% - ${sizes(10)});
+  left: calc(50% - ${sizes(10)});
+  ${media.small} {
+    top: calc(50% - ${sizes(16)});
+    left: calc(50% - ${sizes(16)});
+  }
+`
+
+export const ControlsIndicatorIconWrapper = styled.div`
+  width: ${sizes(20)};
+  height: ${sizes(20)};
+  backdrop-filter: blur(${sizes(6)});
+  background-color: ${colors.transparentBlack[54]};
+  border-radius: 100%;
+  display: flex;
+  transform: scale(0.5);
+  justify-content: center;
+  align-items: center;
+
+  > svg {
+    transform: scale(0.75);
+    width: ${sizes(12)};
+    height: ${sizes(12)};
+  }
+  ${media.small} {
+    width: ${sizes(32)};
+    height: ${sizes(32)};
+
+    > svg {
+      transform: scale(0.75);
+      width: ${sizes(18)};
+      height: ${sizes(18)};
+    }
+  }
+`
+
+export const ControlsIndicatorTooltip = styled.div`
+  user-select: none;
+  display: none;
+  align-self: center;
+  background-color: ${colors.transparentBlack[54]};
+  padding: ${sizes(2)};
+  text-align: center;
+  margin-top: ${sizes(3)};
+  backdrop-filter: blur(${sizes(8)});
+
+  ${media.small} {
+    display: block;
+  }
+`
+
+const animationEasing = 'cubic-bezier(0, 0, 0.3, 1)'
+
+export const ControlsIndicatorTransitions = styled.div`
+  .indicator-exit {
+    opacity: 1;
+  }
+
+  .indicator-exit-active {
+    ${ControlsIndicatorIconWrapper} {
+      transform: scale(1);
+      opacity: 0;
+      transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing};
+
+      > svg {
+        transform: scale(1);
+        transition: transform 750ms ${animationEasing};
+      }
+    }
+    ${ControlsIndicatorTooltip} {
+      opacity: 0;
+      transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing};
+    }
+  }
+`

+ 157 - 0
src/shared/components/VideoPlayer/ControlsIndicator.tsx

@@ -0,0 +1,157 @@
+import React, { useEffect, useState } from 'react'
+import { CSSTransition } from 'react-transition-group'
+import { VideoJsPlayer } from 'video.js'
+
+import {
+  SvgPlayerBackwardFiveSec,
+  SvgPlayerBackwardTenSec,
+  SvgPlayerForwardFiveSec,
+  SvgPlayerForwardTenSec,
+  SvgPlayerPause,
+  SvgPlayerPlay,
+  SvgPlayerSoundHalf,
+  SvgPlayerSoundOff,
+  SvgPlayerSoundOn,
+} from '@/shared/icons'
+
+import {
+  ControlsIndicatorIconWrapper,
+  ControlsIndicatorTooltip,
+  ControlsIndicatorTransitions,
+  ControlsIndicatorWrapper,
+} from './ControlsIndicator.style'
+import { CustomVideojsEvents } from './videoJsPlayer'
+
+import { Text } from '../Text'
+
+type VideoEvent = CustomVideojsEvents | null
+
+type EventState = {
+  type: VideoEvent
+  description: string | null
+  icon: React.ReactNode | null
+  isVisible: boolean
+}
+
+type ControlsIndicatorProps = {
+  player: VideoJsPlayer | null
+}
+
+export const ControlsIndicator: React.FC<ControlsIndicatorProps> = ({ player }) => {
+  const [indicator, setIndicator] = useState<EventState | null>(null)
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    let timeout: number
+    const indicatorEvents = Object.values(CustomVideojsEvents)
+
+    const handler = (e: Event) => {
+      // This setTimeout is needed to get current value from `player.volume()`
+      // if we omit this we'll get stale results
+      timeout = window.setTimeout(() => {
+        const indicator = createIndicator(e.type as VideoEvent, player.volume(), player.muted())
+        if (indicator) {
+          setIndicator({ ...indicator, isVisible: true })
+        }
+      }, 0)
+    }
+    player.on(indicatorEvents, handler)
+
+    return () => {
+      clearTimeout(timeout)
+      player.off(indicatorEvents, handler)
+    }
+  }, [player])
+
+  return (
+    <ControlsIndicatorTransitions>
+      <CSSTransition
+        in={indicator?.isVisible}
+        timeout={indicator?.isVisible ? 0 : 750}
+        classNames="indicator"
+        mountOnEnter
+        unmountOnExit
+        onEntered={() => setIndicator((indicator) => (indicator ? { ...indicator, isVisible: false } : null))}
+        onExited={() => setIndicator(null)}
+      >
+        <ControlsIndicatorWrapper>
+          <ControlsIndicatorIconWrapper>{indicator?.icon}</ControlsIndicatorIconWrapper>
+          <ControlsIndicatorTooltip>
+            <Text variant="caption">{indicator?.description}</Text>
+          </ControlsIndicatorTooltip>
+        </ControlsIndicatorWrapper>
+      </CSSTransition>
+    </ControlsIndicatorTransitions>
+  )
+}
+
+const createIndicator = (type: VideoEvent | null, playerVolume: number, playerMuted: boolean) => {
+  const formattedVolume = Math.floor(playerVolume * 100) + '%'
+  const isMuted = playerMuted || !Number(playerVolume.toFixed(2))
+
+  switch (type) {
+    case CustomVideojsEvents.PauseControl:
+      return {
+        icon: <SvgPlayerPause />,
+        description: 'Pause',
+        type,
+      }
+    case CustomVideojsEvents.PlayControl:
+      return {
+        icon: <SvgPlayerPlay />,
+        description: 'Play',
+        type,
+      }
+    case CustomVideojsEvents.BackwardFiveSec:
+      return {
+        icon: <SvgPlayerBackwardFiveSec />,
+        description: 'Backward 5s',
+        type,
+      }
+    case CustomVideojsEvents.ForwardFiveSec:
+      return {
+        icon: <SvgPlayerForwardFiveSec />,
+        description: 'Forward 5s',
+        type,
+      }
+    case CustomVideojsEvents.BackwardTenSec:
+      return {
+        icon: <SvgPlayerBackwardTenSec />,
+        description: 'Backward 10s',
+        type,
+      }
+    case CustomVideojsEvents.ForwardTenSec:
+      return {
+        icon: <SvgPlayerForwardTenSec />,
+        description: 'Forward 10s',
+        type,
+      }
+    case CustomVideojsEvents.Unmuted:
+      return {
+        icon: <SvgPlayerSoundOn />,
+        description: formattedVolume,
+        type,
+      }
+    case CustomVideojsEvents.Muted:
+      return {
+        icon: <SvgPlayerSoundOff />,
+        description: 'Mute',
+        type,
+      }
+    case CustomVideojsEvents.VolumeIncrease:
+      return {
+        icon: <SvgPlayerSoundOn />,
+        description: formattedVolume,
+        type,
+      }
+    case CustomVideojsEvents.VolumeDecrease:
+      return {
+        icon: isMuted ? <SvgPlayerSoundOff /> : <SvgPlayerSoundHalf />,
+        description: isMuted ? 'Mute' : formattedVolume,
+        type,
+      }
+    default:
+      return null
+  }
+}

+ 71 - 0
src/shared/components/VideoPlayer/VideoOverlay.tsx

@@ -0,0 +1,71 @@
+import React, { useEffect, useState } from 'react'
+import { CSSTransition, SwitchTransition } from 'react-transition-group'
+
+import { useVideos } from '@/api/hooks'
+import { AssetAvailability, VideoFieldsFragment } from '@/api/queries'
+import { transitions } from '@/shared/theme'
+import { getRandomIntInclusive } from '@/utils/number'
+
+import { EndingOverlay, ErrorOverlay, LoadingOverlay } from './VideoOverlays'
+import { PlayerState } from './VideoPlayer'
+
+type VideoOverlaProps = {
+  playerState: PlayerState
+  onPlay: () => void
+  channelId?: string
+  currentThumbnailUrl?: string | null
+  videoId?: string
+}
+export const VideoOverlay: React.FC<VideoOverlaProps> = ({
+  playerState,
+  onPlay,
+  channelId,
+  currentThumbnailUrl,
+  videoId,
+}) => {
+  const [randomNextVideo, setRandomNextVideo] = useState<VideoFieldsFragment | null>(null)
+  const { videos } = useVideos({
+    where: {
+      channelId_eq: channelId,
+      isPublic_eq: true,
+      mediaAvailability_eq: AssetAvailability.Accepted,
+    },
+  })
+
+  useEffect(() => {
+    if (!videos?.length || videos.length <= 1) {
+      return
+    }
+    const filteredVideos = videos.filter((video) => video.id !== videoId)
+    const randomNumber = getRandomIntInclusive(0, filteredVideos.length - 1)
+
+    setRandomNextVideo(filteredVideos[randomNumber])
+  }, [videoId, videos])
+
+  return (
+    <SwitchTransition>
+      <CSSTransition
+        key={playerState}
+        timeout={playerState !== 'error' ? parseInt(transitions.timings.sharp) : 0}
+        classNames={transitions.names.fade}
+        mountOnEnter
+        unmountOnExit
+        appear
+      >
+        <div>
+          {playerState === 'loading' && <LoadingOverlay onPlay={onPlay} />}
+          {playerState === 'ended' && (
+            <EndingOverlay
+              isEnded={true}
+              onPlayAgain={onPlay}
+              channelId={channelId}
+              currentThumbnailUrl={currentThumbnailUrl}
+              randomNextVideo={randomNextVideo}
+            />
+          )}
+          {playerState === 'error' && <ErrorOverlay />}
+        </div>
+      </CSSTransition>
+    </SwitchTransition>
+  )
+}

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

@@ -0,0 +1,142 @@
+import styled from '@emotion/styled'
+import { fluidRange } from 'polished'
+
+import { ChannelLink } from '@/components/ChannelLink'
+import { breakpoints, colors, media, sizes, zIndex } from '@/shared/theme'
+
+import { Button } from '../../Button'
+import { CircularProgressbar } from '../../CircularProgressbar'
+import { IconButton } from '../../IconButton'
+import { Text } from '../../Text'
+
+type OverlayBackgroundProps = {
+  thumbnailUrl?: string | null
+}
+
+export const OverlayBackground = styled.div<OverlayBackgroundProps>`
+  position: absolute;
+  overflow: auto;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: ${zIndex.overlay};
+  background-image: ${({ thumbnailUrl }) =>
+    `linear-gradient(to right, ${colors.transparentBlack[86]}, ${colors.transparentBlack[86]}), url(${thumbnailUrl}) `};
+  background-size: cover;
+  height: 100%;
+`
+
+type InnerContainerProps = {
+  isFullScreen?: boolean
+}
+
+export const InnerContainer = styled.div<InnerContainerProps>`
+  padding: ${sizes(4)};
+  height: calc(100% - 72px);
+  overflow-y: auto;
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+
+  ${media.small} {
+    flex-direction: column;
+    padding: ${sizes(6)};
+  }
+`
+
+export const VideoInfo = styled.div`
+  margin: auto;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  ${media.small} {
+    margin: unset;
+  }
+`
+
+export const SubHeading = styled(Text)`
+  text-align: center;
+`
+
+export const Heading = styled(Text)`
+  ${fluidRange({ prop: 'fontSize', fromSize: sizes(6), toSize: sizes(8) }, breakpoints.base, breakpoints.large)};
+
+  margin-top: ${sizes(4)};
+  flex-shrink: 0;
+  max-width: 560px;
+  word-break: break-all;
+  width: 100%;
+  text-align: center;
+`
+
+type StyledChannelLinkProps = {
+  noNextVideo?: boolean
+}
+export const StyledChannelLink = styled(ChannelLink)<StyledChannelLinkProps>`
+  flex-shrink: 0;
+  margin-top: ${({ noNextVideo }) => (noNextVideo ? sizes(2) : sizes(4))};
+
+  ${media.small} {
+    margin-top: ${({ noNextVideo }) => (noNextVideo ? sizes(2) : sizes(3))};
+  }
+
+  span {
+    font-size: ${({ noNextVideo }) => (noNextVideo ? sizes(5) : '14px')};
+    display: flex;
+    align-items: center;
+    margin-left: ${sizes(2)};
+    ${media.small} {
+      font-size: ${({ noNextVideo }) => (noNextVideo ? sizes(10) : sizes(4))};
+      margin-left: ${sizes(3)};
+    }
+  }
+
+  div {
+    width: ${sizes(6)};
+    height: ${sizes(6)};
+    min-width: ${sizes(6)};
+    ${media.small} {
+      width: ${({ noNextVideo }) => (noNextVideo ? sizes(10) : sizes(8))};
+      height: ${({ noNextVideo }) => (noNextVideo ? sizes(10) : sizes(8))};
+      min-width: ${({ noNextVideo }) => (noNextVideo ? sizes(10) : sizes(8))};
+    }
+  }
+`
+export const CountDownWrapper = styled.div`
+  flex-shrink: 0;
+  margin: ${sizes(6)} ${sizes(4)};
+  position: relative;
+  display: flex;
+  height: ${sizes(14)};
+  justify-content: center;
+  align-items: center;
+`
+
+export const StyledCircularProgressBar = styled(CircularProgressbar)`
+  width: ${sizes(14)};
+  height: ${sizes(14)};
+`
+
+export const CountDownButton = styled(IconButton)`
+  /* we need important, because video.js is setting this value to inline-block */
+  display: block !important;
+  position: absolute;
+  width: ${sizes(10)};
+  height: ${sizes(10)};
+
+  svg {
+    width: ${sizes(6)};
+    height: ${sizes(6)};
+  }
+`
+
+export const RestartButton = styled(Button)`
+  margin-top: ${sizes(6)};
+  ${media.small} {
+    margin-top: ${sizes(12)};
+  }
+`

+ 130 - 0
src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.tsx

@@ -0,0 +1,130 @@
+import React, { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router'
+
+import { VideoFieldsFragment } from '@/api/queries'
+import { absoluteRoutes } from '@/config/routes'
+import { AssetType, useAsset } from '@/providers'
+import { SvgGlyphRestart, SvgPlayerPause, SvgPlayerPlay } from '@/shared/icons'
+
+import {
+  CountDownButton,
+  CountDownWrapper,
+  Heading,
+  InnerContainer,
+  OverlayBackground,
+  RestartButton,
+  StyledChannelLink,
+  StyledCircularProgressBar,
+  SubHeading,
+  VideoInfo,
+} from './EndingOverlay.style'
+
+type EndingOverlayProps = {
+  channelId?: string
+  currentThumbnailUrl?: string | null
+  isFullScreen?: boolean
+  onPlayAgain?: () => void
+  randomNextVideo?: VideoFieldsFragment | null
+  isEnded: boolean
+}
+// 10 seconds
+const NEXT_VIDEO_TIMEOUT = 10000
+
+export const EndingOverlay: React.FC<EndingOverlayProps> = ({
+  onPlayAgain,
+  isFullScreen,
+  channelId,
+  currentThumbnailUrl,
+  randomNextVideo,
+  isEnded,
+}) => {
+  const navigate = useNavigate()
+  const [countdownProgress, setCountdownProgress] = useState(0)
+  const [isCountDownStarted, setIsCountDownStarted] = useState(false)
+
+  const { url: randomNextVideoThumbnailUrl } = useAsset({
+    entity: randomNextVideo,
+    assetType: AssetType.THUMBNAIL,
+  })
+
+  useEffect(() => {
+    if (!randomNextVideo || !isEnded) {
+      return
+    }
+    setIsCountDownStarted(true)
+  }, [isEnded, randomNextVideo])
+
+  useEffect(() => {
+    if (!randomNextVideo || !isCountDownStarted) {
+      return
+    }
+
+    const tick = NEXT_VIDEO_TIMEOUT / 100
+    const timeout = setTimeout(() => {
+      setCountdownProgress(countdownProgress + tick)
+    }, tick)
+
+    if (countdownProgress === NEXT_VIDEO_TIMEOUT) {
+      navigate(absoluteRoutes.viewer.video(randomNextVideo.id))
+    }
+
+    if (!isEnded) {
+      clearTimeout(timeout)
+      setCountdownProgress(0)
+      setIsCountDownStarted(false)
+    }
+
+    return () => {
+      clearTimeout(timeout)
+    }
+  }, [countdownProgress, isCountDownStarted, isEnded, navigate, randomNextVideo])
+
+  const handleCountDownButton = () => {
+    if (isCountDownStarted) {
+      setIsCountDownStarted(false)
+      setCountdownProgress(0)
+    } else {
+      navigate(absoluteRoutes.viewer.video(randomNextVideo?.id))
+    }
+  }
+
+  return (
+    <OverlayBackground thumbnailUrl={randomNextVideo ? randomNextVideoThumbnailUrl : currentThumbnailUrl}>
+      {randomNextVideo ? (
+        <InnerContainer isFullScreen={isFullScreen}>
+          <VideoInfo>
+            <SubHeading variant="body1" secondary>
+              Up next
+            </SubHeading>
+            <Heading variant="h3">{randomNextVideo.title}</Heading>
+            <StyledChannelLink id={randomNextVideo.channel.id} avatarSize="default" />
+          </VideoInfo>
+          <CountDownWrapper>
+            <StyledCircularProgressBar
+              value={countdownProgress}
+              maxValue={NEXT_VIDEO_TIMEOUT}
+              strokeWidth={8}
+              variant={'player'}
+              noTrail={!isCountDownStarted}
+            />
+            <CountDownButton variant="tertiary" onClick={handleCountDownButton}>
+              {isCountDownStarted ? <SvgPlayerPause /> : <SvgPlayerPlay />}
+            </CountDownButton>
+          </CountDownWrapper>
+        </InnerContainer>
+      ) : (
+        <InnerContainer isFullScreen={isFullScreen}>
+          <VideoInfo>
+            <SubHeading variant="body1" secondary>
+              You’ve finished watching a video from
+            </SubHeading>
+            <StyledChannelLink id={channelId} avatarSize="small" noNextVideo />
+            <RestartButton onClick={onPlayAgain} variant="secondary" icon={<SvgGlyphRestart />}>
+              Play again
+            </RestartButton>
+          </VideoInfo>
+        </InnerContainer>
+      )}
+    </OverlayBackground>
+  )
+}

+ 83 - 0
src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.style.ts

@@ -0,0 +1,83 @@
+import styled from '@emotion/styled'
+import { fluidRange } from 'polished'
+
+import { breakpoints, colors, media, sizes, zIndex } from '@/shared/theme'
+
+import { AnimatedError } from '../../AnimatedError'
+import { Button } from '../../Button'
+import { Text } from '../../Text'
+
+export const OverlayBackground = styled.div`
+  display: flex;
+  z-index: ${zIndex.nearOverlay};
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background-color: ${colors.gray[900]};
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 100%;
+  width: 100%;
+`
+
+export const InnerContainer = styled.div`
+  padding: ${sizes(4)};
+  height: 100%;
+  overflow-y: auto;
+  flex-direction: column;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  ${media.small} {
+    padding: ${sizes(6)};
+  }
+`
+
+export const AnimationWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: ${sizes(40)};
+  position: relative;
+  ${media.small} {
+    margin-top: ${sizes(20)};
+  }
+`
+
+export const StyledAnimatedError = styled(AnimatedError)`
+  width: 108px;
+  position: absolute;
+  bottom: 0;
+  ${media.small} {
+    width: 216px;
+  }
+`
+
+export const Heading = styled(Text)`
+  ${fluidRange({ prop: 'fontSize', fromSize: '20px', toSize: '40px' }, breakpoints.base, breakpoints.medium)};
+
+  margin-top: ${sizes(8)};
+  text-align: center;
+`
+
+export const ErrorMessage = styled(Text)`
+  ${fluidRange({ prop: 'fontSize', fromSize: '14px', toSize: '16px' }, breakpoints.base, breakpoints.medium)};
+
+  max-width: 560px;
+  margin-top: ${sizes(2)};
+  text-align: center;
+`
+
+export const ButtonGroup = styled.div`
+  margin-top: ${sizes(8)};
+  display: flex;
+`
+export const StyledDiscordButton = styled(Button)`
+  margin-right: ${sizes(4)};
+`

+ 39 - 0
src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.tsx

@@ -0,0 +1,39 @@
+import React from 'react'
+
+import { JOYSTREAM_DISCORD_URL } from '@/config/urls'
+
+import {
+  AnimationWrapper,
+  ButtonGroup,
+  ErrorMessage,
+  Heading,
+  InnerContainer,
+  OverlayBackground,
+  StyledAnimatedError,
+  StyledDiscordButton,
+} from './ErrorOverlay.style'
+
+import { Button } from '../../Button'
+
+export const ErrorOverlay: React.FC = () => {
+  return (
+    <OverlayBackground>
+      <InnerContainer>
+        <AnimationWrapper>
+          <StyledAnimatedError />
+        </AnimationWrapper>
+        <Heading variant="h3">Aw, shucks!</Heading>
+        <ErrorMessage variant="body1" secondary>
+          The video could not be loaded because of an error. Please try again later. If the issue persists, reach out to
+          our community on Discord.
+        </ErrorMessage>
+        <ButtonGroup>
+          <StyledDiscordButton variant="secondary" to={JOYSTREAM_DISCORD_URL}>
+            Open Discord
+          </StyledDiscordButton>
+          <Button onClick={() => window.location.reload()}>Refresh the page</Button>
+        </ButtonGroup>
+      </InnerContainer>
+    </OverlayBackground>
+  )
+}

+ 17 - 0
src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.style.ts

@@ -0,0 +1,17 @@
+import styled from '@emotion/styled'
+
+import { colors } from '@/shared/theme'
+
+export const OverlayBackground = styled.div`
+  position: absolute;
+  z-index: 0;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: ${colors.transparentBlack[54]};
+  display: flex;
+  background-size: cover;
+  justify-content: center;
+  align-items: center;
+`

+ 17 - 0
src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.tsx

@@ -0,0 +1,17 @@
+import React from 'react'
+
+import { OverlayBackground } from './LoadingOverlay.style'
+
+import { Loader } from '../../Loader'
+
+type LoadingOverlayProps = {
+  onPlay: () => void
+}
+
+export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ onPlay }) => {
+  return (
+    <OverlayBackground onClick={onPlay}>
+      <Loader variant="player" />
+    </OverlayBackground>
+  )
+}

+ 3 - 0
src/shared/components/VideoPlayer/VideoOverlays/index.ts

@@ -0,0 +1,3 @@
+export * from './EndingOverlay'
+export * from './ErrorOverlay'
+export * from './LoadingOverlay'

+ 63 - 100
src/shared/components/VideoPlayer/VideoPlayer.style.tsx

@@ -3,7 +3,7 @@ import styled from '@emotion/styled'
 
 import { SvgPlayerSoundOff } from '@/shared/icons'
 
-import { colors, media, sizes, transitions, zIndex } from '../../theme'
+import { colors, sizes, transitions, zIndex } from '../../theme'
 import { Text } from '../Text'
 
 type ContainerProps = {
@@ -12,6 +12,7 @@ type ContainerProps = {
 }
 type CustomControlsProps = {
   isFullScreen?: boolean
+  isEnded?: boolean
 }
 
 const focusStyles = css`
@@ -46,23 +47,24 @@ export const ControlsOverlay = styled.div<CustomControlsProps>`
 export const CustomControls = styled.div<CustomControlsProps>`
   font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))};
   position: absolute;
-  height: 2.5em;
   bottom: ${({ isFullScreen }) => (isFullScreen ? '2.5em' : '1em')};
-  padding: 0 1em;
+  padding: 0.5em 1em 0;
+  border-top: ${({ isEnded }) => (isEnded ? `1px solid ${colors.transparentPrimary[18]}` : 'unset')};
   left: 0;
   display: flex;
-  align-items: flex-end;
+  align-items: center;
+  z-index: ${zIndex.nearOverlay - 1};
   width: 100%;
-  opacity: 0;
   transition: transform 200ms ${transitions.easing}, opacity 200ms ${transitions.easing};
 `
 
 export const ControlButton = styled.button`
   margin-right: 0.5em;
+  display: flex !important;
   cursor: pointer;
   border: none;
+  background: none;
   border-radius: 100%;
-  display: flex;
   align-items: center;
   justify-content: center;
   padding: 0.5em;
@@ -167,93 +169,28 @@ export const StyledSvgPlayerSoundOff = styled(SvgPlayerSoundOff)`
 `
 export const CurrentTimeWrapper = styled.div`
   display: flex;
-  height: 100%;
-  color: ${colors.white};
-  margin-left: 1em;
-  text-shadow: 0 1px 2px ${colors.transparentBlack[32]};
   align-items: center;
-  font-feature-settings: 'tnum' on, 'lnum' on;
+  height: 2.5em;
+  margin-left: 1em;
 `
 
 export const CurrentTime = styled(Text)`
   /* 14px */
   font-size: 0.875em;
+  color: ${colors.white};
+  text-shadow: 0 1px 2px ${colors.transparentBlack[32]};
+  font-feature-settings: 'tnum' on, 'lnum' on;
 `
 
 export const ScreenControls = styled.div`
   margin-left: auto;
+  display: flex;
 
   ${ControlButton}:last-of-type {
     margin-right: 0;
   }
 `
 
-export const ControlsIndicatorWrapper = styled.div`
-  position: absolute;
-  top: calc(50% - ${sizes(16)});
-  left: calc(50% - ${sizes(16)});
-  display: flex;
-  flex-direction: column;
-`
-
-export const ControlsIndicator = styled.div`
-  width: ${sizes(32)};
-  height: ${sizes(32)};
-  backdrop-filter: blur(${sizes(6)});
-  background-color: ${colors.transparentBlack[54]};
-  border-radius: 100%;
-  display: flex;
-  transform: scale(0.5);
-  justify-content: center;
-  align-items: center;
-
-  > svg {
-    transform: scale(0.75);
-    width: ${sizes(18)};
-    height: ${sizes(18)};
-  }
-`
-
-export const ControlsIndicatorTooltip = styled.div`
-  user-select: none;
-  display: none;
-  align-self: center;
-  background-color: ${colors.transparentBlack[54]};
-  padding: ${sizes(2)};
-  text-align: center;
-  margin-top: ${sizes(3)};
-  backdrop-filter: blur(${sizes(8)});
-
-  ${media.small} {
-    display: block;
-  }
-`
-
-const animationEasing = 'cubic-bezier(0, 0, 0.3, 1)'
-
-export const indicatorTransitions = css`
-  .indicator-exit {
-    opacity: 1;
-  }
-
-  .indicator-exit-active {
-    ${ControlsIndicator} {
-      transform: scale(1);
-      opacity: 0;
-      transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing};
-
-      > svg {
-        transform: scale(1);
-        transition: transform 750ms ${animationEasing};
-      }
-    }
-    ${ControlsIndicatorTooltip} {
-      opacity: 0;
-      transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing};
-    }
-  }
-`
-
 const backgroundContainerCss = css`
   .vjs-waiting .vjs-loading-spinner {
     display: none;
@@ -263,6 +200,10 @@ const backgroundContainerCss = css`
     display: none;
   }
 
+  .vjs-error-display {
+    display: block;
+  }
+
   .vjs-poster {
     display: block !important;
     opacity: 0;
@@ -276,10 +217,9 @@ const backgroundContainerCss = css`
 `
 
 export const Container = styled.div<ContainerProps>`
-  ${indicatorTransitions}
-
   position: relative;
   height: 100%;
+  z-index: 0;
 
   [class^='vjs'] {
     font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))} !important;
@@ -289,28 +229,35 @@ export const Container = styled.div<ContainerProps>`
     background-color: ${colors.gray[900]};
   }
 
-  .vjs-playing:hover ${CustomControls} {
-    transform: translateY(-0.5em);
-    opacity: 1;
-  }
-  .vjs-paused ${CustomControls} {
-    transform: translateY(-0.5em);
-    opacity: 1;
+  .vjs-error-display {
+    display: none;
   }
 
-  .vjs-user-inactive.vjs-playing > ${CustomControls} {
-    transform: translateY(0.5em);
-    opacity: 0;
+  .vjs-playing:hover {
+    ${ControlsOverlay} {
+      opacity: 1;
+      ${CustomControls} {
+        transform: translateY(-0.5em);
+      }
+    }
   }
 
-  .vjs-playing:hover ${ControlsOverlay} {
-    opacity: 1;
-  }
-  .vjs-paused ${ControlsOverlay} {
-    opacity: 1;
+  .vjs-user-inactive.vjs-playing {
+    ${ControlsOverlay} {
+      opacity: 0;
+      ${CustomControls} {
+        transform: translateY(0.5em);
+      }
+    }
   }
-  .vjs-user-inactive.vjs-playing > ${ControlsOverlay} {
-    opacity: 0;
+
+  .vjs-paused {
+    ${ControlsOverlay} {
+      opacity: 1;
+      ${CustomControls} {
+        transform: translateY(-0.5em);
+      }
+    }
   }
 
   .vjs-poster {
@@ -322,11 +269,11 @@ export const Container = styled.div<ContainerProps>`
     background: none;
     align-items: flex-end;
     height: 2em;
-    z-index: ${zIndex.overlay};
     transition: opacity 200ms ${transitions.easing} !important;
+    z-index: ${zIndex.nearOverlay};
 
     :hover {
-      & ~ ${CustomControls} {
+      & ~ ${ControlsOverlay} ${CustomControls} {
         opacity: 0;
         transform: translateY(0.5em);
       }
@@ -407,16 +354,32 @@ export const Container = styled.div<ContainerProps>`
   ${({ isInBackground }) => isInBackground && backgroundContainerCss};
 `
 
-export const PlayOverlay = styled.div`
+export const BigPlayButtonOverlay = styled.div`
   position: absolute;
   top: 0;
   right: 0;
   bottom: 0;
   left: 0;
   z-index: ${zIndex.overlay};
-  background: linear-gradient(0deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6));
+  background: ${colors.transparentBlack[86]};
   display: flex;
   justify-content: center;
   align-items: center;
+`
+
+export const BigPlayButton = styled(ControlButton)`
+  display: flex !important;
+  width: ${sizes(20)};
+  height: ${sizes(20)};
+  justify-content: center;
+  align-items: center;
   cursor: pointer;
+  position: absolute;
+  background-color: ${colors.transparentPrimary[18]} !important;
+  backdrop-filter: blur(${sizes(8)}) !important;
+
+  > svg {
+    width: ${sizes(10)} !important;
+    height: ${sizes(10)} !important;
+  }
 `

+ 137 - 170
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -1,38 +1,33 @@
 import { debounce } from 'lodash'
 import React, { useCallback, useEffect, useRef, useState } from 'react'
-import { CSSTransition } from 'react-transition-group'
 
+import { VideoFieldsFragment } from '@/api/queries'
 import { usePersonalDataStore } from '@/providers'
 import {
-  SvgOutlineVideo,
-  SvgPlayerBackwardFiveSec,
-  SvgPlayerBackwardTenSec,
-  SvgPlayerForwardFiveSec,
-  SvgPlayerForwardTenSec,
   SvgPlayerFullScreen,
   SvgPlayerPause,
   SvgPlayerPip,
   SvgPlayerPipDisable,
   SvgPlayerPlay,
+  SvgPlayerRestart,
   SvgPlayerSmallScreen,
   SvgPlayerSoundHalf,
-  SvgPlayerSoundOff,
   SvgPlayerSoundOn,
 } from '@/shared/icons'
 import { Logger } from '@/utils/logger'
 import { formatDurationShort } from '@/utils/time'
 
+import { ControlsIndicator } from './ControlsIndicator'
+import { VideoOverlay } from './VideoOverlay'
 import {
+  BigPlayButton,
+  BigPlayButtonOverlay,
   Container,
   ControlButton,
-  ControlsIndicator,
-  ControlsIndicatorTooltip,
-  ControlsIndicatorWrapper,
   ControlsOverlay,
   CurrentTime,
   CurrentTimeWrapper,
   CustomControls,
-  PlayOverlay,
   ScreenControls,
   StyledSvgPlayerSoundOff,
   VolumeButton,
@@ -42,13 +37,14 @@ import {
 } from './VideoPlayer.style'
 import { CustomVideojsEvents, VOLUME_STEP, VideoJsConfig, useVideoJsPlayer } from './videoJsPlayer'
 
-import { Text } from '../Text'
-
 export type VideoPlayerProps = {
+  nextVideo?: VideoFieldsFragment | null
   className?: string
   autoplay?: boolean
   isInBackground?: boolean
   playing?: boolean
+  channelId?: string
+  videoId?: string
 } & VideoJsConfig
 
 declare global {
@@ -59,53 +55,39 @@ declare global {
 }
 
 const isPiPSupported = 'pictureInPictureEnabled' in document
-type VideoEvent = CustomVideojsEvents | null
 
-type EventState = {
-  type: VideoEvent
-  description: string | null
-  icon: React.ReactNode | null
-  isVisible: boolean
-}
+export type PlayerState = 'loading' | 'ended' | 'error' | 'playing' | null
 
 const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, VideoPlayerProps> = (
-  { className, autoplay, isInBackground, playing, ...videoJsConfig },
+  { className, isInBackground, playing, nextVideo, channelId, videoId, autoplay, ...videoJsConfig },
   externalRef
 ) => {
   const [player, playerRef] = useVideoJsPlayer(videoJsConfig)
   const cachedPlayerVolume = usePersonalDataStore((state) => state.cachedPlayerVolume)
   const updateCachedPlayerVolume = usePersonalDataStore((state) => state.actions.updateCachedPlayerVolume)
-  const [indicator, setIndicator] = useState<EventState | null>(null)
 
   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
+  const [playerState, setPlayerState] = useState<PlayerState>(null)
+  const [isLoaded, setIsLoaded] = useState(false)
 
-  // handle showing player indicators
+  // handle error
   useEffect(() => {
-    if (!player || isInBackground) {
+    if (!player) {
       return
     }
-    const indicatorEvents = Object.values(CustomVideojsEvents)
-    const handler = (e: Event) => {
-      const playerVolume = e.type === CustomVideojsEvents.Unmuted ? cachedPlayerVolume || VOLUME_STEP : player.volume()
-      const indicator = createIndicator(e.type as VideoEvent, playerVolume, player.muted())
-      if (indicator) {
-        setIndicator({ ...indicator, isVisible: true })
-      }
+    const handler = () => {
+      setPlayerState('error')
     }
-    player.on(indicatorEvents, handler)
-
+    player.on('error', handler)
     return () => {
-      player.off(indicatorEvents, handler)
+      player.off('error', handler)
     }
-  }, [cachedPlayerVolume, isInBackground, player])
+  })
 
   const playVideo = useCallback(() => {
     if (!player) {
@@ -124,13 +106,47 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
   }, [player])
 
-  // handle loading video
+  // handle video loading
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    const handler = (event: Event) => {
+      if (event.type === 'waiting') {
+        setPlayerState('loading')
+      }
+      if (event.type === 'canplay') {
+        if (playerState !== null) {
+          setPlayerState('playing')
+        }
+      }
+    }
+    player.on(['waiting', 'canplay'], handler)
+    return () => {
+      player.off(['waiting', 'canplay'], handler)
+    }
+  }, [player, playerState])
+
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    const handler = () => {
+      setPlayerState('ended')
+    }
+    player.on('ended', handler)
+    return () => {
+      player.off('ended', handler)
+    }
+  }, [nextVideo, player])
+
+  // handle loadstart
   useEffect(() => {
     if (!player) {
       return
     }
     const handler = () => {
-      setInitialized(true)
+      setIsLoaded(true)
     }
     player.on('loadstart', handler)
     return () => {
@@ -140,7 +156,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
 
   // handle autoplay
   useEffect(() => {
-    if (!player || !initialized || !autoplay) {
+    if (!player || !isLoaded || !autoplay) {
       return
     }
     const playPromise = player.play()
@@ -149,7 +165,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
         Logger.warn('Autoplay failed:', e)
       })
     }
-  }, [player, initialized, autoplay])
+  }, [player, isLoaded, autoplay])
 
   // handle playing and pausing from outside the component
   useEffect(() => {
@@ -170,8 +186,10 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
     const handler = (event: Event) => {
       if (event.type === 'play') {
-        setPlayOverlayVisible(false)
-        setIsPlaying(true)
+        if (playerState !== 'loading') {
+          setPlayerState('playing')
+          setIsPlaying(true)
+        }
       }
       if (event.type === 'pause') {
         setIsPlaying(false)
@@ -181,7 +199,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     return () => {
       player.off(['play', 'pause'], handler)
     }
-  }, [player])
+  }, [player, playerState])
 
   useEffect(() => {
     if (!externalRef) {
@@ -206,6 +224,22 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
   }, [player])
 
+  // handle seeking
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    const handler = () => {
+      if (playerState === 'ended') {
+        player.play()
+      }
+    }
+    player.on('seeking', handler)
+    return () => {
+      player.off('seeking', handler)
+    }
+  }, [player, playerState])
+
   // handle fullscreen mode
   useEffect(() => {
     if (!player) {
@@ -341,145 +375,78 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     }
   }
 
+  const showBigPlayButton = playerState === null && !isInBackground
+  const showPlayerControls = !isInBackground && isLoaded && playerState
+
   return (
     <Container isFullScreen={isFullScreen} className={className} isInBackground={isInBackground}>
-      {displayPlayOverlay && (
-        <PlayOverlay onClick={handlePlayPause}>
-          <SvgOutlineVideo width={72} height={72} viewBox="0 0 24 24" />
-        </PlayOverlay>
-      )}
       <div data-vjs-player>
+        {showBigPlayButton && (
+          <BigPlayButtonOverlay>
+            <BigPlayButton onClick={handlePlayPause}>
+              <SvgPlayerPlay />
+            </BigPlayButton>
+          </BigPlayButtonOverlay>
+        )}
         <video
           ref={playerRef}
           className="video-js"
-          onClick={() => {
-            if (player?.paused()) {
-              player?.trigger(CustomVideojsEvents.PauseControl)
-            } else {
-              player?.trigger(CustomVideojsEvents.PlayControl)
-            }
-          }}
+          onClick={() =>
+            player?.paused()
+              ? player?.trigger(CustomVideojsEvents.PauseControl)
+              : player?.trigger(CustomVideojsEvents.PlayControl)
+          }
         />
-        {!isInBackground && !playOverlayVisible && (
+        {showPlayerControls && (
           <>
-            <ControlsOverlay isFullScreen={isFullScreen} />
-            <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>
-              <CurrentTimeWrapper>
-                <CurrentTime variant="body2">
-                  {formatDurationShort(videoTime)} / {formatDurationShort(Math.floor(player?.duration() || 0))}
-                </CurrentTime>
-              </CurrentTimeWrapper>
-              <ScreenControls>
-                {isPiPSupported && (
-                  <ControlButton onClick={handlePictureInPicture}>
-                    {isPiPEnabled ? <SvgPlayerPipDisable /> : <SvgPlayerPip />}
-                  </ControlButton>
-                )}
-                <ControlButton onClick={handleFullScreen}>
-                  {isFullScreen ? <SvgPlayerSmallScreen /> : <SvgPlayerFullScreen />}
+            <ControlsOverlay isFullScreen={isFullScreen}>
+              <CustomControls isFullScreen={isFullScreen} isEnded={playerState === 'ended'}>
+                <ControlButton onClick={handlePlayPause}>
+                  {playerState === 'ended' ? <SvgPlayerRestart /> : isPlaying ? <SvgPlayerPause /> : <SvgPlayerPlay />}
                 </ControlButton>
-              </ScreenControls>
-            </CustomControls>
+                <VolumeControl>
+                  <VolumeButton onClick={handleMute}>{renderVolumeButton()}</VolumeButton>
+                  <VolumeSliderContainer>
+                    <VolumeSlider
+                      step={0.01}
+                      max={1}
+                      min={0}
+                      value={volume}
+                      onChange={handleChangeVolume}
+                      type="range"
+                    />
+                  </VolumeSliderContainer>
+                </VolumeControl>
+                <CurrentTimeWrapper>
+                  <CurrentTime variant="body2">
+                    {formatDurationShort(videoTime)} / {formatDurationShort(Math.floor(player?.duration() || 0))}
+                  </CurrentTime>
+                </CurrentTimeWrapper>
+                <ScreenControls>
+                  {isPiPSupported && (
+                    <ControlButton onClick={handlePictureInPicture}>
+                      {isPiPEnabled ? <SvgPlayerPipDisable /> : <SvgPlayerPip />}
+                    </ControlButton>
+                  )}
+                  <ControlButton onClick={handleFullScreen}>
+                    {isFullScreen ? <SvgPlayerSmallScreen /> : <SvgPlayerFullScreen />}
+                  </ControlButton>
+                </ScreenControls>
+              </CustomControls>
+            </ControlsOverlay>
+            <VideoOverlay
+              videoId={videoId}
+              playerState={playerState}
+              onPlay={handlePlayPause}
+              channelId={channelId}
+              currentThumbnailUrl={videoJsConfig.posterUrl}
+            />
           </>
         )}
-        <CSSTransition
-          in={indicator?.isVisible}
-          timeout={indicator?.isVisible ? 0 : 750}
-          classNames="indicator"
-          mountOnEnter
-          unmountOnExit
-          onEntered={() => setIndicator((indicator) => (indicator ? { ...indicator, isVisible: false } : null))}
-          onExited={() => setIndicator(null)}
-        >
-          <ControlsIndicatorWrapper>
-            <ControlsIndicator>{indicator?.icon}</ControlsIndicator>
-            <ControlsIndicatorTooltip>
-              <Text variant="caption">{indicator?.description}</Text>
-            </ControlsIndicatorTooltip>
-          </ControlsIndicatorWrapper>
-        </CSSTransition>
       </div>
+      {!isInBackground && <ControlsIndicator player={player} />}
     </Container>
   )
 }
 
 export const VideoPlayer = React.forwardRef(VideoPlayerComponent)
-
-const createIndicator = (type: VideoEvent | null, playerVolume: number, playerMuted: boolean) => {
-  const formattedVolume = Math.floor(playerVolume * 100) + '%'
-  const isMuted = playerMuted || !Number(playerVolume.toFixed(2))
-
-  switch (type) {
-    case CustomVideojsEvents.PauseControl:
-      return {
-        icon: <SvgPlayerPause />,
-        description: 'Pause',
-        type,
-      }
-    case CustomVideojsEvents.PlayControl:
-      return {
-        icon: <SvgPlayerPlay />,
-        description: 'Play',
-        type,
-      }
-    case CustomVideojsEvents.BackwardFiveSec:
-      return {
-        icon: <SvgPlayerBackwardFiveSec />,
-        description: 'Backward 5s',
-        type,
-      }
-    case CustomVideojsEvents.ForwardFiveSec:
-      return {
-        icon: <SvgPlayerForwardFiveSec />,
-        description: 'Forward 5s',
-        type,
-      }
-    case CustomVideojsEvents.BackwardTenSec:
-      return {
-        icon: <SvgPlayerBackwardTenSec />,
-        description: 'Backward 10s',
-        type,
-      }
-    case CustomVideojsEvents.ForwardTenSec:
-      return {
-        icon: <SvgPlayerForwardTenSec />,
-        description: 'Forward 10s',
-        type,
-      }
-    case CustomVideojsEvents.Unmuted:
-      return {
-        icon: <SvgPlayerSoundOn />,
-        description: formattedVolume,
-        type,
-      }
-    case CustomVideojsEvents.Muted:
-      return {
-        icon: <SvgPlayerSoundOff />,
-        description: 'Mute',
-        type,
-      }
-    case CustomVideojsEvents.VolumeIncrease:
-      return {
-        icon: <SvgPlayerSoundOn />,
-        description: formattedVolume,
-        type,
-      }
-    case CustomVideojsEvents.VolumeDecrease:
-      return {
-        icon: isMuted ? <SvgPlayerSoundOff /> : <SvgPlayerSoundHalf />,
-        description: isMuted ? 'Mute' : formattedVolume,
-        type,
-      }
-    default:
-      return null
-  }
-}

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

@@ -130,6 +130,7 @@ 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,
+      loadingSpinner: false,
       bigPlayButton: false,
       userActions: {
         hotkeys: (event) => hotkeysHandler(event, playerInstance),

+ 8 - 0
src/shared/icons/GlyphRestart.tsx

@@ -0,0 +1,8 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgGlyphRestart = (props: React.SVGProps<SVGSVGElement>) => (
+  <svg width={16} height={16} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <path d="M5 8a3 3 0 111.5 2.6l-1 1.73A5 5 0 103 8H1l3 4 3-4H5z" fill="#F4F6F8" />
+  </svg>
+)

+ 30 - 0
src/shared/icons/PlayerRestart.tsx

@@ -0,0 +1,30 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerRestart = (props: React.SVGProps<SVGSVGElement>) => (
+  <svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <g filter="url(#player-restart_svg__filter0_d)">
+      <path d="M8 11l-3 4-3-4h6z" fill="#F4F6F8" />
+      <path d="M5 12a7 7 0 112 4.899" stroke="#F4F6F8" strokeWidth={2} />
+    </g>
+    <defs>
+      <filter
+        id="player-restart_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>
+)

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

@@ -37,6 +37,7 @@ export * from './GlyphPan'
 export * from './GlyphPlay'
 export * from './GlyphPlus'
 export * from './GlyphResize'
+export * from './GlyphRestart'
 export * from './GlyphRetry'
 export * from './GlyphShow'
 export * from './GlyphSoundOff'
@@ -93,6 +94,7 @@ export * from './PlayerPlaylistAdd'
 export * from './PlayerPlaylist'
 export * from './PlayerPrevious'
 export * from './PlayerReplay'
+export * from './PlayerRestart'
 export * from './PlayerShare'
 export * from './PlayerSmallScreen'
 export * from './PlayerSoundHalf'

+ 3 - 0
src/shared/icons/svgs/glyph-restart.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C7.45191 11 6.94097 10.8539 6.50073 10.5993L5.49927 12.3305C6.23573 12.7565 7.09094 13 8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8H1L4 12L7 8H5Z" fill="#F4F6F8"/>
+</svg>

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

@@ -0,0 +1,17 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g filter="url(#filter0_d)">
+<path d="M8 11L5 15L2 11L8 11Z" fill="#F4F6F8"/>
+<path d="M5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12C19 15.866 15.866 19 12 19C10.0413 19 8.27052 18.1955 7 16.899" stroke="#F4F6F8" stroke-width="2"/>
+</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>

+ 2 - 2
src/views/viewer/VideoView/VideoView.style.tsx

@@ -13,9 +13,9 @@ export const StyledViewWrapper = styled(ViewWrapper)`
 export const PlayerContainer = styled.div`
   width: 100%;
   height: calc(100vw * 0.5625);
-
   ${media.medium} {
-    height: 70vh;
+    height: calc((100vw - var(--sidenav-collapsed-width)) * 0.5625);
+    max-height: calc(70vh);
   }
 `
 

+ 1 - 0
src/views/viewer/VideoView/VideoView.tsx

@@ -132,6 +132,7 @@ export const VideoView: React.FC = () => {
         <PlayerContainer>
           {video ? (
             <VideoPlayer
+              channelId={video.channel.id}
               autoplay
               src={mediaUrl}
               fill