Ver Fonte

Save Interrupted Video State (#208)

* Save Interrupted Video State

* Code Review Fixes

* Code Review Fixes

* Code Review Fixes

* Code Review Fixes

* Code Review Fixes

* Fix Cover Video Bug
Francesco há 4 anos atrás
pai
commit
bbdf542785

+ 4 - 1
src/App.tsx

@@ -3,11 +3,14 @@ import { ApolloProvider } from '@apollo/client'
 
 import { client } from '@/api'
 import LayoutWithRouting from '@/views/LayoutWithRouting'
+import { PersonalDataProvider } from '@/hooks'
 
 export default function App() {
   return (
     <ApolloProvider client={client}>
-      <LayoutWithRouting />
+      <PersonalDataProvider>
+        <LayoutWithRouting />
+      </PersonalDataProvider>
     </ApolloProvider>
   )
 }

+ 1 - 0
src/hooks/usePersonalData/localStorageClient/types.ts

@@ -40,6 +40,7 @@ export interface PersonalDataClient {
     id: string,
     timestamp?: number
   ) => Promise<void>
+
   // ==== followed channels ====
 
   // get all the followed channels

+ 2 - 2
src/hooks/usePersonalData/usePersonalData.tsx

@@ -25,13 +25,13 @@ const asyncReducer = (state: PersonalData, action: Action) => {
     case 'UPDATE_WATCHED_VIDEOS': {
       return {
         ...state,
-        ...action.watchedVideos,
+        watchedVideos: action.watchedVideos,
       }
     }
     case 'UPDATE_FOLLOWED_CHANNELS': {
       return {
         ...state,
-        ...action.followedChannels,
+        followedChannels: action.followedChannels,
       }
     }
     default: {

+ 17 - 9
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useState, useCallback } from 'react'
 import { Container, PlayOverlay, StyledPlayIcon } from './VideoPlayer.style'
 import { useVideoJsPlayer, VideoJsConfig } from './videoJsPlayer'
 
@@ -9,13 +9,10 @@ type VideoPlayerProps = {
   playing?: boolean
 } & VideoJsConfig
 
-const VideoPlayer: React.FC<VideoPlayerProps> = ({
-  className,
-  autoplay,
-  isInBackground,
-  playing,
-  ...videoJsConfig
-}) => {
+const VideoPlayer: React.ForwardRefRenderFunction<HTMLVideoElement, VideoPlayerProps> = (
+  { className, autoplay, isInBackground, playing, ...videoJsConfig },
+  externalRef
+) => {
   const [player, playerRef] = useVideoJsPlayer(videoJsConfig)
   const [playOverlayVisible, setPlayOverlayVisible] = useState(true)
   const [initialized, setInitialized] = useState(false)
@@ -86,6 +83,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
     }
   })
 
+  useEffect(() => {
+    if (!externalRef) {
+      return
+    }
+    if (typeof externalRef === 'function') {
+      externalRef(playerRef.current)
+    } else {
+      externalRef.current = playerRef.current
+    }
+  })
+
   const handlePlayOverlayClick = () => {
     if (!player) {
       return
@@ -107,4 +115,4 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
   )
 }
 
-export default VideoPlayer
+export default React.forwardRef(VideoPlayer)

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

@@ -13,9 +13,12 @@ export type VideoJsConfig = {
   fill?: boolean
   muted?: boolean
   posterUrl?: string
+  startTime?: number
   onDataLoaded?: () => void
   onPlay?: () => void
   onPause?: () => void
+  onEnd?: () => void
+  onTimeUpdated?: (time: number) => void
 }
 
 const createJoystreamStorageUrl = (dataObjectId: string) => {
@@ -32,9 +35,12 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
   width,
   muted = false,
   posterUrl,
+  startTime = 0,
   onDataLoaded,
   onPlay,
   onPause,
+  onEnd,
+  onTimeUpdated,
 }) => {
   const playerRef = useRef<HTMLVideoElement>(null)
   const [player, setPlayer] = useState<VideoJsPlayer | null>(null)
@@ -128,6 +134,14 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
     }
   }, [player, onDataLoaded])
 
+  useEffect(() => {
+    if (!player || !startTime) {
+      return
+    }
+
+    player.currentTime(startTime)
+  }, [player, startTime])
+
   useEffect(() => {
     if (!player || !onPlay) {
       return
@@ -152,5 +166,24 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({
     }
   }, [player, onPause])
 
+  useEffect(() => {
+    if (!player || !onEnd) {
+      return
+    }
+    player.on('ended', onEnd)
+
+    return () => player.off('ended', onEnd)
+  }, [player, onEnd])
+
+  useEffect(() => {
+    if (!player || !onTimeUpdated) {
+      return
+    }
+    const handler = () => onTimeUpdated(player.currentTime())
+    player.on('timeupdate', handler)
+
+    return () => player.off('timeupdate', handler)
+  }, [onTimeUpdated, player])
+
   return [player, playerRef]
 }

+ 46 - 4
src/views/VideoView/VideoView.tsx

@@ -1,5 +1,6 @@
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'
 import { RouteComponentProps, useParams } from '@reach/router'
+import { debounce } from 'lodash'
 import {
   ChannelContainer,
   Container,
@@ -21,7 +22,9 @@ import { ADD_VIDEO_VIEW, GET_VIDEO } from '@/api/queries'
 import { GetVideo, GetVideoVariables } from '@/api/queries/__generated__/GetVideo'
 import { formatVideoViewsAndDate } from '@/utils/video'
 import { AddVideoView, AddVideoViewVariables } from '@/api/queries/__generated__/AddVideoView'
+
 import { ChannelLink, InfiniteVideoGrid } from '@/components'
+import { usePersonalData } from '@/hooks'
 
 const VideoView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
@@ -29,11 +32,21 @@ const VideoView: React.FC<RouteComponentProps> = () => {
     variables: { id },
   })
   const [addVideoView] = useMutation<AddVideoView, AddVideoViewVariables>(ADD_VIDEO_VIEW)
+  const { state, updateWatchedVideos } = usePersonalData()
+
+  const [startTimestamp, setStartTimestamp] = useState<number>()
+  useEffect(() => {
+    if (startTimestamp != null) {
+      return
+    }
+    const currentVideo = state.watchedVideos.find((v) => v.id === data?.video?.id)
+    setStartTimestamp(currentVideo?.__typename === 'INTERRUPTED' ? currentVideo.timestamp : 0)
+  }, [data?.video?.id, state.watchedVideos, startTimestamp])
 
-  const videoId = data?.video?.id
   const channelId = data?.video?.channel.id
+  const videoId = data?.video?.id
 
-  const [playing, setPlaying] = useState<boolean>(true)
+  const [playing, setPlaying] = useState(true)
   const handleUserKeyPress = useCallback((event: Event) => {
     const { keyCode } = event as KeyboardEvent
     if (keyCode === 32) {
@@ -72,6 +85,31 @@ const VideoView: React.FC<RouteComponentProps> = () => {
     })
   }, [addVideoView, videoId, channelId])
 
+  // Save the video timestamp
+  // disabling eslint for this line since debounce is an external fn and eslint can't figure out its args, so it will complain.
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const handleTimeUpdate = useCallback(
+    debounce((time) => {
+      if (data?.video?.id) {
+        updateWatchedVideos('INTERRUPTED', data.video.id, time)
+      }
+    }, 5000),
+    [data?.video?.id]
+  )
+
+  const handleVideoEnd = useCallback(() => {
+    if (data?.video?.id) {
+      updateWatchedVideos('COMPLETED', data?.video?.id)
+    }
+  }, [data?.video?.id, updateWatchedVideos])
+
+  const handlePlay = useCallback(() => {
+    setPlaying(true)
+  }, [])
+  const handlePause = useCallback(() => {
+    setPlaying(false)
+  }, [])
+
   if (error) {
     throw error
   }
@@ -88,9 +126,13 @@ const VideoView: React.FC<RouteComponentProps> = () => {
             <VideoPlayer
               playing={playing}
               src={data.video.media.location}
-              autoplay
               fill
               posterUrl={data.video.thumbnailUrl}
+              onEnd={handleVideoEnd}
+              onTimeUpdated={handleTimeUpdate}
+              onPlay={handlePlay}
+              onPause={handlePause}
+              startTime={startTimestamp}
             />
           ) : (
             <PlayerPlaceholder />