diff --git a/src/config/urls.ts b/src/config/urls.ts index ddd8725e92..70a29cb178 100644 --- a/src/config/urls.ts +++ b/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' diff --git a/src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx b/src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx index 43dc7cdec1..96b06d66f2 100644 --- a/src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx +++ b/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)` + 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]}; ` diff --git a/src/shared/components/CircularProgressbar/CircularProgressbar.tsx b/src/shared/components/CircularProgressbar/CircularProgressbar.tsx index a9bbf50e3d..7918d2b88f 100644 --- a/src/shared/components/CircularProgressbar/CircularProgressbar.tsx +++ b/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 = ({ @@ -29,6 +31,8 @@ export const CircularProgressbar: React.FC = ({ maxValue = 100, minValue = 0, strokeWidth = 15, + variant = 'default', + noTrail, className, }) => { const getBackgroundPadding = () => (background ? backgroundPadding : 0) @@ -46,12 +50,15 @@ export const CircularProgressbar: React.FC = ({ <> {background ? : null} - + {noTrail && ( + + )} 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}; + } + } +` diff --git a/src/shared/components/VideoPlayer/ControlsIndicator.tsx b/src/shared/components/VideoPlayer/ControlsIndicator.tsx new file mode 100644 index 0000000000..02555dbc07 --- /dev/null +++ b/src/shared/components/VideoPlayer/ControlsIndicator.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react' +import { CSSTransition } from 'react-transition-group' +import { VideoJsPlayer } from 'video.js' + +import { + SvgPlayerBackwardFiveSec, + SvgPlayerBackwardTenSec, + SvgPlayerForwardFiveSec, + SvgPlayerForwardTenSec, + SvgPlayerPause, + SvgPlayerPlay, + SvgPlayerSoundHalf, + SvgPlayerSoundOff, + SvgPlayerSoundOn, +} from '@/shared/icons' + +import { + ControlsIndicatorIconWrapper, + ControlsIndicatorTooltip, + ControlsIndicatorTransitions, + ControlsIndicatorWrapper, +} from './ControlsIndicator.style' +import { CustomVideojsEvents } from './videoJsPlayer' + +import { Text } from '../Text' + +type VideoEvent = CustomVideojsEvents | null + +type EventState = { + type: VideoEvent + description: string | null + icon: React.ReactNode | null + isVisible: boolean +} + +type ControlsIndicatorProps = { + player: VideoJsPlayer | null +} + +export const ControlsIndicator: React.FC = ({ player }) => { + const [indicator, setIndicator] = useState(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 ( + + setIndicator((indicator) => (indicator ? { ...indicator, isVisible: false } : null))} + onExited={() => setIndicator(null)} + > + + {indicator?.icon} + + {indicator?.description} + + + + + ) +} + +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: , + description: 'Pause', + type, + } + case CustomVideojsEvents.PlayControl: + return { + icon: , + description: 'Play', + type, + } + case CustomVideojsEvents.BackwardFiveSec: + return { + icon: , + description: 'Backward 5s', + type, + } + case CustomVideojsEvents.ForwardFiveSec: + return { + icon: , + description: 'Forward 5s', + type, + } + case CustomVideojsEvents.BackwardTenSec: + return { + icon: , + description: 'Backward 10s', + type, + } + case CustomVideojsEvents.ForwardTenSec: + return { + icon: , + description: 'Forward 10s', + type, + } + case CustomVideojsEvents.Unmuted: + return { + icon: , + description: formattedVolume, + type, + } + case CustomVideojsEvents.Muted: + return { + icon: , + description: 'Mute', + type, + } + case CustomVideojsEvents.VolumeIncrease: + return { + icon: , + description: formattedVolume, + type, + } + case CustomVideojsEvents.VolumeDecrease: + return { + icon: isMuted ? : , + description: isMuted ? 'Mute' : formattedVolume, + type, + } + default: + return null + } +} diff --git a/src/shared/components/VideoPlayer/VideoOverlay.tsx b/src/shared/components/VideoPlayer/VideoOverlay.tsx new file mode 100644 index 0000000000..3f389ca0dc --- /dev/null +++ b/src/shared/components/VideoPlayer/VideoOverlay.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react' +import { CSSTransition, SwitchTransition } from 'react-transition-group' + +import { useVideos } from '@/api/hooks' +import { AssetAvailability, VideoFieldsFragment } from '@/api/queries' +import { transitions } from '@/shared/theme' +import { getRandomIntInclusive } from '@/utils/number' + +import { EndingOverlay, ErrorOverlay, LoadingOverlay } from './VideoOverlays' +import { PlayerState } from './VideoPlayer' + +type VideoOverlaProps = { + playerState: PlayerState + onPlay: () => void + channelId?: string + currentThumbnailUrl?: string | null + videoId?: string +} +export const VideoOverlay: React.FC = ({ + playerState, + onPlay, + channelId, + currentThumbnailUrl, + videoId, +}) => { + const [randomNextVideo, setRandomNextVideo] = useState(null) + const { videos } = useVideos({ + where: { + channelId_eq: channelId, + isPublic_eq: true, + mediaAvailability_eq: AssetAvailability.Accepted, + }, + }) + + useEffect(() => { + if (!videos?.length || videos.length <= 1) { + return + } + const filteredVideos = videos.filter((video) => video.id !== videoId) + const randomNumber = getRandomIntInclusive(0, filteredVideos.length - 1) + + setRandomNextVideo(filteredVideos[randomNumber]) + }, [videoId, videos]) + + return ( + + +
+ {playerState === 'loading' && } + {playerState === 'ended' && ( + + )} + {playerState === 'error' && } +
+ + + ) +} diff --git a/src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.style.ts b/src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.style.ts new file mode 100644 index 0000000000..bef94fed30 --- /dev/null +++ b/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` + 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` + 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)` + 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)}; + } +` diff --git a/src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.tsx b/src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.tsx new file mode 100644 index 0000000000..64e563f373 --- /dev/null +++ b/src/shared/components/VideoPlayer/VideoOverlays/EndingOverlay.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import { VideoFieldsFragment } from '@/api/queries' +import { absoluteRoutes } from '@/config/routes' +import { AssetType, useAsset } from '@/providers' +import { SvgGlyphRestart, SvgPlayerPause, SvgPlayerPlay } from '@/shared/icons' + +import { + CountDownButton, + CountDownWrapper, + Heading, + InnerContainer, + OverlayBackground, + RestartButton, + StyledChannelLink, + StyledCircularProgressBar, + SubHeading, + VideoInfo, +} from './EndingOverlay.style' + +type EndingOverlayProps = { + channelId?: string + currentThumbnailUrl?: string | null + isFullScreen?: boolean + onPlayAgain?: () => void + randomNextVideo?: VideoFieldsFragment | null + isEnded: boolean +} +// 10 seconds +const NEXT_VIDEO_TIMEOUT = 10000 + +export const EndingOverlay: React.FC = ({ + 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 ( + + {randomNextVideo ? ( + + + + Up next + + {randomNextVideo.title} + + + + + + {isCountDownStarted ? : } + + + + ) : ( + + + + You’ve finished watching a video from + + + }> + Play again + + + + )} + + ) +} diff --git a/src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.style.ts b/src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.style.ts new file mode 100644 index 0000000000..b1a5213bae --- /dev/null +++ b/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)}; +` diff --git a/src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.tsx b/src/shared/components/VideoPlayer/VideoOverlays/ErrorOverlay.tsx new file mode 100644 index 0000000000..9d866427a7 --- /dev/null +++ b/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 ( + + + + + + Aw, shucks! + + 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. + + + + Open Discord + + + + + + ) +} diff --git a/src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.style.ts b/src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.style.ts new file mode 100644 index 0000000000..a3e511bdcf --- /dev/null +++ b/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; +` diff --git a/src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.tsx b/src/shared/components/VideoPlayer/VideoOverlays/LoadingOverlay.tsx new file mode 100644 index 0000000000..1b7faf3594 --- /dev/null +++ b/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 = ({ onPlay }) => { + return ( + + + + ) +} diff --git a/src/shared/components/VideoPlayer/VideoOverlays/index.ts b/src/shared/components/VideoPlayer/VideoOverlays/index.ts new file mode 100644 index 0000000000..aa3df6c6d3 --- /dev/null +++ b/src/shared/components/VideoPlayer/VideoOverlays/index.ts @@ -0,0 +1,3 @@ +export * from './EndingOverlay' +export * from './ErrorOverlay' +export * from './LoadingOverlay' diff --git a/src/shared/components/VideoPlayer/VideoPlayer.style.tsx b/src/shared/components/VideoPlayer/VideoPlayer.style.tsx index 6cc93ede4b..3720671850 100644 --- a/src/shared/components/VideoPlayer/VideoPlayer.style.tsx +++ b/src/shared/components/VideoPlayer/VideoPlayer.style.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled' import { SvgPlayerSoundOff } from '@/shared/icons' -import { colors, media, sizes, transitions, zIndex } from '../../theme' +import { colors, sizes, transitions, zIndex } from '../../theme' import { Text } from '../Text' type ContainerProps = { @@ -12,6 +12,7 @@ type ContainerProps = { } type CustomControlsProps = { isFullScreen?: boolean + isEnded?: boolean } const focusStyles = css` @@ -46,23 +47,24 @@ export const ControlsOverlay = styled.div` export const CustomControls = styled.div` font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))}; position: absolute; - height: 2.5em; bottom: ${({ isFullScreen }) => (isFullScreen ? '2.5em' : '1em')}; - padding: 0 1em; + padding: 0.5em 1em 0; + border-top: ${({ isEnded }) => (isEnded ? `1px solid ${colors.transparentPrimary[18]}` : 'unset')}; left: 0; display: flex; - align-items: flex-end; + align-items: center; + z-index: ${zIndex.nearOverlay - 1}; width: 100%; - opacity: 0; transition: transform 200ms ${transitions.easing}, opacity 200ms ${transitions.easing}; ` export const ControlButton = styled.button` margin-right: 0.5em; + display: flex !important; cursor: pointer; border: none; + background: none; border-radius: 100%; - display: flex; align-items: center; justify-content: center; padding: 0.5em; @@ -167,93 +169,28 @@ export const StyledSvgPlayerSoundOff = styled(SvgPlayerSoundOff)` ` export const CurrentTimeWrapper = styled.div` display: flex; - height: 100%; - color: ${colors.white}; - margin-left: 1em; - text-shadow: 0 1px 2px ${colors.transparentBlack[32]}; align-items: center; - font-feature-settings: 'tnum' on, 'lnum' on; + height: 2.5em; + margin-left: 1em; ` export const CurrentTime = styled(Text)` /* 14px */ font-size: 0.875em; + color: ${colors.white}; + text-shadow: 0 1px 2px ${colors.transparentBlack[32]}; + font-feature-settings: 'tnum' on, 'lnum' on; ` export const ScreenControls = styled.div` margin-left: auto; + display: flex; ${ControlButton}:last-of-type { margin-right: 0; } ` -export const ControlsIndicatorWrapper = styled.div` - position: absolute; - top: calc(50% - ${sizes(16)}); - left: calc(50% - ${sizes(16)}); - display: flex; - flex-direction: column; -` - -export const ControlsIndicator = styled.div` - width: ${sizes(32)}; - height: ${sizes(32)}; - backdrop-filter: blur(${sizes(6)}); - background-color: ${colors.transparentBlack[54]}; - border-radius: 100%; - display: flex; - transform: scale(0.5); - justify-content: center; - align-items: center; - - > svg { - transform: scale(0.75); - width: ${sizes(18)}; - height: ${sizes(18)}; - } -` - -export const ControlsIndicatorTooltip = styled.div` - user-select: none; - display: none; - align-self: center; - background-color: ${colors.transparentBlack[54]}; - padding: ${sizes(2)}; - text-align: center; - margin-top: ${sizes(3)}; - backdrop-filter: blur(${sizes(8)}); - - ${media.small} { - display: block; - } -` - -const animationEasing = 'cubic-bezier(0, 0, 0.3, 1)' - -export const indicatorTransitions = css` - .indicator-exit { - opacity: 1; - } - - .indicator-exit-active { - ${ControlsIndicator} { - transform: scale(1); - opacity: 0; - transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing}; - - > svg { - transform: scale(1); - transition: transform 750ms ${animationEasing}; - } - } - ${ControlsIndicatorTooltip} { - opacity: 0; - transition: transform 750ms ${animationEasing}, opacity 600ms 150ms ${animationEasing}; - } - } -` - const backgroundContainerCss = css` .vjs-waiting .vjs-loading-spinner { display: none; @@ -263,6 +200,10 @@ const backgroundContainerCss = css` display: none; } + .vjs-error-display { + display: block; + } + .vjs-poster { display: block !important; opacity: 0; @@ -276,10 +217,9 @@ const backgroundContainerCss = css` ` export const Container = styled.div` - ${indicatorTransitions} - position: relative; height: 100%; + z-index: 0; [class^='vjs'] { font-size: ${({ isFullScreen }) => (isFullScreen ? sizes(8) : sizes(4))} !important; @@ -289,28 +229,35 @@ export const Container = styled.div` background-color: ${colors.gray[900]}; } - .vjs-playing:hover ${CustomControls} { - transform: translateY(-0.5em); - opacity: 1; - } - .vjs-paused ${CustomControls} { - transform: translateY(-0.5em); - opacity: 1; + .vjs-error-display { + display: none; } - .vjs-user-inactive.vjs-playing > ${CustomControls} { - transform: translateY(0.5em); - opacity: 0; + .vjs-playing:hover { + ${ControlsOverlay} { + opacity: 1; + ${CustomControls} { + transform: translateY(-0.5em); + } + } } - .vjs-playing:hover ${ControlsOverlay} { - opacity: 1; - } - .vjs-paused ${ControlsOverlay} { - opacity: 1; + .vjs-user-inactive.vjs-playing { + ${ControlsOverlay} { + opacity: 0; + ${CustomControls} { + transform: translateY(0.5em); + } + } } - .vjs-user-inactive.vjs-playing > ${ControlsOverlay} { - opacity: 0; + + .vjs-paused { + ${ControlsOverlay} { + opacity: 1; + ${CustomControls} { + transform: translateY(-0.5em); + } + } } .vjs-poster { @@ -322,11 +269,11 @@ export const Container = styled.div` background: none; align-items: flex-end; height: 2em; - z-index: ${zIndex.overlay}; transition: opacity 200ms ${transitions.easing} !important; + z-index: ${zIndex.nearOverlay}; :hover { - & ~ ${CustomControls} { + & ~ ${ControlsOverlay} ${CustomControls} { opacity: 0; transform: translateY(0.5em); } @@ -407,16 +354,32 @@ export const Container = styled.div` ${({ 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; + } ` diff --git a/src/shared/components/VideoPlayer/VideoPlayer.tsx b/src/shared/components/VideoPlayer/VideoPlayer.tsx index 53807caba1..709f2c934b 100644 --- a/src/shared/components/VideoPlayer/VideoPlayer.tsx +++ b/src/shared/components/VideoPlayer/VideoPlayer.tsx @@ -1,38 +1,33 @@ import { debounce } from 'lodash' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { CSSTransition } from 'react-transition-group' +import { VideoFieldsFragment } from '@/api/queries' import { usePersonalDataStore } from '@/providers' import { - SvgOutlineVideo, - SvgPlayerBackwardFiveSec, - SvgPlayerBackwardTenSec, - SvgPlayerForwardFiveSec, - SvgPlayerForwardTenSec, SvgPlayerFullScreen, SvgPlayerPause, SvgPlayerPip, SvgPlayerPipDisable, SvgPlayerPlay, + SvgPlayerRestart, SvgPlayerSmallScreen, SvgPlayerSoundHalf, - SvgPlayerSoundOff, SvgPlayerSoundOn, } from '@/shared/icons' import { Logger } from '@/utils/logger' import { formatDurationShort } from '@/utils/time' +import { ControlsIndicator } from './ControlsIndicator' +import { VideoOverlay } from './VideoOverlay' import { + BigPlayButton, + BigPlayButtonOverlay, Container, ControlButton, - ControlsIndicator, - ControlsIndicatorTooltip, - ControlsIndicatorWrapper, ControlsOverlay, CurrentTime, CurrentTimeWrapper, CustomControls, - PlayOverlay, ScreenControls, StyledSvgPlayerSoundOff, VolumeButton, @@ -42,13 +37,14 @@ import { } from './VideoPlayer.style' import { CustomVideojsEvents, VOLUME_STEP, VideoJsConfig, useVideoJsPlayer } from './videoJsPlayer' -import { Text } from '../Text' - export type VideoPlayerProps = { + nextVideo?: VideoFieldsFragment | null className?: string autoplay?: boolean isInBackground?: boolean playing?: boolean + channelId?: string + videoId?: string } & VideoJsConfig declare global { @@ -59,53 +55,39 @@ declare global { } const isPiPSupported = 'pictureInPictureEnabled' in document -type VideoEvent = CustomVideojsEvents | null -type EventState = { - type: VideoEvent - description: string | null - icon: React.ReactNode | null - isVisible: boolean -} +export type PlayerState = 'loading' | 'ended' | 'error' | 'playing' | null const VideoPlayerComponent: React.ForwardRefRenderFunction = ( - { className, autoplay, isInBackground, playing, ...videoJsConfig }, + { className, isInBackground, playing, nextVideo, channelId, videoId, autoplay, ...videoJsConfig }, externalRef ) => { const [player, playerRef] = useVideoJsPlayer(videoJsConfig) const cachedPlayerVolume = usePersonalDataStore((state) => state.cachedPlayerVolume) const updateCachedPlayerVolume = usePersonalDataStore((state) => state.actions.updateCachedPlayerVolume) - const [indicator, setIndicator] = useState(null) const [volume, setVolume] = useState(cachedPlayerVolume) const [isPlaying, setIsPlaying] = useState(false) const [videoTime, setVideoTime] = useState(0) const [isFullScreen, setIsFullScreen] = useState(false) const [isPiPEnabled, setIsPiPEnabled] = useState(false) - const [playOverlayVisible, setPlayOverlayVisible] = useState(true) - const [initialized, setInitialized] = useState(false) - const displayPlayOverlay = playOverlayVisible && !isInBackground + const [playerState, setPlayerState] = useState(null) + const [isLoaded, setIsLoaded] = useState(false) - // handle showing player indicators + // handle error useEffect(() => { - if (!player || isInBackground) { + if (!player) { return } - const indicatorEvents = Object.values(CustomVideojsEvents) - const handler = (e: Event) => { - const playerVolume = e.type === CustomVideojsEvents.Unmuted ? cachedPlayerVolume || VOLUME_STEP : player.volume() - const indicator = createIndicator(e.type as VideoEvent, playerVolume, player.muted()) - if (indicator) { - setIndicator({ ...indicator, isVisible: true }) - } + const handler = () => { + setPlayerState('error') } - player.on(indicatorEvents, handler) - + player.on('error', handler) return () => { - player.off(indicatorEvents, handler) + player.off('error', handler) } - }, [cachedPlayerVolume, isInBackground, player]) + }) const playVideo = useCallback(() => { if (!player) { @@ -124,13 +106,47 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { + if (!player) { + return + } + const handler = (event: Event) => { + if (event.type === 'waiting') { + setPlayerState('loading') + } + if (event.type === 'canplay') { + if (playerState !== null) { + setPlayerState('playing') + } + } + } + player.on(['waiting', 'canplay'], handler) + return () => { + player.off(['waiting', 'canplay'], handler) + } + }, [player, playerState]) + + useEffect(() => { + if (!player) { + return + } + const handler = () => { + setPlayerState('ended') + } + player.on('ended', handler) + return () => { + player.off('ended', handler) + } + }, [nextVideo, player]) + + // handle loadstart useEffect(() => { if (!player) { return } const handler = () => { - setInitialized(true) + setIsLoaded(true) } player.on('loadstart', handler) return () => { @@ -140,7 +156,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { - if (!player || !initialized || !autoplay) { + if (!player || !isLoaded || !autoplay) { return } const playPromise = player.play() @@ -149,7 +165,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { @@ -170,8 +186,10 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { if (event.type === 'play') { - setPlayOverlayVisible(false) - setIsPlaying(true) + if (playerState !== 'loading') { + setPlayerState('playing') + setIsPlaying(true) + } } if (event.type === 'pause') { setIsPlaying(false) @@ -181,7 +199,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { player.off(['play', 'pause'], handler) } - }, [player]) + }, [player, playerState]) useEffect(() => { if (!externalRef) { @@ -206,6 +224,22 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction { + if (!player) { + return + } + const handler = () => { + if (playerState === 'ended') { + player.play() + } + } + player.on('seeking', handler) + return () => { + player.off('seeking', handler) + } + }, [player, playerState]) + // handle fullscreen mode useEffect(() => { if (!player) { @@ -341,145 +375,78 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction - {displayPlayOverlay && ( - - - - )}
+ {showBigPlayButton && ( + + + + + + )}
+ {!isInBackground && } ) } export const VideoPlayer = React.forwardRef(VideoPlayerComponent) - -const createIndicator = (type: VideoEvent | null, playerVolume: number, playerMuted: boolean) => { - const formattedVolume = Math.floor(playerVolume * 100) + '%' - const isMuted = playerMuted || !Number(playerVolume.toFixed(2)) - - switch (type) { - case CustomVideojsEvents.PauseControl: - return { - icon: , - description: 'Pause', - type, - } - case CustomVideojsEvents.PlayControl: - return { - icon: , - description: 'Play', - type, - } - case CustomVideojsEvents.BackwardFiveSec: - return { - icon: , - description: 'Backward 5s', - type, - } - case CustomVideojsEvents.ForwardFiveSec: - return { - icon: , - description: 'Forward 5s', - type, - } - case CustomVideojsEvents.BackwardTenSec: - return { - icon: , - description: 'Backward 10s', - type, - } - case CustomVideojsEvents.ForwardTenSec: - return { - icon: , - description: 'Forward 10s', - type, - } - case CustomVideojsEvents.Unmuted: - return { - icon: , - description: formattedVolume, - type, - } - case CustomVideojsEvents.Muted: - return { - icon: , - description: 'Mute', - type, - } - case CustomVideojsEvents.VolumeIncrease: - return { - icon: , - description: formattedVolume, - type, - } - case CustomVideojsEvents.VolumeDecrease: - return { - icon: isMuted ? : , - description: isMuted ? 'Mute' : formattedVolume, - type, - } - default: - return null - } -} diff --git a/src/shared/components/VideoPlayer/videoJsPlayer.ts b/src/shared/components/VideoPlayer/videoJsPlayer.ts index afe5ec7f69..b47b022a04 100644 --- a/src/shared/components/VideoPlayer/videoJsPlayer.ts +++ b/src/shared/components/VideoPlayer/videoJsPlayer.ts @@ -130,6 +130,7 @@ export const useVideoJsPlayer: VideoJsPlayerHook = ({ controls: true, // @ts-ignore @types/video.js is outdated and doesn't provide types for some newer video.js features playsinline: true, + loadingSpinner: false, bigPlayButton: false, userActions: { hotkeys: (event) => hotkeysHandler(event, playerInstance), diff --git a/src/shared/icons/GlyphRestart.tsx b/src/shared/icons/GlyphRestart.tsx new file mode 100644 index 0000000000..1b290471d0 --- /dev/null +++ b/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) => ( + + + +) diff --git a/src/shared/icons/PlayerRestart.tsx b/src/shared/icons/PlayerRestart.tsx new file mode 100644 index 0000000000..1c6e438d8a --- /dev/null +++ b/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) => ( + + + + + + + + + + + + + + + + + +) diff --git a/src/shared/icons/index.tsx b/src/shared/icons/index.tsx index 2b5393addc..70d0b76e82 100644 --- a/src/shared/icons/index.tsx +++ b/src/shared/icons/index.tsx @@ -37,6 +37,7 @@ export * from './GlyphPan' export * from './GlyphPlay' export * from './GlyphPlus' export * from './GlyphResize' +export * from './GlyphRestart' export * from './GlyphRetry' export * from './GlyphShow' export * from './GlyphSoundOff' @@ -93,6 +94,7 @@ export * from './PlayerPlaylistAdd' export * from './PlayerPlaylist' export * from './PlayerPrevious' export * from './PlayerReplay' +export * from './PlayerRestart' export * from './PlayerShare' export * from './PlayerSmallScreen' export * from './PlayerSoundHalf' diff --git a/src/shared/icons/svgs/glyph-restart.svg b/src/shared/icons/svgs/glyph-restart.svg new file mode 100644 index 0000000000..0883fe354f --- /dev/null +++ b/src/shared/icons/svgs/glyph-restart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/icons/svgs/player-restart.svg b/src/shared/icons/svgs/player-restart.svg new file mode 100644 index 0000000000..479f6bbf3d --- /dev/null +++ b/src/shared/icons/svgs/player-restart.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/views/viewer/VideoView/VideoView.style.tsx b/src/views/viewer/VideoView/VideoView.style.tsx index 12c32beb89..4121e28b75 100644 --- a/src/views/viewer/VideoView/VideoView.style.tsx +++ b/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); } ` diff --git a/src/views/viewer/VideoView/VideoView.tsx b/src/views/viewer/VideoView/VideoView.tsx index 7d96ad150f..8ed7fc576f 100644 --- a/src/views/viewer/VideoView/VideoView.tsx +++ b/src/views/viewer/VideoView/VideoView.tsx @@ -132,6 +132,7 @@ export const VideoView: React.FC = () => { {video ? (