Browse Source

merge player rework

merge player rework
Klaudiusz Dembler 3 years ago
parent
commit
3ca4dff086
96 changed files with 2618 additions and 442 deletions
  1. 1 1
      .env
  2. 2 2
      package.json
  3. 12 0
      src/api/hooks/video.ts
  4. 68 0
      src/api/queries/__generated__/videos.generated.tsx
  5. 16 0
      src/api/queries/videos.graphql
  6. 0 10
      src/components/CoverVideo/CoverVideo.tsx
  7. 2 0
      src/config/urls.ts
  8. 17 4
      src/providers/assets/helpers.ts
  9. 2 0
      src/providers/assets/types.ts
  10. 17 7
      src/providers/personalData/store.tsx
  11. 3 3
      src/providers/uploadsManager/store.tsx
  12. 8 11
      src/providers/uploadsManager/useStartFileUpload.tsx
  13. 22 5
      src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx
  14. 14 7
      src/shared/components/CircularProgressbar/CircularProgressbar.tsx
  15. 83 0
      src/shared/components/VideoPlayer/ControlsIndicator.style.ts
  16. 157 0
      src/shared/components/VideoPlayer/ControlsIndicator.tsx
  17. 142 0
      src/shared/components/VideoPlayer/CustomTimeline.style.ts
  18. 175 0
      src/shared/components/VideoPlayer/CustomTimeline.tsx
  19. 117 0
      src/shared/components/VideoPlayer/PlayerControlButton.style.ts
  20. 47 0
      src/shared/components/VideoPlayer/PlayerControlButton.tsx
  21. 71 0
      src/shared/components/VideoPlayer/VideoOverlay.tsx
  22. 142 0
      src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.style.ts
  23. 130 0
      src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.tsx
  24. 83 0
      src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.style.ts
  25. 39 0
      src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.tsx
  26. 17 0
      src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.style.ts
  27. 17 0
      src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.tsx
  28. 3 0
      src/shared/components/VideoPlayer/VideoOverlays/index.ts
  29. 194 164
      src/shared/components/VideoPlayer/VideoPlayer.style.tsx
  30. 407 61
      src/shared/components/VideoPlayer/VideoPlayer.tsx
  31. 89 0
      src/shared/components/VideoPlayer/utils.ts
  32. 8 1
      src/shared/components/VideoPlayer/videoJsPlayer.ts
  33. 8 0
      src/shared/icons/GlyphRestart.tsx
  34. 13 0
      src/shared/icons/PlayerBackwardFiveSec.tsx
  35. 13 0
      src/shared/icons/PlayerBackwardTenSec.tsx
  36. 13 0
      src/shared/icons/PlayerCaptionsOff.tsx
  37. 13 0
      src/shared/icons/PlayerCaptionsOn.tsx
  38. 13 0
      src/shared/icons/PlayerCastOff.tsx
  39. 13 0
      src/shared/icons/PlayerCastOn.tsx
  40. 13 0
      src/shared/icons/PlayerEmbed.tsx
  41. 13 0
      src/shared/icons/PlayerForwardFiveSec.tsx
  42. 13 0
      src/shared/icons/PlayerForwardTenSec.tsx
  43. 4 5
      src/shared/icons/PlayerFullScreen.tsx
  44. 1 1
      src/shared/icons/PlayerNext.tsx
  45. 1 1
      src/shared/icons/PlayerPause.tsx
  46. 6 2
      src/shared/icons/PlayerPip.tsx
  47. 14 0
      src/shared/icons/PlayerPipDisable.tsx
  48. 8 0
      src/shared/icons/PlayerPrevious.tsx
  49. 9 0
      src/shared/icons/PlayerReplay.tsx
  50. 30 0
      src/shared/icons/PlayerRestart.tsx
  51. 13 0
      src/shared/icons/PlayerShare.tsx
  52. 4 5
      src/shared/icons/PlayerSmallScreen.tsx
  53. 13 0
      src/shared/icons/PlayerSoundHalf.tsx
  54. 1 10
      src/shared/icons/PlayerSoundOff.tsx
  55. 2 1
      src/shared/icons/PlayerSoundOn.tsx
  56. 13 0
      src/shared/icons/PlayerSubtitlesOff.tsx
  57. 13 0
      src/shared/icons/PlayerSubtitlesOn.tsx
  58. 13 0
      src/shared/icons/PlayerVideoModeCinemaView.tsx
  59. 13 0
      src/shared/icons/PlayerVideoModeCompactView.tsx
  60. 13 0
      src/shared/icons/PlayerVideoSettingsOff.tsx
  61. 13 0
      src/shared/icons/PlayerVideoSettingsOn.tsx
  62. 22 0
      src/shared/icons/index.tsx
  63. 3 0
      src/shared/icons/svgs/glyph-restart.svg
  64. 3 0
      src/shared/icons/svgs/player-backward-five-sec.svg
  65. 3 0
      src/shared/icons/svgs/player-backward-ten-sec.svg
  66. 3 0
      src/shared/icons/svgs/player-captions-off.svg
  67. 3 0
      src/shared/icons/svgs/player-captions-on.svg
  68. 3 0
      src/shared/icons/svgs/player-cast-off.svg
  69. 3 0
      src/shared/icons/svgs/player-cast-on.svg
  70. 3 0
      src/shared/icons/svgs/player-embed.svg
  71. 3 0
      src/shared/icons/svgs/player-forward-five-sec.svg
  72. 3 0
      src/shared/icons/svgs/player-forward-ten-sec.svg
  73. 2 2
      src/shared/icons/svgs/player-full-screen.svg
  74. 1 1
      src/shared/icons/svgs/player-next.svg
  75. 2 3
      src/shared/icons/svgs/player-pause.svg
  76. 4 0
      src/shared/icons/svgs/player-pip-disable.svg
  77. 1 2
      src/shared/icons/svgs/player-pip.svg
  78. 3 0
      src/shared/icons/svgs/player-previous.svg
  79. 4 0
      src/shared/icons/svgs/player-replay.svg
  80. 17 0
      src/shared/icons/svgs/player-restart.svg
  81. 3 0
      src/shared/icons/svgs/player-share.svg
  82. 4 4
      src/shared/icons/svgs/player-small-screen.svg
  83. 3 0
      src/shared/icons/svgs/player-sound-half.svg
  84. 3 4
      src/shared/icons/svgs/player-sound-off.svg
  85. 2 2
      src/shared/icons/svgs/player-sound-on.svg
  86. 3 0
      src/shared/icons/svgs/player-subtitles-off.svg
  87. 3 0
      src/shared/icons/svgs/player-subtitles-on.svg
  88. 3 0
      src/shared/icons/svgs/player-video-mode-cinema-view.svg
  89. 3 0
      src/shared/icons/svgs/player-video-mode-compact-view.svg
  90. 1 0
      src/shared/icons/svgs/player-video-settings-off.svg
  91. 3 0
      src/shared/icons/svgs/player-video-settings-on.svg
  92. 1 0
      src/shared/theme/colors.ts
  93. 1 0
      src/shared/theme/transitions.ts
  94. 2 2
      src/views/viewer/VideoView/VideoView.style.tsx
  95. 3 39
      src/views/viewer/VideoView/VideoView.tsx
  96. 80 82
      yarn.lock

+ 1 - 1
.env

@@ -5,7 +5,7 @@ REACT_APP_ENV=development
 # default target env is development
 REACT_APP_DEVELOPMENT_QUERY_NODE_URL=https://sumer-dev-2.joystream.app/query/server/graphql
 REACT_APP_DEVELOPMENT_QUERY_NODE_SUBSCRIPTION_URL=wss://sumer-dev-2.joystream.app/query/server/graphql
-REACT_APP_DEVELOPMENT_ORION_URL=https://orion-staging.joystream.app/graphql
+REACT_APP_DEVELOPMENT_ORION_URL=https://sumer-dev-2.joystream.app/orion/graphql
 REACT_APP_DEVELOPMENT_NODE_URL=wss://sumer-dev-2.joystream.app/rpc
 REACT_APP_DEVELOPMENT_FAUCET_URL=https://sumer-dev-2.joystream.app/members/register
 

+ 2 - 2
package.json

@@ -91,7 +91,7 @@
     "retry-axios": "^2.4.0",
     "subscriptions-transport-ws": "^0.9.18",
     "use-resize-observer": "^7.0.0",
-    "video.js": "^7.8.3",
+    "video.js": "^7.13.3",
     "zustand": "^3.5.2"
   },
   "devDependencies": {
@@ -122,7 +122,7 @@
     "@types/react": "^16.9.0",
     "@types/react-dom": "^16.9.0",
     "@types/react-transition-group": "^4.4.0",
-    "@types/video.js": "^7.3.10",
+    "@types/video.js": "^7.3.23",
     "@typescript-eslint/eslint-plugin": "^4.27.0",
     "@typescript-eslint/parser": "^4.27.0",
     "eslint": "^7.28.0",

+ 12 - 0
src/api/hooks/video.ts

@@ -2,10 +2,13 @@ import { MutationHookOptions, QueryHookOptions } from '@apollo/client'
 
 import {
   AddVideoViewMutation,
+  GetBasicVideosQuery,
+  GetBasicVideosQueryVariables,
   GetVideoQuery,
   GetVideosQuery,
   GetVideosQueryVariables,
   useAddVideoViewMutation,
+  useGetBasicVideosQuery,
   useGetVideoQuery,
   useGetVideosQuery,
 } from '@/api/queries'
@@ -52,3 +55,12 @@ export const useAddVideoView = (opts?: AddVideoViewOpts) => {
     ...rest,
   }
 }
+
+type BasicVideosQueryOpts = QueryHookOptions<GetBasicVideosQuery>
+export const useBasicVideos = (variables?: GetBasicVideosQueryVariables, opts?: BasicVideosQueryOpts) => {
+  const { data, ...rest } = useGetBasicVideosQuery({ ...opts, variables })
+  return {
+    videos: data?.videos,
+    ...rest,
+  }
+}

+ 68 - 0
src/api/queries/__generated__/videos.generated.tsx

@@ -22,6 +22,15 @@ export type LicenseFieldsFragment = {
   customText?: Types.Maybe<string>
 }
 
+export type BasicVideoFieldsFragment = {
+  __typename?: 'Video'
+  id: string
+  title?: Types.Maybe<string>
+  thumbnailPhotoUrls: Array<string>
+  thumbnailPhotoAvailability: Types.AssetAvailability
+  thumbnailPhotoDataObject?: Types.Maybe<{ __typename?: 'DataObject' } & DataObjectFieldsFragment>
+}
+
 export type VideoFieldsFragment = {
   __typename?: 'Video'
   id: string
@@ -87,6 +96,15 @@ export type GetVideosQuery = {
   videos?: Types.Maybe<Array<{ __typename?: 'Video' } & VideoFieldsFragment>>
 }
 
+export type GetBasicVideosQueryVariables = Types.Exact<{
+  where?: Types.Maybe<Types.VideoWhereInput>
+}>
+
+export type GetBasicVideosQuery = {
+  __typename?: 'Query'
+  videos?: Types.Maybe<Array<{ __typename?: 'Video' } & BasicVideoFieldsFragment>>
+}
+
 export type GetVideoViewsQueryVariables = Types.Exact<{
   videoId: Types.Scalars['ID']
 }>
@@ -115,6 +133,18 @@ export type AddVideoViewMutation = {
   addVideoView: { __typename?: 'EntityViewsInfo'; id: string; views: number }
 }
 
+export const BasicVideoFieldsFragmentDoc = gql`
+  fragment BasicVideoFields on Video {
+    id
+    title
+    thumbnailPhotoUrls
+    thumbnailPhotoAvailability
+    thumbnailPhotoDataObject {
+      ...DataObjectFields
+    }
+  }
+  ${DataObjectFieldsFragmentDoc}
+`
 export const VideoMediaMetadataFieldsFragmentDoc = gql`
   fragment VideoMediaMetadataFields on VideoMediaMetadata {
     id
@@ -313,6 +343,44 @@ export function useGetVideosLazyQuery(
 export type GetVideosQueryHookResult = ReturnType<typeof useGetVideosQuery>
 export type GetVideosLazyQueryHookResult = ReturnType<typeof useGetVideosLazyQuery>
 export type GetVideosQueryResult = Apollo.QueryResult<GetVideosQuery, GetVideosQueryVariables>
+export const GetBasicVideosDocument = gql`
+  query GetBasicVideos($where: VideoWhereInput) {
+    videos(where: $where) {
+      ...BasicVideoFields
+    }
+  }
+  ${BasicVideoFieldsFragmentDoc}
+`
+
+/**
+ * __useGetBasicVideosQuery__
+ *
+ * To run a query within a React component, call `useGetBasicVideosQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetBasicVideosQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetBasicVideosQuery({
+ *   variables: {
+ *      where: // value for 'where'
+ *   },
+ * });
+ */
+export function useGetBasicVideosQuery(
+  baseOptions?: Apollo.QueryHookOptions<GetBasicVideosQuery, GetBasicVideosQueryVariables>
+) {
+  return Apollo.useQuery<GetBasicVideosQuery, GetBasicVideosQueryVariables>(GetBasicVideosDocument, baseOptions)
+}
+export function useGetBasicVideosLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetBasicVideosQuery, GetBasicVideosQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetBasicVideosQuery, GetBasicVideosQueryVariables>(GetBasicVideosDocument, baseOptions)
+}
+export type GetBasicVideosQueryHookResult = ReturnType<typeof useGetBasicVideosQuery>
+export type GetBasicVideosLazyQueryHookResult = ReturnType<typeof useGetBasicVideosLazyQuery>
+export type GetBasicVideosQueryResult = Apollo.QueryResult<GetBasicVideosQuery, GetBasicVideosQueryVariables>
 export const GetVideoViewsDocument = gql`
   query GetVideoViews($videoId: ID!) {
     videoViews(videoId: $videoId) {

+ 16 - 0
src/api/queries/videos.graphql

@@ -11,6 +11,16 @@ fragment LicenseFields on License {
   customText
 }
 
+fragment BasicVideoFields on Video {
+  id
+  title
+  thumbnailPhotoUrls
+  thumbnailPhotoAvailability
+  thumbnailPhotoDataObject {
+    ...DataObjectFields
+  }
+}
+
 fragment VideoFields on Video {
   id
   title
@@ -84,6 +94,12 @@ query GetVideos($offset: Int, $limit: Int, $where: VideoWhereInput, $orderBy: Vi
   }
 }
 
+query GetBasicVideos($where: VideoWhereInput) {
+  videos(where: $where) {
+    ...BasicVideoFields
+  }
+}
+
 ### Orion
 
 # modyfying this query name will need a sync-up in `src/api/client/resolvers.ts`

+ 0 - 10
src/components/CoverVideo/CoverVideo.tsx

@@ -56,14 +56,6 @@ export const CoverVideo: React.FC = () => {
     setSoundMuted(!soundMuted)
   }
 
-  const handlePlay = () => {
-    setVideoPlaying(true)
-  }
-
-  const handlePause = () => {
-    setVideoPlaying(false)
-  }
-
   return (
     <Container>
       <MediaWrapper>
@@ -77,8 +69,6 @@ export const CoverVideo: React.FC = () => {
                 playing={videoPlaying}
                 posterUrl={thumbnailPhotoUrl}
                 onDataLoaded={handlePlaybackDataLoaded}
-                onPlay={handlePlay}
-                onPause={handlePause}
                 src={coverVideo?.coverCutMediaUrl}
               />
             ) : (

+ 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'

+ 17 - 4
src/providers/assets/helpers.ts

@@ -2,6 +2,7 @@ import {
   AllChannelFieldsFragment,
   AssetAvailability,
   BasicChannelFieldsFragment,
+  BasicVideoFieldsFragment,
   VideoFieldsFragment,
 } from '@/api/queries'
 import { ASSET_RESPONSE_TIMEOUT } from '@/config/assets'
@@ -27,7 +28,13 @@ export const testAssetDownload = (url: string, type: AssetType) => {
   return withTimeout(testPromise, ASSET_RESPONSE_TIMEOUT)
 }
 export const readAssetData = (
-  entity: VideoFieldsFragment | AllChannelFieldsFragment | BasicChannelFieldsFragment | null | undefined,
+  entity:
+    | VideoFieldsFragment
+    | BasicVideoFieldsFragment
+    | AllChannelFieldsFragment
+    | BasicChannelFieldsFragment
+    | null
+    | undefined,
   assetType: AssetType
 ): AssetResolutionData | null => {
   if (entity?.__typename === 'Channel') {
@@ -46,9 +53,15 @@ export const readAssetData = (
     }
   } else if (entity?.__typename === 'Video') {
     return {
-      availability: assetType === AssetType.MEDIA ? entity.mediaAvailability : entity.thumbnailPhotoAvailability,
-      urls: assetType === AssetType.MEDIA ? entity.mediaUrls : entity.thumbnailPhotoUrls,
-      dataObject: assetType === AssetType.MEDIA ? entity.mediaDataObject : entity.thumbnailPhotoDataObject,
+      availability:
+        assetType === AssetType.MEDIA
+          ? (entity as VideoFieldsFragment).mediaAvailability
+          : entity.thumbnailPhotoAvailability,
+      urls: assetType === AssetType.MEDIA ? (entity as VideoFieldsFragment).mediaUrls : entity.thumbnailPhotoUrls,
+      dataObject:
+        assetType === AssetType.MEDIA
+          ? (entity as VideoFieldsFragment).mediaDataObject
+          : entity.thumbnailPhotoDataObject,
       assetType,
     }
   }

+ 2 - 0
src/providers/assets/types.ts

@@ -2,6 +2,7 @@ import {
   AllChannelFieldsFragment,
   AssetAvailability,
   BasicChannelFieldsFragment,
+  BasicVideoFieldsFragment,
   DataObject,
   VideoFieldsFragment,
 } from '@/api/queries'
@@ -14,6 +15,7 @@ export enum AssetType {
 }
 
 export type UseAssetDataArgs =
+  | { entity?: BasicVideoFieldsFragment | null; assetType: AssetType.THUMBNAIL }
   | { entity?: VideoFieldsFragment | null; assetType: AssetType.THUMBNAIL | AssetType.MEDIA }
   | { entity?: AllChannelFieldsFragment | null; assetType: AssetType.COVER | AssetType.AVATAR }
   | { entity?: BasicChannelFieldsFragment | null; assetType: AssetType.AVATAR }

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

@@ -1,3 +1,5 @@
+import { round } from 'lodash'
+
 import { createStore } from '@/store'
 import { readFromLocalStorage } from '@/utils/localStorage'
 
@@ -15,7 +17,8 @@ export type PersonalDataStoreState = {
   followedChannels: FollowedChannel[]
   recentSearches: RecentSearch[]
   dismissedMessages: DismissedMessage[]
-  playerVolume: number
+  currentVolume: number
+  cachedVolume: number
 }
 
 const WHITELIST = [
@@ -23,7 +26,8 @@ const WHITELIST = [
   'followedChannels',
   'recentSearches',
   'dismissedMessages',
-  'playerVolume',
+  'currentVolume',
+  'cachedVolume',
 ] as (keyof PersonalDataStoreState)[]
 
 export type PersonalDataStoreActions = {
@@ -31,23 +35,25 @@ 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
+  setCurrentVolume: (volume: number) => void
+  setCachedVolume: (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 currentVolume = readFromLocalStorage<number>('playerVolume') ?? 1
 
 export const usePersonalDataStore = createStore<PersonalDataStoreState, PersonalDataStoreActions>(
   {
     state: {
+      cachedVolume: 0,
       watchedVideos,
       followedChannels,
       recentSearches,
       dismissedMessages,
-      playerVolume,
+      currentVolume,
     },
     actionsFactory: (set) => ({
       updateWatchedVideos: (__typename, id, timestamp) => {
@@ -87,9 +93,13 @@ export const usePersonalDataStore = createStore<PersonalDataStoreState, Personal
           }
         })
       },
-      updatePlayerVolume: (volume) =>
+      setCurrentVolume: (volume) =>
+        set((state) => {
+          state.currentVolume = round(volume, 2)
+        }),
+      setCachedVolume: (volume) =>
         set((state) => {
-          state.playerVolume = volume
+          state.cachedVolume = round(volume, 2)
         }),
     }),
   },

+ 3 - 3
src/providers/uploadsManager/store.tsx

@@ -18,7 +18,7 @@ type UploadStoreState = {
   uploadsStatus: UploadsStatusRecord
   setUploadStatus: (contentId: string, status: Partial<UploadStatus>) => void
   assetsFiles: AssetFile[]
-  setAssetsFiles: (assetFile: AssetFile) => void
+  addAssetFile: (assetFile: AssetFile) => void
   isSyncing: boolean
   setIsSyncing: (isSyncing: boolean) => void
 }
@@ -39,8 +39,8 @@ export const useUploadsStore = create<UploadStoreState>(
           uploadsStatus: { ...state.uploadsStatus, [contentId]: { ...state.uploadsStatus[contentId], ...status } },
         }))
       },
-      setAssetsFiles: (assetFile) => {
-        set((state) => ({ ...state, assetFiles: [...state.assetsFiles, assetFile] }))
+      addAssetFile: (assetFile) => {
+        set((state) => ({ ...state, assetsFiles: [...state.assetsFiles, assetFile] }))
       },
       addAsset: (asset) => {
         set((state) => ({ ...state, uploads: [...state.uploads, asset] }))

+ 8 - 11
src/providers/uploadsManager/useStartFileUpload.tsx

@@ -24,7 +24,7 @@ export const useStartFileUpload = () => {
   const { displaySnackbar } = useSnackbar()
   const { getRandomStorageProvider, markStorageProviderNotWorking } = useStorageProviders()
 
-  const setAssetsFiles = useUploadsStore((state) => state.setAssetsFiles)
+  const addAssetFile = useUploadsStore((state) => state.addAssetFile)
   const addAsset = useUploadsStore((state) => state.addAsset)
   const setUploadStatus = useUploadsStore((state) => state.setUploadStatus)
   const assetsFiles = useUploadsStore((state) => state.assetsFiles)
@@ -100,25 +100,22 @@ export const useStartFileUpload = () => {
       }
       const fileInState = assetsFiles?.find((file) => file.contentId === asset.contentId)
       if (!fileInState && file) {
-        setAssetsFiles({ contentId: asset.contentId, blob: file })
+        addAssetFile({ contentId: asset.contentId, blob: file })
       }
 
       const assetKey = `${asset.parentObject.type}-${asset.parentObject.id}`
 
       try {
-        rax.attach()
-        const assetUrl = createStorageNodeUrl(asset.contentId, storageUrl)
         if (!fileInState && !file) {
           throw Error('File was not provided nor found')
         }
-        if (!opts?.isReUpload && file) {
+        rax.attach()
+        const assetUrl = createStorageNodeUrl(asset.contentId, storageUrl)
+        if (!opts?.isReUpload && !opts?.changeHost && file) {
           addAsset({ ...asset, size: file.size })
-          setAssetStatus({ lastStatus: 'inProgress' })
-        }
-        if (opts?.isReUpload && file) {
-          setAssetStatus({ lastStatus: 'inProgress' })
         }
-        setAssetStatus({ progress: 0 })
+
+        setAssetStatus({ lastStatus: 'inProgress', progress: 0 })
 
         const setUploadProgress = ({ loaded, total }: ProgressEvent) => {
           setAssetStatus({ progress: (loaded / total) * 100 })
@@ -193,7 +190,7 @@ export const useStartFileUpload = () => {
       getRandomStorageProvider,
       markStorageProviderNotWorking,
       navigate,
-      setAssetsFiles,
+      addAssetFile,
       setUploadStatus,
     ]
   )

+ 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 './utils'
+
+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
+  }
+}

+ 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>
+  )
+}

+ 117 - 0
src/shared/components/VideoPlayer/PlayerControlButton.style.ts

@@ -0,0 +1,117 @@
+import styled from '@emotion/styled'
+
+import { colors, sizes, transitions } from '@/shared/theme'
+
+import { Text } from '../Text'
+
+type ControlButtonProps = {
+  showTooltipOnlyOnFocus?: boolean
+  disableFocus?: boolean
+}
+
+export const ControlButton = styled.button<ControlButtonProps>`
+  margin-right: 0.5em;
+  display: flex !important;
+  padding: 0.5em;
+  cursor: pointer;
+  border: none;
+  background: none;
+  border-radius: 100%;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  transition: background ${transitions.timings.player} ease-in !important;
+
+  & > svg {
+    filter: drop-shadow(0 1px 2px ${colors.transparentBlack[32]});
+    width: 1.5em;
+    height: 1.5em;
+  }
+
+  :hover,
+  :focus,
+  :focus-visible {
+    /* turn off transition on mouse enter, but turn on on mouse leave */
+    transition: none !important;
+    ${() => ControlButtonTooltip} {
+      transition: none !important;
+    }
+  }
+
+  :hover {
+    background-color: ${colors.transparentPrimary[18]};
+    backdrop-filter: blur(${sizes(8)});
+
+    ${() => ControlButtonTooltip} {
+      opacity: ${({ showTooltipOnlyOnFocus }) => (showTooltipOnlyOnFocus ? 0 : 1)};
+    }
+  }
+
+  :active {
+    background-color: ${colors.transparentPrimary[10]};
+  }
+
+  :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]};
+    ${() => ControlButtonTooltip} {
+      opacity: ${({ disableFocus }) => (disableFocus ? 0 : 1)};
+    }
+  }
+
+  :focus-visible {
+    box-shadow: inset 0 0 0 3px ${colors.transparentPrimary[18]};
+    ${() => ControlButtonTooltip} {
+      opacity: ${({ disableFocus }) => (disableFocus ? 0 : 1)};
+    }
+  }
+
+  :focus:not(:focus-visible) {
+    box-shadow: unset;
+  }
+
+  :hover:focus {
+    ${() => ControlButtonTooltip} {
+      opacity: 1;
+    }
+  }
+
+  :focus:not(:focus-visible):hover {
+    ${() => ControlButtonTooltip} {
+      opacity: ${({ showTooltipOnlyOnFocus }) => (showTooltipOnlyOnFocus ? 0 : 1)};
+    }
+  }
+
+  :focus:not(:focus-visible):not(:hover) {
+    ${() => ControlButtonTooltip} {
+      opacity: 0;
+    }
+  }
+`
+
+type ControlButtonTooltipProps = {
+  tooltipPosition?: 'left' | 'right'
+}
+
+export const ControlButtonTooltip = styled.div<ControlButtonTooltipProps>`
+  ${({ tooltipPosition }) => tooltipPosition === 'left' && 'left: 0'};
+  ${({ tooltipPosition }) => tooltipPosition === 'right' && 'right: 0'};
+
+  opacity: 0;
+  pointer-events: none;
+  position: absolute;
+  bottom: 3em;
+  background: ${colors.transparentBlack[54]};
+  backdrop-filter: blur(${sizes(8)});
+  display: flex;
+  align-items: center;
+  padding: 0.5em;
+  white-space: nowrap;
+  transition: opacity ${transitions.timings.player} ease-in, backdrop-filter ${transitions.timings.player} ease-in !important;
+`
+
+export const ControlButtonTooltipText = styled(Text)`
+  /* 12px */
+  font-size: 0.75em;
+`

+ 47 - 0
src/shared/components/VideoPlayer/PlayerControlButton.tsx

@@ -0,0 +1,47 @@
+import React, { useEffect, useState } from 'react'
+
+import { ControlButton, ControlButtonTooltip, ControlButtonTooltipText } from './PlayerControlButton.style'
+
+type PlayerControlButtonProps = {
+  className?: string
+  showTooltipOnlyOnFocus?: boolean
+  tooltipPosition?: 'left' | 'right'
+  onClick?: (e: React.MouseEvent) => void
+  tooltipText?: string
+}
+
+export const PlayerControlButton: React.FC<PlayerControlButtonProps> = ({
+  children,
+  onClick,
+  tooltipText,
+  tooltipPosition,
+  className,
+  showTooltipOnlyOnFocus,
+}) => {
+  const [disableFocus, setDisableFocus] = useState(true)
+
+  useEffect(() => {
+    if (disableFocus) {
+      return
+    }
+    const handler = () => setDisableFocus(true)
+    window.addEventListener('mousemove', handler)
+    return () => window.removeEventListener('mousemove', handler)
+  }, [disableFocus])
+
+  const handleButtonFocus = () => setDisableFocus(false)
+  return (
+    <ControlButton
+      showTooltipOnlyOnFocus={showTooltipOnlyOnFocus}
+      className={className}
+      onFocus={handleButtonFocus}
+      disableFocus={disableFocus}
+      onClick={onClick}
+    >
+      {children}
+      <ControlButtonTooltip tooltipPosition={tooltipPosition}>
+        <ControlButtonTooltipText variant="caption">{tooltipText}</ControlButtonTooltipText>
+      </ControlButtonTooltip>
+    </ControlButton>
+  )
+}

+ 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 { useBasicVideos } from '@/api/hooks'
+import { AssetAvailability, BasicVideoFieldsFragment } 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<BasicVideoFieldsFragment | null>(null)
+  const { videos } = useBasicVideos({
+    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 { BasicVideoFieldsFragment } 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?: BasicVideoFieldsFragment | 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={channelId} 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'

+ 194 - 164
src/shared/components/VideoPlayer/VideoPlayer.style.tsx

@@ -1,19 +1,165 @@
 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 { PlayerControlButton } from './PlayerControlButton'
+import { ControlButton } from './PlayerControlButton.style'
+
+import { colors, sizes, transitions, zIndex } from '../../theme'
+import { Text } from '../Text'
 
 type ContainerProps = {
   isInBackground?: boolean
+  isFullScreen?: boolean
 }
+type CustomControlsProps = {
+  isFullScreen?: boolean
+  isEnded?: boolean
+}
+
+export const TRANSITION_DELAY = '50ms'
+
+export const ControlsOverlay = styled.div<CustomControlsProps>`
+  font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))};
+  opacity: 0;
+  position: absolute;
+  bottom: 0;
+  width: 100%;
+  background: linear-gradient(180deg, transparent 0%, ${colors.gray[900]} 100%);
+  height: 8em;
+  transition: opacity 200ms ${TRANSITION_DELAY} ${transitions.easing},
+    visibility 200ms ${TRANSITION_DELAY} ${transitions.easing};
+`
+
+export const CustomControls = styled.div<CustomControlsProps>`
+  font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))};
+  position: absolute;
+  bottom: ${({ isFullScreen }) => (isFullScreen ? '2.5em' : '1em')};
+  padding: 0.5em 1em 0;
+  border-top: ${({ isEnded }) => (isEnded ? `1px solid ${colors.transparentPrimary[18]}` : 'unset')};
+  left: 0;
+  display: flex;
+  align-items: center;
+  z-index: ${zIndex.nearOverlay - 1};
+  width: 100%;
+  transition: transform 200ms ${TRANSITION_DELAY} ${transitions.easing},
+    opacity 200ms ${TRANSITION_DELAY} ${transitions.easing};
+`
+
+export const VolumeSliderContainer = styled.div`
+  display: flex;
+  align-items: center;
+`
+
+export const thumbStyles = css`
+  appearance: none;
+  border: none;
+  background: ${colors.white};
+  width: 0.75em;
+  height: 0.75em;
+  border-radius: 100%;
+  cursor: pointer;
+`
+
+export const VolumeSlider = styled.input`
+  appearance: none;
+  border-radius: 2px;
+  margin: 0;
+  padding: 0;
+  width: 4em;
+  height: 0.25em;
+  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: 1.25em;
+  width: 2.5em;
+  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: 7.5em;
+    ${VolumeSlider} {
+      opacity: 1;
+      transform: scaleX(1);
+    }
+  }
+`
+export const VolumeButton = styled(PlayerControlButton)`
+  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 CurrentTimeWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  height: 2.5em;
+  margin-left: 1em;
+`
+
+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;
+`
+
+export const ScreenControls = styled.div`
+  display: grid;
+  grid-template-columns: auto auto;
+  gap: 0.5em;
+  margin-left: auto;
+
+  ${ControlButton}:last-of-type {
+    margin-right: 0;
+  }
+`
 
 const backgroundContainerCss = css`
   .vjs-waiting .vjs-loading-spinner {
     display: none;
   }
 
-  .vjs-control-bar {
-    display: none;
+  .vjs-error-display {
+    display: block;
   }
 
   .vjs-poster {
@@ -31,197 +177,81 @@ const backgroundContainerCss = css`
 export const Container = styled.div<ContainerProps>`
   position: relative;
   height: 100%;
+  z-index: 0;
 
-  *:focus {
-    outline: none;
+  [class^='vjs'] {
+    font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))} !important;
   }
 
-  .vjs-poster {
-    background-size: cover;
+  .video-js {
+    background-color: ${colors.gray[900]};
   }
 
+  .vjs-error-display,
   .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]};
+    display: none;
+  }
 
-      .vjs-slider-bar,
-      .vjs-volume-level {
-        background-color: ${colors.blue[500]};
+  .vjs-playing:hover {
+    ${ControlsOverlay} {
+      opacity: 1;
+      ${CustomControls} {
+        transform: translateY(-0.5em);
       }
     }
+  }
 
-    .vjs-progress-control {
-      position: absolute;
-      transition: none !important;
-      top: initial;
-      height: 2px;
-      left: 0;
-      width: 100%;
-      bottom: -2px;
-
-      ${media.small} {
-        top: 0;
-        left: ${sizes(8)};
-        width: calc(100% - 2 * ${sizes(8)});
-        height: 5px;
-      }
-
-      .vjs-progress-holder {
-        height: 100%;
-        margin: 0;
-
-        .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]};
-
-          div {
-            background: none;
-          }
-        }
+  .vjs-user-inactive.vjs-playing {
+    ${ControlsOverlay} {
+      opacity: 0;
+      ${CustomControls} {
+        transform: translateY(0.5em);
       }
     }
+  }
 
-    .vjs-volume-control {
-      width: 72px !important;
-
-      .vjs-volume-bar {
-        width: 72px;
-        margin-left: 0;
-        margin-right: 0;
-        height: 4px;
-
-        .vjs-volume-level {
-          height: 4px;
-
-          ::before {
-            font-size: ${typography.sizes.icon.small};
-            top: -0.25em;
-          }
-        }
+  .vjs-paused {
+    ${ControlsOverlay} {
+      opacity: 1;
+      ${CustomControls} {
+        transform: translateY(-0.5em);
       }
     }
   }
 
-  .vjs-big-play-button {
-    display: none !important;
+  .vjs-poster {
+    background-size: cover;
   }
 
   ${({ 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;
+  }
 `

+ 407 - 61
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -1,151 +1,497 @@
-import { debounce } from 'lodash'
-import React, { useEffect, useRef, useState } from 'react'
+import { debounce, round } from 'lodash'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
 
+import { VideoFieldsFragment } from '@/api/queries'
 import { usePersonalDataStore } from '@/providers'
-import { SvgOutlineVideo } from '@/shared/icons'
+import {
+  SvgPlayerFullScreen,
+  SvgPlayerPause,
+  SvgPlayerPip,
+  SvgPlayerPipDisable,
+  SvgPlayerPlay,
+  SvgPlayerRestart,
+  SvgPlayerSmallScreen,
+  SvgPlayerSoundHalf,
+  SvgPlayerSoundOn,
+} from '@/shared/icons'
 import { Logger } from '@/utils/logger'
+import { formatDurationShort } from '@/utils/time'
 
-import { Container, PlayOverlay } from './VideoPlayer.style'
+import { ControlsIndicator } from './ControlsIndicator'
+import { CustomTimeline } from './CustomTimeline'
+import { PlayerControlButton } from './PlayerControlButton'
+import { VideoOverlay } from './VideoOverlay'
+import {
+  BigPlayButton,
+  BigPlayButtonOverlay,
+  Container,
+  ControlsOverlay,
+  CurrentTime,
+  CurrentTimeWrapper,
+  CustomControls,
+  ScreenControls,
+  StyledSvgPlayerSoundOff,
+  VolumeButton,
+  VolumeControl,
+  VolumeSlider,
+  VolumeSliderContainer,
+} from './VideoPlayer.style'
+import { CustomVideojsEvents, VOLUME_STEP, hotkeysHandler } from './utils'
 import { VideoJsConfig, useVideoJsPlayer } from './videoJsPlayer'
 
 export type VideoPlayerProps = {
+  nextVideo?: VideoFieldsFragment | null
   className?: string
   autoplay?: boolean
   isInBackground?: boolean
   playing?: boolean
+  channelId?: string
+  videoId?: string
 } & VideoJsConfig
 
+declare global {
+  interface Document {
+    pictureInPictureEnabled: boolean
+    pictureInPictureElement: Element
+  }
+}
+
+const isPiPSupported = 'pictureInPictureEnabled' in document
+
+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 currentVolume = usePersonalDataStore((state) => state.currentVolume)
+  const cachedVolume = usePersonalDataStore((state) => state.cachedVolume)
+  const setCurrentVolume = usePersonalDataStore((state) => state.actions.setCurrentVolume)
+  const setCachedVolume = usePersonalDataStore((state) => state.actions.setCachedVolume)
+  const [volumeToSave, setVolumeToSave] = useState(0)
+
+  const [isPlaying, setIsPlaying] = useState(false)
+  const [videoTime, setVideoTime] = useState(0)
+  const [isFullScreen, setIsFullScreen] = useState(false)
+  const [isPiPEnabled, setIsPiPEnabled] = useState(false)
+
+  const [playerState, setPlayerState] = useState<PlayerState>(null)
+  const [isLoaded, setIsLoaded] = useState(false)
+
+  // handle hotkeys
+  useEffect(() => {
+    if (!player || isInBackground) {
+      return
+    }
 
-  const playerVolume = usePersonalDataStore((state) => state.playerVolume)
-  const updatePlayerVolume = usePersonalDataStore((state) => state.actions.updatePlayerVolume)
+    const handler = (event: KeyboardEvent) => {
+      if (
+        (document.activeElement?.tagName === 'BUTTON' && event.key === ' ') ||
+        document.activeElement?.tagName === 'INPUT'
+      ) {
+        return
+      }
 
-  const [playOverlayVisible, setPlayOverlayVisible] = useState(true)
-  const [initialized, setInitialized] = useState(false)
+      const playerReservedKeys = ['k', ' ', 'ArrowLeft', 'ArrowRight', 'j', 'l', 'ArrowUp', 'ArrowDown', 'm', 'f']
+      if (playerReservedKeys.includes(event.key)) {
+        event.preventDefault()
+        hotkeysHandler(event, player)
+      }
+    }
+    document.addEventListener('keydown', handler)
 
-  const displayPlayOverlay = playOverlayVisible && !isInBackground
+    return () => document.removeEventListener('keydown', handler)
+  }, [isInBackground, player])
 
+  // handle error
   useEffect(() => {
     if (!player) {
       return
     }
+    const handler = () => {
+      setPlayerState('error')
+    }
+    player.on('error', handler)
+    return () => {
+      player.off('error', handler)
+    }
+  })
+
+  const playVideo = useCallback(() => {
+    if (!player) {
+      return
+    }
+    player.trigger(CustomVideojsEvents.PlayControl)
+    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 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 = () => {
-      setInitialized(true)
+      setPlayerState('ended')
     }
+    player.on('ended', handler)
+    return () => {
+      player.off('ended', handler)
+    }
+  }, [nextVideo, player])
 
+  // handle loadstart
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    const handler = () => {
+      setIsLoaded(true)
+    }
     player.on('loadstart', handler)
-
     return () => {
       player.off('loadstart', handler)
     }
   }, [player])
 
+  // handle autoplay
   useEffect(() => {
-    if (!player || !initialized || !autoplay) {
+    if (!player || !isLoaded || !autoplay) {
       return
     }
-
     const playPromise = player.play()
     if (playPromise) {
       playPromise.catch((e) => {
         Logger.warn('Autoplay failed:', e)
       })
     }
-  }, [player, initialized, autoplay])
+  }, [player, isLoaded, autoplay])
 
+  // handle playing and pausing from outside the component
   useEffect(() => {
     if (!player) {
       return
     }
+    if (playing) {
+      playVideo()
+    } else {
+      player.pause()
+    }
+  }, [playVideo, player, playing])
 
-    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)
-            }
-          })
+  // handle playing and pausing
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+    const handler = (event: Event) => {
+      if (event.type === 'play') {
+        setIsPlaying(true)
+        if (playerState !== 'loading') {
+          setPlayerState('playing')
         }
-      } else {
-        player.pause()
+      }
+      if (event.type === 'pause') {
+        setIsPlaying(false)
       }
     }
-  }, [player, playing])
+    player.on(['play', 'pause'], handler)
+    return () => {
+      player.off(['play', 'pause'], handler)
+    }
+  }, [player, playerState])
 
   useEffect(() => {
-    if (!player) {
+    if (!externalRef) {
       return
     }
+    if (typeof externalRef === 'function') {
+      externalRef(playerRef.current)
+    } else {
+      externalRef.current = playerRef.current
+    }
+  }, [externalRef, playerRef])
 
+  // handle video timer
+  useEffect(() => {
+    if (!player) {
+      return
+    }
     const handler = () => {
-      setPlayOverlayVisible(false)
+      const currentTime = round(player.currentTime())
+      setVideoTime(currentTime)
     }
+    player.on('timeupdate', handler)
+    return () => {
+      player.off('timeupdate', handler)
+    }
+  }, [player])
 
-    player.on('play', handler)
+  // 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) {
+      return
+    }
+    const handler = () => setIsFullScreen(player.isFullscreen())
+    player.on('fullscreenchange', handler)
     return () => {
-      player.off('play', handler)
+      player.off('fullscreenchange', handler)
     }
   }, [player])
 
+  // handle picture in picture
   useEffect(() => {
-    if (!externalRef) {
+    if (!player) {
       return
     }
-    if (typeof externalRef === 'function') {
-      externalRef(playerRef.current)
-    } else {
-      externalRef.current = playerRef.current
+    const handler = (event: Event) => {
+      if (event.type === 'enterpictureinpicture') {
+        setIsPiPEnabled(true)
+      }
+      if (event.type === 'leavepictureinpicture') {
+        setIsPiPEnabled(false)
+      }
     }
-  }, [externalRef, playerRef])
+    player.on(['enterpictureinpicture', 'leavepictureinpicture'], handler)
+    return () => {
+      player.off(['enterpictureinpicture', 'leavepictureinpicture'], handler)
+    }
+  }, [player])
 
-  const handlePlayOverlayClick = () => {
+  // update volume on keyboard input
+  useEffect(() => {
     if (!player) {
       return
     }
-    player.play()
-  }
+    const events = [
+      CustomVideojsEvents.VolumeIncrease,
+      CustomVideojsEvents.VolumeDecrease,
+      CustomVideojsEvents.Muted,
+      CustomVideojsEvents.Unmuted,
+    ]
+
+    const handler = (event: Event) => {
+      if (event.type === CustomVideojsEvents.Muted) {
+        if (currentVolume) {
+          setCachedVolume(currentVolume)
+        }
+        setCurrentVolume(0)
+        return
+      }
+      if (event.type === CustomVideojsEvents.Unmuted) {
+        setCurrentVolume(cachedVolume || VOLUME_STEP)
+        return
+      }
+      if (event.type === CustomVideojsEvents.VolumeIncrease || CustomVideojsEvents.VolumeDecrease) {
+        setCurrentVolume(player.volume())
+      }
+    }
+    player.on(events, handler)
+    return () => {
+      player.off(events, handler)
+    }
+  }, [currentVolume, player, cachedVolume, setCachedVolume, setCurrentVolume])
 
   const debouncedVolumeChange = useRef(
     debounce((volume: number) => {
-      updatePlayerVolume(volume)
-    }, 500)
+      setVolumeToSave(volume)
+    }, 125)
   )
-
-  const isInitialMount = useRef(true)
+  // update volume on mouse input
   useEffect(() => {
-    if (!player || !isInitialMount) {
+    if (!player || isInBackground) {
       return
     }
-    isInitialMount.current = false
+    player?.volume(currentVolume)
 
-    player.volume(playerVolume)
+    debouncedVolumeChange.current(currentVolume)
+    if (currentVolume) {
+      player.muted(false)
+    } else {
+      if (volumeToSave) {
+        setCachedVolume(volumeToSave)
+      }
+      player.muted(true)
+    }
+  }, [currentVolume, volumeToSave, isInBackground, player, setCachedVolume])
 
-    const handleVolumeChange = () => debouncedVolumeChange.current(player.volume())
-    player.on('volumechange', handleVolumeChange)
-    return () => {
-      player.off('volumechange', handleVolumeChange)
+  // button/input handlers
+  const handlePlayPause = () => {
+    if (isPlaying) {
+      player?.pause()
+      player?.trigger(CustomVideojsEvents.PauseControl)
+    } else {
+      playVideo()
+    }
+  }
+
+  const handleChangeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setCurrentVolume(Number(event.target.value))
+  }
+
+  const handleMute = (event: React.MouseEvent) => {
+    event.stopPropagation()
+    if (currentVolume === 0) {
+      setCurrentVolume(cachedVolume || 0.05)
+    } else {
+      setCurrentVolume(0)
     }
-  }, [player, playerVolume])
+  }
+
+  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()
+    }
+  }
+
+  const renderVolumeButton = () => {
+    if (currentVolume === 0) {
+      return <StyledSvgPlayerSoundOff />
+    } else {
+      return currentVolume <= 0.5 ? <SvgPlayerSoundHalf /> : <SvgPlayerSoundOn />
+    }
+  }
 
+  const showBigPlayButton = playerState === null && !isInBackground
+  const showPlayerControls = !isInBackground && isLoaded && playerState
   return (
-    <Container className={className} isInBackground={isInBackground}>
-      {displayPlayOverlay && (
-        <PlayOverlay onClick={handlePlayOverlayClick}>
-          <SvgOutlineVideo width={72} height={72} viewBox="0 0 24 24" />
-        </PlayOverlay>
-      )}
+    <Container isFullScreen={isFullScreen} className={className} isInBackground={isInBackground}>
       <div data-vjs-player>
-        <video ref={playerRef} className="video-js" />
+        {showBigPlayButton && (
+          <BigPlayButtonOverlay onClick={handlePlayPause}>
+            <BigPlayButton onClick={handlePlayPause}>
+              <SvgPlayerPlay />
+            </BigPlayButton>
+          </BigPlayButtonOverlay>
+        )}
+        <video
+          ref={playerRef}
+          className="video-js"
+          onClick={() =>
+            player?.paused()
+              ? player?.trigger(CustomVideojsEvents.PauseControl)
+              : player?.trigger(CustomVideojsEvents.PlayControl)
+          }
+        />
+        {showPlayerControls && (
+          <>
+            <ControlsOverlay isFullScreen={isFullScreen}>
+              <CustomTimeline player={player} isFullScreen={isFullScreen} playerState={playerState} />
+              <CustomControls isFullScreen={isFullScreen} isEnded={playerState === 'ended'}>
+                <PlayerControlButton
+                  onClick={handlePlayPause}
+                  tooltipText={isPlaying ? 'Pause (k)' : 'Play (k)'}
+                  tooltipPosition="left"
+                >
+                  {playerState === 'ended' ? <SvgPlayerRestart /> : isPlaying ? <SvgPlayerPause /> : <SvgPlayerPlay />}
+                </PlayerControlButton>
+                <VolumeControl>
+                  <VolumeButton tooltipText="Volume" showTooltipOnlyOnFocus onClick={handleMute}>
+                    {renderVolumeButton()}
+                  </VolumeButton>
+                  <VolumeSliderContainer>
+                    <VolumeSlider
+                      step={0.01}
+                      max={1}
+                      min={0}
+                      value={currentVolume}
+                      onChange={handleChangeVolume}
+                      type="range"
+                    />
+                  </VolumeSliderContainer>
+                </VolumeControl>
+                <CurrentTimeWrapper>
+                  <CurrentTime variant="body2">
+                    {formatDurationShort(videoTime)} / {formatDurationShort(round(player?.duration() || 0))}
+                  </CurrentTime>
+                </CurrentTimeWrapper>
+                <ScreenControls>
+                  {isPiPSupported && (
+                    <PlayerControlButton onClick={handlePictureInPicture} tooltipText="Picture-in-picture">
+                      {isPiPEnabled ? <SvgPlayerPipDisable /> : <SvgPlayerPip />}
+                    </PlayerControlButton>
+                  )}
+                  <PlayerControlButton
+                    tooltipPosition="right"
+                    tooltipText={isFullScreen ? 'Exit full screen (f)' : 'Full screen (f)'}
+                    onClick={handleFullScreen}
+                  >
+                    {isFullScreen ? <SvgPlayerSmallScreen /> : <SvgPlayerFullScreen />}
+                  </PlayerControlButton>
+                </ScreenControls>
+              </CustomControls>
+            </ControlsOverlay>
+            <VideoOverlay
+              videoId={videoId}
+              playerState={playerState}
+              onPlay={handlePlayPause}
+              channelId={channelId}
+              currentThumbnailUrl={videoJsConfig.posterUrl}
+            />
+          </>
+        )}
+        {!isInBackground && <ControlsIndicator player={player} />}
       </div>
     </Container>
   )

+ 89 - 0
src/shared/components/VideoPlayer/utils.ts

@@ -0,0 +1,89 @@
+import { VideoJsPlayer } from 'video.js'
+
+export const VOLUME_STEP = 0.1
+
+export enum CustomVideojsEvents {
+  BackwardFiveSec = 'BACKWARD_FIVE_SEC',
+  BackwardTenSec = 'BACKWARD_TEN_SEC',
+  ForwardFiveSec = 'FORWARD_FIVE_SEC',
+  ForwardTenSec = 'FORWARD_TEN_SEC',
+  Muted = 'MUTED',
+  Unmuted = 'UNMUTED',
+  VolumeIncrease = 'VOLUME_INCREASE',
+  VolumeDecrease = 'VOLUME_DECREASE',
+  PlayControl = 'PLAY_CONTROL',
+  PauseControl = 'PAUSE_CONTROL',
+}
+
+export const hotkeysHandler = (event: KeyboardEvent, playerInstance: VideoJsPlayer) => {
+  if (!playerInstance) {
+    return
+  }
+  const currentTime = playerInstance.currentTime()
+  const currentVolume = Number(playerInstance.volume().toFixed(2))
+  const isMuted = playerInstance.muted()
+  const isFullscreen = playerInstance.isFullscreen()
+  const isPaused = playerInstance.paused()
+
+  switch (event.code) {
+    case 'Space':
+    case 'KeyK':
+      if (isPaused) {
+        playerInstance.play()
+        playerInstance.trigger(CustomVideojsEvents.PlayControl)
+      } else {
+        playerInstance.pause()
+        playerInstance.trigger(CustomVideojsEvents.PauseControl)
+      }
+      return
+    case 'ArrowLeft':
+      playerInstance.currentTime(currentTime - 5)
+      playerInstance.trigger(CustomVideojsEvents.BackwardFiveSec)
+      return
+    case 'ArrowRight':
+      playerInstance.currentTime(currentTime + 5)
+      playerInstance.trigger(CustomVideojsEvents.ForwardFiveSec)
+      return
+    case 'KeyJ':
+      playerInstance.currentTime(currentTime - 10)
+      playerInstance.trigger(CustomVideojsEvents.BackwardTenSec)
+      return
+    case 'KeyL':
+      playerInstance.currentTime(currentTime + 10)
+      playerInstance.trigger(CustomVideojsEvents.ForwardTenSec)
+      return
+    case 'ArrowUp':
+      if (playerInstance.muted()) {
+        playerInstance.muted(false)
+      }
+      if (currentVolume <= 1) {
+        playerInstance.volume(Math.min(currentVolume + VOLUME_STEP, 1))
+      }
+      playerInstance.trigger(CustomVideojsEvents.VolumeIncrease)
+      return
+    case 'ArrowDown':
+      if (currentVolume >= 0) {
+        playerInstance.volume(Math.max(currentVolume - VOLUME_STEP, 0))
+      }
+      playerInstance.trigger(CustomVideojsEvents.VolumeDecrease)
+      return
+    case 'KeyM':
+      if (isMuted) {
+        playerInstance.trigger(CustomVideojsEvents.Unmuted)
+        playerInstance.muted(false)
+      } else {
+        playerInstance.trigger(CustomVideojsEvents.Muted)
+        playerInstance.muted(true)
+      }
+      return
+    case 'KeyF':
+      if (isFullscreen) {
+        playerInstance.exitFullscreen()
+      } else {
+        playerInstance.requestFullscreen()
+      }
+      return
+    default:
+      return
+  }
+}

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

@@ -38,13 +38,20 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
   const [player, setPlayer] = useState<VideoJsPlayer | null>(null)
 
   useEffect(() => {
+    if (!playerRef.current) {
+      return
+    }
     const videoJsOptions: VideoJsPlayerOptions = {
       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,
+      controlBar: false,
     }
 
-    const playerInstance = videojs(playerRef.current, videoJsOptions)
+    const playerInstance = videojs(playerRef.current as Element, videoJsOptions)
+
     setPlayer(playerInstance)
 
     return () => {

+ 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>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerBackwardFiveSec = (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="M12 4a8 8 0 00-8 8H2C2 6.477 6.477 2 12 2v2zm4 5l-4 3 4 3V9zm-5 0l-4 3 4 3V9z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerBackwardTenSec = (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="M12 4a8 8 0 100 16v2C6.477 22 2 17.523 2 12S6.477 2 12 2v2zm4 5l-4 3 4 3V9zm-5 0l-4 3 4 3V9z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerCaptionsOff = (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="M1 5a2 2 0 012-2h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5zm20 0H3v14h18V5zM5 9a2 2 0 012-2h2a2 2 0 012 2v2H9V9H7v6h2v-2h2v2a2 2 0 01-2 2H7a2 2 0 01-2-2V9zm10-2a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2v-2h-2v2h-2V9h2v2h2V9a2 2 0 00-2-2h-2z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerCaptionsOn = (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="M3 3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2H3zm2 6a2 2 0 012-2h2a2 2 0 012 2v2H9V9H7v6h2v-2h2v2a2 2 0 01-2 2H7a2 2 0 01-2-2V9zm8 0a2 2 0 012-2h2a2 2 0 012 2v2h-2V9h-2v6h2v-2h2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V9z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerCastOff = (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="M1 5a2 2 0 012-2h18a2 2 0 012 2v14a2 2 0 01-2 2H11v-2h10V5H3v6H1V5zm6 16a6 6 0 00-6-6v-2a8 8 0 018 8H7zm-6-2a2 2 0 012 2h2a4 4 0 00-4-4v2z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerCastOn = (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="M3 3a2 2 0 00-2 2v6h2V5h18v14H11v2h10a2 2 0 002-2V5a2 2 0 00-2-2H3zM1 15a6 6 0 016 6h2a8 8 0 00-8-8v2zm2 6a2 2 0 00-2-2v-2a4 4 0 014 4H3zm2-10v.832A10.037 10.037 0 0110.168 17H19V7H5v4z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerEmbed = (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="M15.293 7.707L19.586 12l-4.293 4.293 1.414 1.414 5-5 .707-.707-.707-.707-5-5-1.414 1.414zm-6.586 8.586L4.414 12l4.293-4.293-1.414-1.414-5 5-.707.707.707.707 5 5 1.414-1.414z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerForwardFiveSec = (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="M12 4a8 8 0 018 8h2c0-5.523-4.477-10-10-10v2zM8 9l4 3-4 3V9zm5 0l4 3-4 3V9z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerForwardTenSec = (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="M12 4a8 8 0 110 16v2c5.523 0 10-4.477 10-10S17.523 2 12 2v2zM8 9l4 3-4 3V9zm5 0l4 3-4 3V9z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

+ 4 - 5
src/shared/icons/PlayerFullScreen.tsx

@@ -4,11 +4,10 @@ import * as React from 'react'
 export const SvgPlayerFullScreen = (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
-      d="M4 16v3a1 1 0 001 1h3m12-4v3a1 1 0 01-1 1h-3M20 8V5a1 1 0 00-1-1h-3M4 8V5a1 1 0 011-1h3"
-      stroke="#F4F6F8"
-      strokeWidth={2}
-      strokeMiterlimit={10}
-      strokeLinecap="square"
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M5 15v4h4v2H5a2 2 0 01-2-2v-4h2zm16 0v4a2 2 0 01-2 2h-4v-2h4v-4h2zM3 9V5a2 2 0 012-2h4v2H5v4H3zm16 0V5h-4V3h4a2 2 0 012 2v4h-2z"
+      fill="#F4F6F8"
     />
   </svg>
 )

+ 1 - 1
src/shared/icons/PlayerNext.tsx

@@ -3,7 +3,7 @@ import * as React from 'react'
 
 export const SvgPlayerNext = (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 d="M19 5v14" stroke="#F4F6F8" strokeWidth={2} strokeMiterlimit={10} />
+    <path fillRule="evenodd" clipRule="evenodd" d="M18 19V5h2v14h-2z" fill="#F4F6F8" />
     <path d="M4 5l11 7-11 7V5z" fill="#F4F6F8" />
   </svg>
 )

+ 1 - 1
src/shared/icons/PlayerPause.tsx

@@ -3,6 +3,6 @@ import * as React from 'react'
 
 export const SvgPlayerPause = (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 d="M8 4v16M16 4v16M16 4v16" stroke="#F4F6F8" strokeWidth={4} strokeMiterlimit={10} />
+    <path fillRule="evenodd" clipRule="evenodd" d="M6 20V4h4v16H6zM14 20V4h4v16h-4z" fill="#F4F6F8" />
   </svg>
 )

+ 6 - 2
src/shared/icons/PlayerPip.tsx

@@ -3,7 +3,11 @@ import * as React from 'react'
 
 export const SvgPlayerPip = (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 d="M2 5a1 1 0 011-1h18a1 1 0 011 1v14a1 1 0 01-1 1H3a1 1 0 01-1-1V5z" stroke="#F4F6F8" strokeWidth={2} />
-    <path d="M13 13h6v4h-6v-4z" fill="#F4F6F8" />
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M3 3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2H3zm0 2h18v14H3V5zm16 8h-6v4h6v-4z"
+      fill="#F4F6F8"
+    />
   </svg>
 )

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

@@ -0,0 +1,14 @@
+// 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={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <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"
+      fill="#F4F6F8"
+    />
+    <path d="M3 17V7l2 2v8h8l2 2H5a2 2 0 01-2-2z" fill="#F4F6F8" />
+  </svg>
+)

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

@@ -0,0 +1,8 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerPrevious = (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="M9 12l11 7V5L9 12zm-5 7h2V5H4v14z" fill="#F4F6F8" />
+  </svg>
+)

+ 9 - 0
src/shared/icons/PlayerReplay.tsx

@@ -0,0 +1,9 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerReplay = (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 d="M8 11l-3 4-3-4h6z" fill="#F4F6F8" />
+    <path d="M5 12a7 7 0 112 4.899" stroke="#F4F6F8" strokeWidth={2} />
+  </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>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerShare = (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="M20 12l-8-6v4h-2c-4 0-6 2-6 5v1h2v-1a1 1 0 011-1h5v4l8-6z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

+ 4 - 5
src/shared/icons/PlayerSmallScreen.tsx

@@ -4,11 +4,10 @@ import * as React from 'react'
 export const SvgPlayerSmallScreen = (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
-      d="M16 4v3a1 1 0 001 1h3M8 4v3a1 1 0 01-1 1H4M8 20v-3a1 1 0 00-1-1H4M16 20v-3a1 1 0 011-1h3"
-      stroke="#F4F6F8"
-      strokeWidth={2}
-      strokeMiterlimit={10}
-      strokeLinecap="square"
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M17 3v4h4v2h-4a2 2 0 01-2-2V3h2zM9 3v4a2 2 0 01-2 2H3V7h4V3h2zM7 21v-4H3v-2h4a2 2 0 012 2v4H7zM15 21v-4a2 2 0 012-2h4v2h-4v4h-2z"
+      fill="#F4F6F8"
     />
   </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>
+)

+ 1 - 10
src/shared/icons/PlayerSoundOff.tsx

@@ -3,17 +3,8 @@ import * as React from 'react'
 
 export const SvgPlayerSoundOff = (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 d="M21.222 20.808l-18-18" stroke="#F4F6F8" strokeWidth={2} strokeMiterlimit={10} />
     <path
-      fillRule="evenodd"
-      clipRule="evenodd"
-      d="M14.899 15.899A4.002 4.002 0 0014 8v2a2 2 0 110 4v1l.899.899zM5.172 9H3a1 1 0 00-1 1v4a1 1 0 001 1h3l4 5h2v-4.172L5.172 9zM12 13V4h-2L6.889 7.889 12 12.999z"
-      fill="#F4F6F8"
-    />
-    <path
-      fillRule="evenodd"
-      clipRule="evenodd"
-      d="M14.17 17.998A6.362 6.362 0 0114 18v2a8.02 8.02 0 001.936-.236l-1.767-1.766zm3.786.957A8 8 0 0014 4v2a6 6 0 012.47 11.47l1.486 1.485z"
+      d="M15.326 13.497L12 10.171V4h-2L8.146 6.317 3.929 2.1 2.515 3.515l18 18 1.414-1.415-2.358-2.358A8 8 0 0014 4v2a6 6 0 014.155 10.327l-1.415-1.414A4 4 0 0014 8v2a2 2 0 011.326 3.497zM5.172 9H3a1 1 0 00-1 1v4a1 1 0 001 1h3l4 5h2v-4.172L5.172 9zM14 18c.057 0 .113 0 .17-.002l1.766 1.766A8.02 8.02 0 0114 20v-2z"
       fill="#F4F6F8"
     />
   </svg>

+ 2 - 1
src/shared/icons/PlayerSoundOn.tsx

@@ -4,6 +4,7 @@ import * as React from 'react'
 export const SvgPlayerSoundOn = (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 d="M6 9l4-5h2v16h-2l-4-5H3a1 1 0 01-1-1v-4a1 1 0 011-1h3z" fill="#F4F6F8" />
-    <path d="M14 19a7 7 0 100-14M14 15a3 3 0 100-6" stroke="#F4F6F8" strokeWidth={2} />
+    <path fillRule="evenodd" clipRule="evenodd" d="M20 12a6 6 0 00-6-6V4a8 8 0 110 16v-2a6 6 0 006-6z" fill="#F4F6F8" />
+    <path fillRule="evenodd" clipRule="evenodd" d="M16 12a2 2 0 00-2-2V8a4 4 0 010 8v-2a2 2 0 002-2z" fill="#F4F6F8" />
   </svg>
 )

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerSubtitlesOff = (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="M3 3a2 2 0 00-2 2v12a2 2 0 002 2h2v.234c0 1.554 1.696 2.515 3.029 1.715L11.277 19H21a2 2 0 002-2V5a2 2 0 00-2-2H3zm0 2h18v12H10.723l-.238.142L7 19.234V17H3V5zm10 8H9v2h4v-2zm-8 0h2v2H5v-2zm14 0h-4v2h4v-2z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerSubtitlesOn = (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="M3 3a2 2 0 00-2 2v12a2 2 0 002 2h2v.234c0 1.554 1.696 2.515 3.029 1.715L11.277 19H21a2 2 0 002-2V5a2 2 0 00-2-2H3zm10 10H9v2h4v-2zm-8 0h2v2H5v-2zm14 0h-4v2h4v-2z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerVideoModeCinemaView = (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="M3 3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2H3zm0 2h18v14H3V5zm16 2H5v6h14V7z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerVideoModeCompactView = (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="M3 3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2H3zm0 2h18v14H3V5zm12 2H5v6h10V7z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerVideoSettingsOff = (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.061 1.647l-.41-.41-.561.152c-.896.246-1.746.601-2.535 1.052l-.505.287V5.222l-.828.828H2.729l-.289.505c-.45.789-.805 1.639-1.05 2.535l-.154.56.41.411L3 11.414v1.172l-1.353 1.353-.411.41.153.561c.246.896.601 1.747 1.051 2.535l.288.505H5.222l.828.828v2.493l.505.289c.789.45 1.639.806 2.535 1.05l.56.154.411-.41L11.414 21h1.172l1.353 1.353.41.411.561-.153c.896-.245 1.747-.601 2.535-1.051l.505-.288V18.778l.828-.828h2.493l.289-.505c.45-.788.806-1.639 1.05-2.535l.154-.56-.41-.41L21 12.585v-1.172l1.353-1.353.411-.41-.153-.561c-.245-.896-.601-1.746-1.051-2.535l-.288-.505H18.778l-.828-.828V2.729l-.505-.288c-.788-.45-1.639-.806-2.535-1.052l-.56-.153-.41.41L12.585 3h-1.172l-1.353-1.353zm-2.01 3.99V3.91c.33-.162.67-.304 1.021-.424l1.22 1.22.294.293H13.414l.293-.293 1.22-1.22c.352.12.693.262 1.023.424V6.05l.293.293 1.414 1.415.293.292h2.14c.161.33.303.672.424 1.022l-1.221 1.221-.293.293V13.414l.293.293 1.22 1.22c-.12.352-.262.693-.423 1.023h-2.14l-.293.293-1.414 1.414-.293.293v2.14a8.94 8.94 0 01-1.022.424l-1.22-1.221-.294-.293H10.586l-.293.293-1.22 1.22a8.93 8.93 0 01-1.023-.423v-2.14l-.293-.293-1.414-1.414-.293-.293H3.911a8.932 8.932 0 01-.424-1.022l1.22-1.22.293-.294V10.586l-.293-.293-1.22-1.22c.12-.351.262-.693.424-1.023H6.05l.293-.292 1.414-1.415.293-.293v-.414zM10 12a2 2 0 114 0 2 2 0 01-4 0zm2-4a4 4 0 100 8 4 4 0 000-8z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

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

@@ -0,0 +1,13 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgPlayerVideoSettingsOn = (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="M12.586 3h-1.172L9.65 1.236l-.56.153c-.896.246-1.746.601-2.535 1.052l-.505.287v2.494l-.828.828H2.728l-.288.505c-.45.789-.805 1.639-1.05 2.535l-.154.56L3 11.414v1.172L1.236 14.35l.153.56c.246.896.601 1.747 1.051 2.535l.288.505h2.494l.828.828v2.494l.505.288c.789.45 1.639.806 2.535 1.05l.56.154L11.414 21h1.172l1.764 1.764.56-.153c.896-.245 1.747-.601 2.535-1.051l.505-.288v-2.494l.828-.828h2.494l.288-.505c.45-.788.806-1.639 1.05-2.535l.154-.56L21 12.586v-1.172l1.764-1.764-.153-.56c-.245-.896-.601-1.746-1.051-2.535l-.288-.505h-2.494l-.828-.828V2.728l-.505-.287c-.788-.45-1.639-.806-2.535-1.052l-.56-.153L12.586 3zM12 16a4 4 0 100-8 4 4 0 000 8z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

+ 22 - 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'
@@ -74,13 +75,34 @@ export * from './OutlineVideo'
 export * from './OutlineWarning'
 export * from './OutlineZoomIn'
 export * from './OutlineZoomOut'
+export * from './PlayerBackwardFiveSec'
+export * from './PlayerBackwardTenSec'
+export * from './PlayerCaptionsOff'
+export * from './PlayerCaptionsOn'
+export * from './PlayerCastOff'
+export * from './PlayerCastOn'
+export * from './PlayerEmbed'
+export * from './PlayerForwardFiveSec'
+export * from './PlayerForwardTenSec'
 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 './PlayerPrevious'
+export * from './PlayerReplay'
+export * from './PlayerRestart'
+export * from './PlayerShare'
 export * from './PlayerSmallScreen'
+export * from './PlayerSoundHalf'
 export * from './PlayerSoundOff'
 export * from './PlayerSoundOn'
+export * from './PlayerSubtitlesOff'
+export * from './PlayerSubtitlesOn'
+export * from './PlayerVideoModeCinemaView'
+export * from './PlayerVideoModeCompactView'
+export * from './PlayerVideoSettingsOff'
+export * from './PlayerVideoSettingsOn'

+ 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>

+ 3 - 0
src/shared/icons/svgs/player-backward-five-sec.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="M12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2V4ZM16 9L12 12L16 15V9ZM11 9L7 12L11 15L11 9Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-backward-ten-sec.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="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20V22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2V4ZM16 9L12 12L16 15V9ZM11 9L7 12L11 15L11 9Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-captions-off.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="M1 5C1 3.89543 1.89543 3 3 3H21C22.1046 3 23 3.89543 23 5V19C23 20.1046 22.1046 21 21 21H3C1.89543 21 1 20.1046 1 19V5ZM21 5H3V19H21V5ZM5 9C5 7.89543 5.89543 7 7 7H9C10.1046 7 11 7.89543 11 9V11H9V9H7V15H9V13H11V15C11 16.1046 10.1046 17 9 17H7C5.89543 17 5 16.1046 5 15V9ZM15 7C13.8954 7 13 7.89543 13 9V15C13 16.1046 13.8954 17 15 17H17C18.1046 17 19 16.1046 19 15V13H17V15H15V9H17V11H19V9C19 7.89543 18.1046 7 17 7H15Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-captions-on.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="M3 3C1.89543 3 1 3.89543 1 5V19C1 20.1046 1.89543 21 3 21H21C22.1046 21 23 20.1046 23 19V5C23 3.89543 22.1046 3 21 3H3ZM5 9C5 7.89543 5.89543 7 7 7H9C10.1046 7 11 7.89543 11 9V11H9V9H7V15H9V13H11V15C11 16.1046 10.1046 17 9 17H7C5.89543 17 5 16.1046 5 15V9ZM13 9C13 7.89543 13.8954 7 15 7H17C18.1046 7 19 7.89543 19 9V11H17V9H15V15H17V13H19V15C19 16.1046 18.1046 17 17 17H15C13.8954 17 13 16.1046 13 15V9Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-cast-off.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="M1 5C1 3.89543 1.89543 3 3 3H21C22.1046 3 23 3.89543 23 5V19C23 20.1046 22.1046 21 21 21H11V19H21V5H3V11H1V5ZM7 21C7 17.6863 4.31371 15 1 15V13C5.41828 13 9 16.5817 9 21H7ZM1 19C2.10457 19 3 19.8954 3 21H5C5 18.7909 3.20914 17 1 17V19Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-cast-on.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="M3 3C1.89543 3 1 3.89543 1 5V11H3V5H21V19H11V21H21C22.1046 21 23 20.1046 23 19V5C23 3.89543 22.1046 3 21 3H3ZM1 15C4.31371 15 7 17.6863 7 21H9C9 16.5817 5.41828 13 1 13V15ZM3 21C3 19.8954 2.10457 19 1 19V17C3.20914 17 5 18.7909 5 21H3ZM5 11V11.8321C7.30688 12.84 9.15999 14.6932 10.1679 17H12.3172L12.3172 17H19V7H13H11H5V9.68286V11Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-embed.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="M15.2929 7.70718L19.5858 12.0001L15.2929 16.293L16.7071 17.7072L21.7071 12.7072L22.4142 12.0001L21.7071 11.293L16.7071 6.29297L15.2929 7.70718ZM8.70714 16.293L4.41424 12.0001L8.70713 7.70718L7.29292 6.29297L2.29292 11.293L1.58582 12.0001L2.29292 12.7072L7.29292 17.7072L8.70714 16.293Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-forward-five-sec.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="M12 4C16.4183 4 20 7.58172 20 12H22C22 6.47715 17.5228 2 12 2V4ZM8 9L12 12L8 15V9ZM13 9L17 12L13 15L13 9Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-forward-ten-sec.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="M12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20V22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2V4ZM8 9L12 12L8 15V9ZM13 9L17 12L13 15L13 9Z" fill="#F4F6F8"/>
+</svg>

+ 2 - 2
src/shared/icons/svgs/player-full-screen.svg

@@ -1,4 +1,4 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 16V19C4 19.5523 4.44772 20 5 20H8M20 16V19C20 19.5523 19.5523 20 19 20H16" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
-<path d="M20 8L20 5C20 4.44772 19.5523 4 19 4L16 4M4 8L4 5C4 4.44772 4.44771 4 5 4L8 4" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5 15V19H9V21H5C3.89543 21 3 20.1046 3 19V15H5ZM21 15V19C21 20.1046 20.1046 21 19 21H15V19H19V15H21Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3 9L3 5C3 3.89543 3.89543 3 5 3L9 3V5L5 5L5 9H3ZM19 9V5L15 5V3L19 3C20.1046 3 21 3.89543 21 5V9H19Z" fill="#F4F6F8"/>
 </svg>

+ 1 - 1
src/shared/icons/svgs/player-next.svg

@@ -1,4 +1,4 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M19 5L19 19" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18 19L18 5L20 5L20 19H18Z" fill="#F4F6F8"/>
 <path d="M4 5L15 12L4 19V5Z" fill="#F4F6F8"/>
 </svg>

+ 2 - 3
src/shared/icons/svgs/player-pause.svg

@@ -1,5 +1,4 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 4L8 20" stroke="#F4F6F8" stroke-width="4" stroke-miterlimit="10"/>
-<path d="M16 4L16 20" stroke="#F4F6F8" stroke-width="4" stroke-miterlimit="10"/>
-<path d="M16 4L16 20" stroke="#F4F6F8" stroke-width="4" stroke-miterlimit="10"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6 20L6 4L10 4L10 20H6Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14 20L14 4L18 4L18 20H14Z" fill="#F4F6F8"/>
 </svg>

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

@@ -0,0 +1,4 @@
+<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="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"/>
+</svg>

+ 1 - 2
src/shared/icons/svgs/player-pip.svg

@@ -1,4 +1,3 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 5C2 4.44772 2.44772 4 3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5Z" stroke="#F4F6F8" stroke-width="2"/>
-<path d="M13 13H19V17H13V13Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3C1.89543 3 1 3.89543 1 5V19C1 20.1046 1.89543 21 3 21H21C22.1046 21 23 20.1046 23 19V5C23 3.89543 22.1046 3 21 3H3ZM3 5H21V19H3V5ZM19 13H13V17H19V13Z" fill="#F4F6F8"/>
 </svg>

+ 3 - 0
src/shared/icons/svgs/player-previous.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="M9 12L20 19L20 5L9 12ZM4 19L6 19L6 5L4 5L4 19Z" fill="#F4F6F8"/>
+</svg>

+ 4 - 0
src/shared/icons/svgs/player-replay.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<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"/>
+</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>

+ 3 - 0
src/shared/icons/svgs/player-share.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="M19.9998 12L11.9998 6V9.99963C9.85055 9.99963 9.99976 9.99969 9.99976 9.99969C5.99976 9.99969 4 12 4 14.9996V15.9996H6V14.9996C5.99987 14.4474 6.44708 13.9996 6.99932 13.9996H11.9998V18L19.9998 12Z" fill="#F4F6F8"/>
+</svg>

+ 4 - 4
src/shared/icons/svgs/player-small-screen.svg

@@ -1,6 +1,6 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M16 4V7C16 7.55228 16.4477 8 17 8H20" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
-<path d="M8 4V7C8 7.55228 7.55228 8 7 8H4" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
-<path d="M8 20L8 17C8 16.4477 7.55228 16 7 16L4 16" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
-<path d="M16 20L16 17C16 16.4477 16.4477 16 17 16L20 16" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17 3V7H21V9H17C15.8954 9 15 8.10457 15 7V3H17Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9 3V7C9 8.10457 8.10457 9 7 9H3V7H7V3H9Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7 21L7 17H3L3 15H7C8.10457 15 9 15.8954 9 17L9 21H7Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15 21L15 17C15 15.8954 15.8954 15 17 15H21V17H17L17 21H15Z" fill="#F4F6F8"/>
 </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>

+ 3 - 4
src/shared/icons/svgs/player-sound-off.svg

@@ -1,6 +1,5 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M21.2218 20.8076L3.2218 2.80762" stroke="#F4F6F8" stroke-width="2" stroke-miterlimit="10"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M14.8988 15.8986C16.6751 15.4908 18 13.9001 18 12C18 9.79086 16.2091 8 14 8V10C15.1046 10 16 10.8954 16 12C16 13.1046 15.1046 14 14 14V14.9998L14.8988 15.8986Z" fill="#F4F6F8"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5.17172 9H3C2.44772 9 2 9.44772 2 10V14C2 14.5523 2.44772 15 3 15H6L10 20H12V15.8283L5.17172 9ZM12 12.9998V4H10L6.88896 7.8888L12 12.9998Z" fill="#F4F6F8"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1694 17.9977C14.1131 17.9992 14.0566 18 14 18V20C14.6676 20 15.316 19.9182 15.9359 19.7642L14.1694 17.9977ZM17.9555 18.9553C20.3712 17.5785 22 14.9795 22 12C22 7.58172 18.4183 4 14 4V6C17.3137 6 20 8.68629 20 12C20 14.4333 18.5516 16.5282 16.4698 17.4697L17.9555 18.9553Z" fill="#F4F6F8"/>
+<path d="M15.3259 13.4973L12 10.1714V3.99991H10L8.14602 6.31738L3.92898 2.10034L2.51477 3.51456L20.5148 21.5146L21.929 20.1003L19.5705 17.7418C21.0689 16.2879 22 14.2527 22 11.9999C22 7.58163 18.4183 3.99991 14 3.99991V5.99991C17.3137 5.99991 20 8.6862 20 11.9999C20 13.7004 19.2926 15.2356 18.1561 16.3274L16.7414 14.9128C17.5162 14.1833 18 13.1481 18 11.9999C18 9.79077 16.2091 7.99991 14 7.99991V9.99991C15.1046 9.99991 16 10.8953 16 11.9999C16 12.5958 15.7394 13.1309 15.3259 13.4973Z" fill="#F4F6F8"/>
+<path d="M5.17172 8.99991H3C2.44772 8.99991 2 9.44762 2 9.99991V13.9999C2 14.5522 2.44772 14.9999 3 14.9999H6L10 19.9999H12V15.8282L5.17172 8.99991Z" fill="#F4F6F8"/>
+<path d="M14 17.9999C14.0566 17.9999 14.1131 17.9991 14.1694 17.9976L15.9359 19.7641C15.316 19.9181 14.6676 19.9999 14 19.9999V17.9999Z" fill="#F4F6F8"/>
 </svg>

+ 2 - 2
src/shared/icons/svgs/player-sound-on.svg

@@ -1,5 +1,5 @@
 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
 <path d="M6 9L10 4H12V20H10L6 15H3C2.44772 15 2 14.5523 2 14V10C2 9.44772 2.44772 9 3 9H6Z" fill="#F4F6F8"/>
-<path d="M14 19C17.866 19 21 15.866 21 12C21 8.13401 17.866 5 14 5" stroke="#F4F6F8" stroke-width="2"/>
-<path d="M14 15C15.6569 15 17 13.6569 17 12C17 10.3431 15.6569 9 14 9" stroke="#F4F6F8" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M20 12C20 8.68629 17.3137 6 14 6V4C18.4183 4 22 7.58172 22 12C22 16.4183 18.4183 20 14 20V18C17.3137 18 20 15.3137 20 12Z" fill="#F4F6F8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16 12C16 10.8954 15.1046 10 14 10V8C16.2091 8 18 9.79086 18 12C18 14.2091 16.2091 16 14 16V14C15.1046 14 16 13.1046 16 12Z" fill="#F4F6F8"/>
 </svg>

+ 3 - 0
src/shared/icons/svgs/player-subtitles-off.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="M3 3C1.89543 3 1 3.89543 1 5V17C1 18.1046 1.89543 19 3 19H5V19.2338C5 20.7884 6.69595 21.7486 8.02899 20.9488L11.277 19H21C22.1046 19 23 18.1046 23 17V5C23 3.89543 22.1046 3 21 3H3ZM3 5L21 5V17H11H10.723L10.4855 17.1425L7 19.2338V18V17H6H3V5ZM13 13H9V15H13V13ZM5 13H7V15H5V13ZM19 13H15V15H19V13Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-subtitles-on.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="M3 3C1.89543 3 1 3.89543 1 5V17C1 18.1046 1.89543 19 3 19H5V19.2338C5 20.7884 6.69595 21.7486 8.02899 20.9488L11.277 19H21C22.1046 19 23 18.1046 23 17V5C23 3.89543 22.1046 3 21 3H3ZM13 13H9V15H13V13ZM5 13H7V15H5V13ZM19 13H15V15H19V13Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-video-mode-cinema-view.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="M3 3C1.89543 3 1 3.89543 1 5V19C1 20.1046 1.89543 21 3 21H21C22.1046 21 23 20.1046 23 19V5C23 3.89543 22.1046 3 21 3H3ZM3 5H21V19H3V5ZM19 7H5V13H19V7Z" fill="#F4F6F8"/>
+</svg>

+ 3 - 0
src/shared/icons/svgs/player-video-mode-compact-view.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="M3 3C1.89543 3 1 3.89543 1 5V19C1 20.1046 1.89543 21 3 21H21C22.1046 21 23 20.1046 23 19V5C23 3.89543 22.1046 3 21 3H3ZM3 5H21V19H3V5ZM15 7H5V13H15V7Z" fill="#F4F6F8"/>
+</svg>

File diff suppressed because it is too large
+ 1 - 0
src/shared/icons/svgs/player-video-settings-off.svg


+ 3 - 0
src/shared/icons/svgs/player-video-settings-on.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="M12.5858 3.00011L11.4143 3.00011L9.65024 1.23608L9.08992 1.38944C8.19381 1.6347 7.34352 1.99031 6.55467 2.44053L6.05036 2.72836V5.222L5.22193 6.05043L2.7283 6.05043L2.44047 6.55474C1.99024 7.34358 1.63464 8.19387 1.38938 9.08998L1.23602 9.6503L3.00005 11.4143L3.00005 12.5859L1.23602 14.3499L1.38938 14.9102C1.63464 15.8064 1.99025 16.6567 2.44047 17.4455L2.7283 17.9498H5.22194L6.05037 18.7782V21.2719L6.55468 21.5597C7.34352 22.0099 8.19381 22.3655 9.08992 22.6108L9.65024 22.7641L11.4143 21.0001L12.5858 21.0001L14.3499 22.7641L14.9102 22.6108C15.8063 22.3655 16.6566 22.0099 17.4454 21.5597L17.9498 21.2719V18.7782L18.7782 17.9498H21.2718L21.5596 17.4455C22.0099 16.6567 22.3655 15.8064 22.6107 14.9102L22.7641 14.3499L21.0001 12.5859L21.0001 11.4143L22.7641 9.6503L22.6107 9.08998C22.3655 8.19387 22.0099 7.34358 21.5596 6.55473L21.2718 6.05042L18.7782 6.05042L17.9497 5.22199V2.72836L17.4454 2.44053C16.6566 1.9903 15.8063 1.6347 14.9102 1.38944L14.3499 1.23608L12.5858 3.00011ZM12 16C14.2091 16 16 14.2091 16 12C16 9.79086 14.2091 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16Z" 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: {

+ 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);
   }
 `
 

+ 3 - 39
src/views/viewer/VideoView/VideoView.tsx

@@ -1,10 +1,9 @@
 import { throttle } from 'lodash'
 import React, { useCallback, useEffect, useState } from 'react'
-import { useMatch, useParams } from 'react-router-dom'
+import { useParams } from 'react-router-dom'
 
 import { useAddVideoView, useVideo } from '@/api/hooks'
 import { ChannelLink, InfiniteVideoGrid } from '@/components'
-import { absoluteRoutes } from '@/config/routes'
 import knownLicenses from '@/data/knownLicenses.json'
 import { useRouterQuery } from '@/hooks'
 import { AssetType, useAsset, usePersonalDataStore } from '@/providers'
@@ -44,7 +43,6 @@ export const VideoView: React.FC = () => {
   })
   const { url: mediaUrl } = useAsset({ entity: video, assetType: AssetType.MEDIA })
 
-  const videoRouteMatch = useMatch({ path: absoluteRoutes.viewer.video(id) })
   const [startTimestamp, setStartTimestamp] = useState<number>()
   useEffect(() => {
     if (startTimestamp != null) {
@@ -66,32 +64,6 @@ export const VideoView: React.FC = () => {
   const channelId = video?.channel.id
   const videoId = video?.id
 
-  const [playing, setPlaying] = useState(true)
-  const handleUserKeyPress = useCallback(
-    (event: Event) => {
-      const { keyCode } = event as KeyboardEvent
-      if (videoRouteMatch) {
-        switch (keyCode) {
-          case 32:
-            event.preventDefault()
-            setPlaying((prevState) => !prevState)
-            break
-          default:
-            break
-        }
-      }
-    },
-    [videoRouteMatch]
-  )
-
-  useEffect(() => {
-    window.addEventListener('keydown', handleUserKeyPress)
-
-    return () => {
-      window.removeEventListener('keydown', handleUserKeyPress)
-    }
-  }, [handleUserKeyPress])
-
   useEffect(() => {
     if (!videoId || !channelId) {
       return
@@ -125,13 +97,6 @@ export const VideoView: React.FC = () => {
     }
   }, [video?.id, handleTimeUpdate, updateWatchedVideos])
 
-  const handlePlay = useCallback(() => {
-    setPlaying(true)
-  }, [])
-  const handlePause = useCallback(() => {
-    setPlaying(false)
-  }, [])
-
   if (error) {
     throw error
   }
@@ -148,14 +113,13 @@ export const VideoView: React.FC = () => {
         <PlayerContainer>
           {video ? (
             <VideoPlayer
-              playing={playing}
+              channelId={video.channel.id}
+              autoplay
               src={mediaUrl}
               fill
               posterUrl={thumbnailPhotoUrl}
               onEnd={handleVideoEnd}
               onTimeUpdated={handleTimeUpdate}
-              onPlay={handlePlay}
-              onPause={handlePause}
               startTime={startTimestamp}
             />
           ) : (

+ 80 - 82
yarn.lock

@@ -4961,10 +4961,10 @@
   dependencies:
     vfile "*"
 
-"@types/video.js@^7.3.10":
-  version "7.3.11"
-  resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.11.tgz#b1749fcc733c608ede4d10c9a570ebafc4ec2486"
-  integrity sha512-9KpJkt6zsy6xRiHZjzjKxCEySqt3TgPgl0XegszwJfaLO4+n4rELS5rbsHUgLJxZaMgbJtyTPXk1HkCsJ5kRiA==
+"@types/video.js@^7.3.23":
+  version "7.3.23"
+  resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.23.tgz#13c08a8eb52aca2774d27ca3be25c81c4d44a314"
+  integrity sha512-cTwyy15AOxxTVN6fWWwX7D/zLzOOx3smUCk9630wUb4t/E5tWOjcJ7kZoS86OjThberkQjI3WlN4S7rLmUFuaA==
 
 "@types/webpack-env@^1.15.3":
   version "1.16.0"
@@ -5217,27 +5217,28 @@
   resolved "https://registry.yarnpkg.com/@ungap/global-this/-/global-this-0.4.3.tgz#44cb668b03e7c4bc88cb6e6f9329d381131878ee"
   integrity sha512-MuHEpDBurNVeD6mV9xBcAN2wfTwuaFQhHuhWkJuXmyVJ5P5sBCw+nnFpdfb0tAvgWkfefWCsAoAsh7MTUr3LPg==
 
-"@videojs/http-streaming@1.13.2":
-  version "1.13.2"
-  resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-1.13.2.tgz#9e91f9f440ccaf6c8ed640a3614216397bb38558"
-  integrity sha512-U4Xhh+HxGpRBx9Gm0LlEadq85k9BwckzFgZmyhacauhK/27Mz0goKKFAt+BpxBNp2oHVdAdk8NHfneinsqni3Q==
+"@videojs/http-streaming@2.9.1":
+  version "2.9.1"
+  resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.9.1.tgz#16b59efe24a832b89b5bd6a6c52f0d80ad7996a2"
+  integrity sha512-QAtlrBBILOflrei1KE0GcSDDWiP888ZOySck6zWlQNYi/pXOm6QXTJHzOMIKiRQOndyJIZRTfLHedeUdUIDNLA==
   dependencies:
-    aes-decrypter "3.0.0"
-    global "^4.3.0"
-    m3u8-parser "4.4.0"
-    mpd-parser "0.10.0"
-    mux.js "5.5.1"
-    url-toolkit "^2.1.3"
-    video.js "^6.8.0 || ^7.0.0"
+    "@babel/runtime" "^7.12.5"
+    "@videojs/vhs-utils" "^3.0.2"
+    aes-decrypter "3.1.2"
+    global "^4.4.0"
+    m3u8-parser "4.7.0"
+    mpd-parser "0.17.0"
+    mux.js "5.11.1"
+    video.js "^6 || ^7"
 
-"@videojs/vhs-utils@^1.1.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-1.3.0.tgz#04fe402f603af9a5df4b88881fabba0cf13814c2"
-  integrity sha512-oiqXDtHQqDPun7JseWkirUHGrgdYdeF12goUut5z7vwAj4DmUufEPFJ4xK5hYGXGFDyDhk2rSFOR122Ze6qXyQ==
+"@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz#0203418ecaaff29bc33c69b6ad707787347b7614"
+  integrity sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==
   dependencies:
-    "@babel/runtime" "^7.5.5"
-    global "^4.3.2"
-    url-toolkit "^2.1.6"
+    "@babel/runtime" "^7.12.5"
+    global "^4.4.0"
+    url-toolkit "^2.2.1"
 
 "@videojs/xhr@2.5.1":
   version "2.5.1"
@@ -5542,14 +5543,15 @@ adjust-sourcemap-loader@3.0.0:
     loader-utils "^2.0.0"
     regex-parser "^2.2.11"
 
-aes-decrypter@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.0.0.tgz#7848a1c145b9fdbf57ae3e2b5b1bc7cf0644a8fb"
-  integrity sha1-eEihwUW5/b9Xrj4rWxvHzwZEqPs=
+aes-decrypter@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.1.2.tgz#3545546f8e9f6b878640339a242efe221ba7a7cb"
+  integrity sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==
   dependencies:
-    commander "^2.9.0"
-    global "^4.3.2"
-    pkcs7 "^1.0.2"
+    "@babel/runtime" "^7.12.5"
+    "@videojs/vhs-utils" "^3.0.0"
+    global "^4.4.0"
+    pkcs7 "^1.0.4"
 
 agent-base@6:
   version "6.0.2"
@@ -8033,7 +8035,7 @@ comma-separated-tokens@^1.0.0:
   resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea"
   integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==
 
-commander@^2.15.0, commander@^2.19.0, commander@^2.20.0, commander@^2.9.0:
+commander@^2.15.0, commander@^2.19.0, commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -11194,15 +11196,7 @@ global-prefix@^3.0.0:
     kind-of "^6.0.2"
     which "^1.3.1"
 
-global@4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
-  integrity sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=
-  dependencies:
-    min-document "^2.19.0"
-    process "~0.5.1"
-
-global@^4.3.0, global@^4.3.1, global@^4.3.2, global@^4.4.0, global@~4.4.0:
+global@^4.3.1, global@^4.3.2, global@^4.4.0, global@~4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
   integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==
@@ -14597,12 +14591,14 @@ ltgt@^2.1.2:
   resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5"
   integrity sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=
 
-m3u8-parser@4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.4.0.tgz#adf606c0af6d97f6750095a42006c2ae03dde177"
-  integrity sha512-iH2AygTFILtato+XAgnoPYzLHM4R3DjATj7Ozbk7EHdB2XoLF2oyOUguM7Kc4UVHbQHHL/QPaw98r7PbWzG0gg==
+m3u8-parser@4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.7.0.tgz#e01e8ce136098ade1b14ee691ea20fc4dc60abf6"
+  integrity sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ==
   dependencies:
-    global "^4.3.2"
+    "@babel/runtime" "^7.12.5"
+    "@videojs/vhs-utils" "^3.0.0"
+    global "^4.4.0"
 
 magic-string@^0.25.0, magic-string@^0.25.7:
   version "0.25.7"
@@ -15108,15 +15104,15 @@ move-concurrently@^1.0.1:
     rimraf "^2.5.4"
     run-queue "^1.0.3"
 
-mpd-parser@0.10.0:
-  version "0.10.0"
-  resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.10.0.tgz#e48a39a4ecd3b5bbd0ed4ac5991b9cc36bcd9599"
-  integrity sha512-eIqkH/2osPr7tIIjhRmDWqm2wdJ7Q8oPfWvdjealzsLV2D2oNe0a0ae2gyYYs1sw5e5hdssDA2V6Sz8MW+Uvvw==
+mpd-parser@0.17.0:
+  version "0.17.0"
+  resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.17.0.tgz#d7f3002edcb706f98993ef75846a713d056d3332"
+  integrity sha512-oKS5G0jCcHHJ3sHYlcLeM9Xcbuixl08eAx7QW0Th7ChlZiI0YvLtGaHE/L0aKUBJFNvtkeksIr8XgJgSBBsS4g==
   dependencies:
-    "@babel/runtime" "^7.5.5"
-    "@videojs/vhs-utils" "^1.1.0"
-    global "^4.3.2"
-    xmldom "^0.1.27"
+    "@babel/runtime" "^7.12.5"
+    "@videojs/vhs-utils" "^3.0.2"
+    global "^4.4.0"
+    xmldom "^0.5.0"
 
 ms@2.0.0:
   version "2.0.0"
@@ -15242,10 +15238,12 @@ mute-stream@0.0.8:
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
-mux.js@5.5.1:
-  version "5.5.1"
-  resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-5.5.1.tgz#5bd5d7b2e5e5560da8126928e93af3c532e08372"
-  integrity sha512-5VmmjADBqS4++8pTI6poSRJ+chHdaoI4XErcQPM5w4QfwaDl+FQlSI0iOgWbYDn6CBCbDRKaSCcEiN2K5aHNGQ==
+mux.js@5.11.1:
+  version "5.11.1"
+  resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-5.11.1.tgz#531192c2c5ee5e9abb6243ba58e2c1ef916b35eb"
+  integrity sha512-U/JKEU4GZfk2BpEQpPfmH81nF79UKK2a1QOb6PF9viPspJpexGt11YzR/nTKNWdfjWG0DGjcSU+zb2F52Z/q8w==
+  dependencies:
+    "@babel/runtime" "^7.11.2"
 
 nan@^2.12.1:
   version "2.14.1"
@@ -16354,7 +16352,7 @@ pirates@^4.0.0, pirates@^4.0.1:
   dependencies:
     node-modules-regexp "^1.0.0"
 
-pkcs7@^1.0.2:
+pkcs7@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/pkcs7/-/pkcs7-1.0.4.tgz#6090b9e71160dabf69209d719cbafa538b00a1cb"
   integrity sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==
@@ -17228,11 +17226,6 @@ process@^0.11.10:
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
   integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
 
-process@~0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
-  integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=
-
 progress@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -20934,10 +20927,10 @@ url-parse@^1.4.3:
     querystringify "^2.1.1"
     requires-port "^1.0.0"
 
-url-toolkit@^2.1.3, url-toolkit@^2.1.6:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.2.0.tgz#9a57b89f315d4b7dc340e150bcfa548ddf5f5ce9"
-  integrity sha512-Rde0c9S4fJK3FaHim3DSgdQ8IFrSXcZCpAJo9T7/FA+BoQGhK0ow3mpwGQLJCPYsNn6TstpW7/7DzMpSpz9F9w==
+url-toolkit@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.2.2.tgz#51ef27b56d3187185f9ecf4a8ac7e8f55203c89d"
+  integrity sha512-l25w6Sy+Iy3/IbogunxhWwljPaDnqpiKvrQRoLBm6DfISco7NyRIS7Zf6+Oxhy1T8kHxWdwLND7ZZba6NjXMug==
 
 url@^0.11.0:
   version "0.11.0"
@@ -21126,29 +21119,34 @@ vfile@*, vfile@^4.0.0:
     unist-util-stringify-position "^2.0.0"
     vfile-message "^2.0.0"
 
-"video.js@^6.8.0 || ^7.0.0", video.js@^7.8.3:
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.8.4.tgz#645bf40400b413047d2b873c0c65fc8b42128f2a"
-  integrity sha512-XTWWrhfdrk7nTBkqhWKslfXMpUhsDRsZ2L3ISxjuAgadpiomxFV/OARchtwxO+FdnxTm4njJstPmXbVe3F765g==
+"video.js@^6 || ^7", video.js@^7.13.3:
+  version "7.13.3"
+  resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.13.3.tgz#5efab7bd56267406307f64d110662b2ccb3d7530"
+  integrity sha512-rIGPFRh3v0HqSdfj+/iByKRDBgLVqILK/4i/hW15DfjvgCGhwbw53bRBoJXy496hwh/XYeOAqckc87L2FN375g==
   dependencies:
-    "@babel/runtime" "^7.9.2"
-    "@videojs/http-streaming" "1.13.2"
+    "@babel/runtime" "^7.12.5"
+    "@videojs/http-streaming" "2.9.1"
+    "@videojs/vhs-utils" "^3.0.2"
     "@videojs/xhr" "2.5.1"
-    global "4.3.2"
+    aes-decrypter "3.1.2"
+    global "^4.4.0"
     keycode "^2.2.0"
+    m3u8-parser "4.7.0"
+    mpd-parser "0.17.0"
+    mux.js "5.11.1"
     safe-json-parse "4.0.0"
     videojs-font "3.2.0"
-    videojs-vtt.js "^0.15.2"
+    videojs-vtt.js "^0.15.3"
 
 videojs-font@3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-3.2.0.tgz#212c9d3f4e4ec3fa7345167d64316add35e92232"
   integrity sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==
 
-videojs-vtt.js@^0.15.2:
-  version "0.15.2"
-  resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.2.tgz#a828c4ea0aac6303fa471fd69bc7586a5ba1a273"
-  integrity sha512-kEo4hNMvu+6KhPvVYPKwESruwhHC3oFis133LwhXHO9U7nRnx0RiJYMiqbgwjgazDEXHR6t8oGJiHM6wq5XlAw==
+videojs-vtt.js@^0.15.3:
+  version "0.15.3"
+  resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.3.tgz#84260393b79487fcf195d9372f812d7fab83a993"
+  integrity sha512-5FvVsICuMRx6Hd7H/Y9s9GDeEtYcXQWzGMS+sl4UX3t/zoHp3y+isSfIPRochnTH7h+Bh1ILyC639xy9Z6kPag==
   dependencies:
     global "^4.3.1"
 
@@ -21876,10 +21874,10 @@ xmlchars@^2.2.0:
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
   integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
 
-xmldom@^0.1.27:
-  version "0.1.31"
-  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
-  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+xmldom@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.5.0.tgz#193cb96b84aa3486127ea6272c4596354cb4962e"
+  integrity sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==
 
 xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1:
   version "4.0.2"

Some files were not shown because too many files changed in this diff