Browse Source

enable cover video playback

Klaudiusz Dembler 4 years ago
parent
commit
4febfd7f28

+ 2 - 0
package.json

@@ -62,6 +62,7 @@
     "@types/reach__router": "^1.3.5",
     "@types/react": "^16.9.0",
     "@types/react-dom": "^16.9.0",
+    "@types/react-transition-group": "^4.4.0",
     "@types/video.js": "^7.3.10",
     "@typescript-eslint/eslint-plugin": "^3.5.0",
     "@typescript-eslint/parser": "^3.2.0",
@@ -99,6 +100,7 @@
     "react-player": "^2.2.0",
     "react-scripts": "3.4.1",
     "react-spring": "^8.0.27",
+    "react-transition-group": "^4.4.1",
     "storybook-addon-jsx": "^7.1.15",
     "ts-loader": "^6.2.1",
     "typescript": "^3.8.3",

+ 42 - 12
src/components/FeaturedVideoHeader/FeaturedVideoHeader.style.ts

@@ -2,8 +2,9 @@ import styled from '@emotion/styled'
 import { fluidRange } from 'polished'
 
 import { Avatar, Button } from '@/shared/components'
-import { breakpoints, colors, spacing, typography } from '@/shared/theme'
+import { breakpoints, colors, sizes, spacing, typography } from '@/shared/theme'
 import { Link } from '@reach/router'
+import { css } from '@emotion/core'
 
 export const Container = styled.section`
   position: relative;
@@ -32,32 +33,45 @@ export const MediaWrapper = styled.div`
   width: calc(100% + calc(2 * var(--global-horizontal-padding)));
 `
 
-export const BackgroundImage = styled.div<{ src: string }>`
+export const Media = styled.div`
   width: 100%;
   height: 0;
   padding-top: 56.25%;
-  background-repeat: no-repeat;
-  background-position: center;
-  background-attachment: local;
-  background-size: cover;
+  position: relative;
+`
+
+const absoluteMediaCss = css`
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+`
+
+export const PlayerContainer = styled.div`
+  ${absoluteMediaCss};
+`
+
+export const GradientOverlay = styled.div`
+  ${absoluteMediaCss};
 
   // as the content overlaps the media more and more as the viewport width grows, we need to hide some part of the media with a gradient
   // this helps with keeping a consistent background behind a page content - we don't want the media to peek out in the content spacing
-  background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 20%), url(${({ src }) => src});
+  background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 20%);
   @media screen and (min-width: ${breakpoints.small}) {
-    background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 50%), url(${({ src }) => src});
+    background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 50%);
   }
   @media screen and (min-width: ${breakpoints.medium}) {
-    background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 70%), url(${({ src }) => src});
+    background-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) 70%);
   }
   @media screen and (min-width: ${breakpoints.large}) {
-    background-image: linear-gradient(0deg, black 0%, black 20%, rgba(0, 0, 0, 0) 90%), url(${({ src }) => src});
+    background-image: linear-gradient(0deg, black 0%, black 20%, rgba(0, 0, 0, 0) 90%);
   }
   @media screen and (min-width: ${breakpoints.xlarge}) {
-    background-image: linear-gradient(0deg, black 0%, black 25%, rgba(0, 0, 0, 0) 90%), url(${({ src }) => src});
+    background-image: linear-gradient(0deg, black 0%, black 25%, rgba(0, 0, 0, 0) 90%);
   }
   @media screen and (min-width: ${breakpoints.xxlarge}) {
-    background-image: linear-gradient(0deg, black 0%, black 30%, rgba(0, 0, 0, 0) 90%), url(${({ src }) => src});
+    background-image: linear-gradient(0deg, black 0%, black 30%, rgba(0, 0, 0, 0) 90%);
   }
 `
 
@@ -108,6 +122,9 @@ export const StyledAvatar = styled(Avatar)`
 `
 
 export const TitleContainer = styled.div`
+  a {
+    text-decoration: none;
+  }
   margin-bottom: ${spacing.xxl};
   @media screen and (min-width: ${breakpoints.medium}) {
     margin-bottom: ${spacing.xxxl};
@@ -131,6 +148,19 @@ export const TitleContainer = styled.div`
   }
 `
 
+export const ButtonsContainer = styled.div`
+  transition: opacity 200ms;
+  opacity: 0;
+
+  &.fade-enter-done {
+    opacity: 1;
+  }
+`
+
 export const PlayButton = styled(Button)`
   width: 116px;
 `
+
+export const SoundButton = styled(Button)`
+  margin-left: ${sizes.b4}px;
+`

+ 60 - 7
src/components/FeaturedVideoHeader/FeaturedVideoHeader.tsx

@@ -1,33 +1,86 @@
-import React from 'react'
+import React, { useState } from 'react'
 import {
-  BackgroundImage,
+  ButtonsContainer,
   ChannelLink,
   Container,
+  GradientOverlay,
   InfoContainer,
+  Media,
   MediaWrapper,
   PlayButton,
+  PlayerContainer,
+  SoundButton,
   StyledAvatar,
   TitleContainer,
 } from './FeaturedVideoHeader.style'
-import { mockCoverVideo, mockCoverVideoChannel } from '@/mocking/data/mockCoverVideo'
-import { navigate } from '@reach/router'
+import { CSSTransition } from 'react-transition-group'
+import { mockCoverVideo, mockCoverVideoChannel, mockCoverVideoMedia } from '@/mocking/data/mockCoverVideo'
 import routes from '@/config/routes'
+import { VideoPlayer } from '@/shared/components'
+import { Link } from '@reach/router'
 
 const FeaturedVideoHeader: React.FC = () => {
+  const [videoPlaying, setVideoPlaying] = useState(false)
+  const [initialLoadDone, setInitialLoadDone] = useState(false)
+  const [soundMuted, setSoundMuted] = useState(true)
+
+  const handlePlaybackDataLoaded = () => {
+    setInitialLoadDone(true)
+    setVideoPlaying(true)
+  }
+
+  const handlePlayPauseClick = () => {
+    setVideoPlaying(!videoPlaying)
+  }
+
+  const handleSoundToggleClick = () => {
+    setSoundMuted(!soundMuted)
+  }
+
   return (
     <Container>
       <MediaWrapper>
-        <BackgroundImage src={mockCoverVideo.thumbnailUrl} />
+        <Media>
+          <PlayerContainer>
+            <VideoPlayer
+              fluid
+              isInBackground
+              muted={soundMuted}
+              playing={videoPlaying}
+              posterUrl={mockCoverVideo.thumbnailUrl}
+              onDataLoaded={handlePlaybackDataLoaded}
+              src={mockCoverVideoMedia.location}
+            />
+          </PlayerContainer>
+          <GradientOverlay />
+        </Media>
       </MediaWrapper>
       <InfoContainer>
         <ChannelLink to={routes.channel(mockCoverVideoChannel.id)}>
           <StyledAvatar img={mockCoverVideoChannel.avatarPhotoUrl} name={mockCoverVideoChannel.handle} />
         </ChannelLink>
         <TitleContainer>
-          <h2>{mockCoverVideo.title}</h2>
+          <Link to={routes.video(mockCoverVideo.id)}>
+            <h2>{mockCoverVideo.title}</h2>
+          </Link>
           <span>{mockCoverVideo.description}</span>
         </TitleContainer>
-        <PlayButton onClick={() => navigate(routes.video(mockCoverVideo.id))}>Play</PlayButton>
+        <CSSTransition in={initialLoadDone} timeout={200} classNames="fade">
+          <ButtonsContainer>
+            <PlayButton
+              onClick={handlePlayPauseClick}
+              icon={videoPlaying ? 'pause' : 'play'}
+              disabled={!initialLoadDone}
+            >
+              {videoPlaying ? 'Pause' : 'Play'}
+            </PlayButton>
+            <SoundButton
+              onClick={handleSoundToggleClick}
+              icon={soundMuted ? 'sound-on' : 'sound-off'}
+              disabled={!initialLoadDone}
+            />
+          </ButtonsContainer>
+        </CSSTransition>
       </InfoContainer>
     </Container>
   )

+ 6 - 21
src/shared/components/Button/Button.style.ts

@@ -100,8 +100,6 @@ const disabled = ({ disabled }: ButtonStyleProps) =>
   disabled
     ? css`
         box-shadow: none;
-        fill: unset;
-        stroke: unset;
         color: ${colors.white};
         background-color: ${colors.gray[100]};
         border-color: ${colors.gray[100]};
@@ -120,8 +118,8 @@ const disabled = ({ disabled }: ButtonStyleProps) =>
 
 export const StyledIcon = styled(Icon)`
   flex-shrink: 0;
-  & > * {
-    stroke: currentColor;
+  & + * {
+    margin-left: 10px;
   }
 `
 export const StyledButton = styled.button<ButtonStyleProps>`
@@ -135,21 +133,8 @@ export const StyledButton = styled.button<ButtonStyleProps>`
   &:hover {
     cursor: ${(props) => (!props.disabled && props.clickable ? 'pointer' : '')};
   }
-  &::selected {
-    background: transparent;
-  }
-  ${colorsFromProps}
-  ${sizeFromProps}
-	${disabled}
-	& > ${StyledIcon} {
-    font-size: ${({ size }) =>
-      size === 'regular'
-        ? typography.sizes.icon.large
-        : size === 'small'
-        ? typography.sizes.icon.medium
-        : typography.sizes.icon.small};
-    & + * {
-      margin-left: 10px;
-    }
-  } ;
+
+  ${colorsFromProps};
+  ${sizeFromProps};
+  ${disabled};
 `

+ 1 - 8
src/shared/components/Icon/Icon.tsx

@@ -1,5 +1,4 @@
 import React from 'react'
-import { css } from '@emotion/core'
 import * as Icons from '../../icons'
 import { camelCase } from 'lodash'
 
@@ -11,12 +10,6 @@ type IconProps = {
 const capitalize = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
 const pascalCase = (s: string) => capitalize(camelCase(s))
 const iconsList = Object.keys(Icons)
-const iconStyles = css`
-  stroke: currentColor;
-  > * {
-    stroke: currentColor;
-  }
-`
 
 const Icon: React.FC<IconProps> = ({ name, ...svgProps }) => {
   const iconProp = pascalCase(name) as keyof typeof Icons
@@ -27,7 +20,7 @@ const Icon: React.FC<IconProps> = ({ name, ...svgProps }) => {
 
   const IconComponent = Icons[iconProp]
 
-  return <IconComponent css={iconStyles} {...svgProps} />
+  return <IconComponent {...svgProps} />
 }
 
 export default Icon

+ 23 - 2
src/shared/components/VideoPlayer/VideoPlayer.style.tsx

@@ -2,8 +2,27 @@ import React from 'react'
 import styled from '@emotion/styled'
 import { colors, spacing, typography } from '../../theme'
 import Icon from '../Icon'
+import { css } from '@emotion/core'
+
+type ContainerProps = {
+  isInBackground?: boolean
+}
+
+const backgroundContainerCss = css`
+  .vjs-waiting .vjs-loading-spinner {
+    display: none;
+  }
+
+  .vjs-control-bar {
+    display: none;
+  }
+`
+
+export const Container = styled.div<ContainerProps>`
+  video[poster] {
+    object-fit: fill;
+  }
 
-export const Container = styled.div`
   position: relative;
 
   *:focus {
@@ -116,6 +135,8 @@ export const Container = styled.div`
   .vjs-big-play-button {
     display: none !important;
   }
+
+  ${({ isInBackground }) => isInBackground && backgroundContainerCss};
 `
 
 export const PlayOverlay = styled.div`
@@ -140,4 +161,4 @@ const StyledIcon = styled(Icon)`
   width: 72px;
   color: ${colors.white};
 `
-export const StyledPlayIcon = ({ ...svgProps }) => <StyledIcon name="play" {...svgProps} />
+export const StyledPlayIcon = ({ ...svgProps }) => <StyledIcon name="play-outline" {...svgProps} />

+ 70 - 11
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -5,23 +5,32 @@ import { useVideoJsPlayer, VideoJsConfig } from './videoJsPlayer'
 type VideoPlayerProps = {
   className?: string
   autoplay?: boolean
+  isInBackground?: boolean
+  playing?: boolean
+  onDataLoaded?: () => void
 } & VideoJsConfig
 
-const VideoPlayer: React.FC<VideoPlayerProps> = ({ className, autoplay, ...videoJsConfig }) => {
+const VideoPlayer: React.FC<VideoPlayerProps> = ({
+  className,
+  autoplay,
+  isInBackground,
+  playing,
+  onDataLoaded,
+  ...videoJsConfig
+}) => {
   const [player, playerRef] = useVideoJsPlayer(videoJsConfig)
   const [playOverlayVisible, setPlayOverlayVisible] = useState(true)
+  const [initialized, setInitialized] = useState(false)
+
+  const displayPlayOverlay = playOverlayVisible && !isInBackground
 
   useEffect(() => {
-    if (!player || !autoplay) {
+    if (!player) {
       return
     }
 
-    const handler = async () => {
-      try {
-        await player.play()
-      } catch (e) {
-        console.warn('Autoplay failed:', e)
-      }
+    const handler = () => {
+      setInitialized(true)
     }
 
     player.on('loadstart', handler)
@@ -29,7 +38,57 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ className, autoplay, ...video
     return () => {
       player.off('loadstart', handler)
     }
-  }, [player, autoplay])
+  }, [player])
+
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+
+    const handler = () => {
+      if (onDataLoaded) {
+        onDataLoaded()
+      }
+    }
+
+    player.on('loadeddata', handler)
+
+    return () => {
+      player.off('loadeddata', handler)
+    }
+  }, [player, onDataLoaded])
+
+  useEffect(() => {
+    if (!player || !initialized || !autoplay) {
+      return
+    }
+
+    const playPromise = player.play()
+    if (playPromise) {
+      playPromise.catch((e) => {
+        console.warn('Autoplay failed:', e)
+      })
+    }
+  }, [player, initialized, autoplay])
+
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+
+    if (playing != null) {
+      if (playing) {
+        const playPromise = player.play()
+        if (playPromise) {
+          playPromise.catch((e) => {
+            console.error('Video play failed:', e)
+          })
+        }
+      } else {
+        player.pause()
+      }
+    }
+  })
 
   useEffect(() => {
     if (!player) {
@@ -56,8 +115,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ className, autoplay, ...video
   }
 
   return (
-    <Container className={className}>
-      {playOverlayVisible && (
+    <Container className={className} isInBackground={isInBackground}>
+      {displayPlayOverlay && (
         <PlayOverlay onClick={handlePlayOverlayClick}>
           <StyledPlayIcon />
         </PlayOverlay>

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

@@ -10,10 +10,12 @@ export type VideoJsConfig = {
   height?: number
   fluid?: boolean
   fill?: boolean
+  muted?: boolean
+  posterUrl?: string
 }
 
 type VideoJsPlayerHook = (config: VideoJsConfig) => [VideoJsPlayer | null, RefObject<HTMLVideoElement>]
-export const useVideoJsPlayer: VideoJsPlayerHook = ({ fill, fluid, height, src, width }) => {
+export const useVideoJsPlayer: VideoJsPlayerHook = ({ fill, fluid, height, src, width, muted = false, posterUrl }) => {
   const playerRef = useRef<HTMLVideoElement>(null)
   const [player, setPlayer] = useState<VideoJsPlayer | null>(null)
 
@@ -76,5 +78,21 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({ fill, fluid, height, src,
     player.fill(Boolean(fill))
   }, [player, fill])
 
+  useEffect(() => {
+    if (!player) {
+      return
+    }
+
+    player.muted(muted)
+  }, [player, muted])
+
+  useEffect(() => {
+    if (!player || !posterUrl) {
+      return
+    }
+
+    player.poster(posterUrl)
+  }, [player, posterUrl])
+
   return [player, playerRef]
 }

+ 1 - 1
src/shared/components/VideoPreview/VideoPreview.styles.tsx

@@ -55,7 +55,7 @@ export const CoverIcon = styled(Icon)`
   color: ${colors.white};
 `
 
-export const CoverPlayIcon = ({ ...props }) => <CoverIcon name="play" {...props} />
+export const CoverPlayIcon = ({ ...props }) => <CoverIcon name="play-outline" {...props} />
 
 export const ProgressOverlay = styled.div`
   position: absolute;

+ 8 - 0
src/shared/icons/index.ts

@@ -11,6 +11,10 @@ export { ReactComponent as ChevronLeft } from './chevron-left-big.svg'
 export { ReactComponent as Check } from './check.svg'
 export { ReactComponent as Dash } from './dash.svg'
 export { ReactComponent as Play } from './play.svg'
+export { ReactComponent as PlayOutline } from './play-outline.svg'
+export { ReactComponent as Pause } from './pause.svg'
+export { ReactComponent as SoundOn } from './sound-on.svg'
+export { ReactComponent as SoundOff } from './sound-off.svg'
 export { ReactComponent as Times } from './times.svg'
 
 const icons = [
@@ -27,6 +31,10 @@ const icons = [
   'check',
   'dash',
   'play',
+  'play-outline',
+  'pause',
+  'sound-on',
+  'sound-off',
   'times',
 ] as const
 

+ 1 - 0
src/shared/icons/pause.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1 17h-3v-10h3v10zm5-10h-3v10h3v-10z" fill="#fff"/></svg>

+ 1 - 0
src/shared/icons/play-outline.svg

@@ -0,0 +1 @@
+<svg fill="none" viewBox="5 5 54 54" xmlns="http://www.w3.org/2000/svg"><path d="M28.166 23l12 9-12 9V23z" stroke="#fff" stroke-width="3"/><circle cx="32.001" cy="32" r="25.167" stroke="#fff" stroke-width="3"/></svg>

+ 1 - 1
src/shared/icons/play.svg

@@ -1 +1 @@
-<svg fill="none" viewBox="5 5 54 54" xmlns="http://www.w3.org/2000/svg"><path d="M28.166 23l12 9-12 9V23z" stroke="#fff" stroke-width="3"/><circle cx="32.001" cy="32" r="25.167" stroke="#fff" stroke-width="3"/></svg>
+<svg width="24" height="24" fill="none" viewBox="2 2 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 16.5l6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#fff"/></svg>

+ 1 - 0
src/shared/icons/sound-off.svg

@@ -0,0 +1 @@
+<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0)" stroke="#fff" stroke-width="2" stroke-miterlimit="10"><path d="M15.544 12.91V22l-5.454-3.84" stroke-linecap="square"/><path d="M5.463 16.546H2.818A1.818 1.818 0 011 14.727v-5.09a1.818 1.818 0 011.818-1.819h4.727l8-5.818v4.485"/><path d="M20.09 2L1 21.09" stroke-linecap="square"/></g><defs><clipPath id="clip0"><path fill="#fff" d="M0 0h24v24H0z"/></clipPath></defs></svg>

+ 1 - 0
src/shared/icons/sound-on.svg

@@ -0,0 +1 @@
+<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 14c.317-.394.569-.862.74-1.376.172-.515.26-1.067.26-1.624 0-.557-.088-1.109-.26-1.624A4.428 4.428 0 0018 8M21 16a7.091 7.091 0 001.48-2.294 7.289 7.289 0 000-5.412A7.092 7.092 0 0021 6M15 21.25l-7.7-5.5H2.75c-.464 0-.91-.181-1.237-.503A1.703 1.703 0 011 14.03V9.22c0-.456.184-.893.513-1.216A1.766 1.766 0 012.75 7.5H7.3L15 2v19.25z" stroke="#fff" stroke-width="2" stroke-miterlimit="10" stroke-linecap="square"/></svg>

+ 8 - 1
yarn.lock

@@ -2936,6 +2936,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-transition-group@^4.4.0":
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d"
+  integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*", "@types/react@^16.9.0":
   version "16.9.49"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872"
@@ -14277,7 +14284,7 @@ react-textarea-autosize@^7.1.0:
     "@babel/runtime" "^7.1.2"
     prop-types "^15.6.0"
 
-react-transition-group@^4.3.0:
+react-transition-group@^4.3.0, react-transition-group@^4.4.1:
   version "4.4.1"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
   integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==