Video Player
Video Player components
Video Player Modal
Modal component with React Player.
Icon
VideoPlayer
App.tsx
Content.tsx
createProvider.tsx
import { Flex } from '@chakra-ui/react'
import { Content } from './Content'
import { VideoPlayer, Provider } from './VideoPlayer'
export function App() {
return (
<Provider>
<Flex
flex={1}
h="full"
flexDirection="column"
bg="transparent"
alignItems="center"
justifyContent="center"
minH={{ base: 'auto', lg: '600px' }}
>
<Content src="https://vimeo.com/169599296" />
</Flex>
<VideoPlayer />
</Provider>
)
}
import { Flex, AspectRatio, IconButton, DarkMode } from '@chakra-ui/react'
import React from 'react'
import { Icon } from './Icon'
import { useVideoPlayerContext } from './VideoPlayer'
type Props = {
src: string
}
export function Content({ src }: Props) {
const { onOpen, setSrc } = useVideoPlayerContext()
const handleOpenVideoPlayer = () => {
if (!src) return
setSrc(src)
onOpen()
}
return (
<DarkMode>
<AspectRatio
ratio={16 / 9}
w={{ base: '100%', lg: '600px' }}
cursor="pointer"
onClick={handleOpenVideoPlayer}
>
<Flex
bg="gray.900"
w="full"
justifyContent="center"
alignItems="center"
>
{src && (
<IconButton
borderRadius="full"
aria-label="play button"
w={16}
h={16}
icon={<Icon icon="play" size="3xl" mr={-1} />}
/>
)}
</Flex>
</AspectRatio>
</DarkMode>
)
}
import { forwardRef } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React, { createContext, memo } from 'react'
/**
* Creates a provider component for a given context.
*
* @param {function} useValue - A function that takes props and returns the context value.
*
* @return An object containing the provider component, the context object, and a hook to consume the context.
*/
export function createProvider<
ContextProps extends object,
Props extends object,
>(useValue: (props: Props) => ContextProps) {
const Context = createContext<ContextProps>({} as ContextProps)
const useContext = () => {
const context = React.useContext(Context)
if (!Object.keys(context).length) {
throw new Error(
`【${
(useValue as any).__PROVIDER__
}】Context needs to be consumed in Provider`,
)
}
return context
}
const Provider: React.FC<PropsWithChildren<Props>> = memo<
PropsWithChildren<Props>
>(
forwardRef((props, ref) => (
<Component {...props} ref={ref} {...useValue(props)} />
)) as React.FC<PropsWithChildren<Props>>,
)
Provider.displayName = 'Provider'
const Component: React.FC<PropsWithChildren<Props> & ContextProps> = memo<
PropsWithChildren<Props> & ContextProps
>(
forwardRef(({ children, ...rest }, ref) => {
return (
<Context.Provider value={{ ...(rest as unknown as ContextProps), ref }}>
{children}
</Context.Provider>
)
}) as React.FC<PropsWithChildren<Props> & ContextProps>,
)
return {
Provider,
Context,
useContext,
}
}
import type { IconProps as ChakraIconProps } from '@chakra-ui/react'
import { Icon as ChakraIcon } from '@chakra-ui/react'
import { forwardRef } from '@chakra-ui/react'
import React from 'react'
import type { IconType } from './icons'
import { icons } from './icons'
type Props = ChakraIconProps & {
icon: IconType
size?: Sizes
ref?: React.ForwardedRef<any>
}
const sizes = {
'3xl': {
w: 10,
h: 10,
},
'2xl': {
w: 8,
h: 8,
},
xl: {
w: 6,
h: 6,
},
lg: {
w: '1.5em',
h: '1.5em',
},
md: {
w: '1.25em',
h: '1.25em',
},
sm: {
w: '1.15em',
h: '1.15em',
},
xs: {
w: '1em',
h: '1em',
},
} as const
type Sizes = keyof typeof sizes
export const Icon = forwardRef<Props, 'svg'>((props, ref) => {
const { size, icon, ...iconProps } = props
const iconComponent = icons[icon]
const sizeStyle = sizes[size ?? 'md']
return (
<ChakraIcon
ref={ref}
as={iconComponent}
color="whiteAlpha"
{...sizeStyle}
{...(iconProps as any)}
/>
)
})
Icon.id = 'Icon'
import { AiFillPlayCircle } from 'react-icons/ai'
import { BiPlay, BiPause, BiPlayCircle } from 'react-icons/bi'
export const icons = {
pause: BiPause,
play: BiPlay,
playCircle: AiFillPlayCircle,
playCircleOutline: BiPlayCircle,
} as const
export type IconType = keyof typeof icons
import type { ChakraProps } from '@chakra-ui/react'
import { chakra } from '@chakra-ui/react'
import styled from '@emotion/styled'
import React from 'react'
type Props = {
seconds: number
} & ChakraProps
export function Duration(props: Props) {
return (
<Time {...props} dateTime={`P${Math.round(props.seconds)}S`}>
{format(props.seconds)}
</Time>
)
}
const format = (seconds: number) => {
const date = new Date(seconds * 1000)
const hh = date.getUTCHours()
const mm = date.getUTCMinutes()
const ss = pad(date.getUTCSeconds())
if (hh) {
return `${hh}:${pad(mm)}:${ss}`
}
return `${mm}:${ss}`
}
const pad = (str: number) => ('0' + str).slice(-2)
const Time = chakra(
styled.time`
display: flex;
justify-content: center;
align-items: center;
`,
{
baseStyle: {
fontSize: 'xs',
},
},
)
import type { ChakraProps } from '@chakra-ui/react'
import { chakra, useColorMode } from '@chakra-ui/react'
import styled from '@emotion/styled'
import React, { useCallback, useMemo } from 'react'
import type { State } from './VideoPlayer'
type Props = {
played: number
setVideoState: React.Dispatch<React.SetStateAction<State>>
seekTo: (amount: number, type?: 'seconds' | 'fraction') => void
} & ChakraProps
const MIN = 0
const MAX = 0.999999
export function DurationBar(props: Props) {
const { setVideoState, seekTo, played } = props
const { colorMode } = useColorMode()
const handleSeekChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setVideoState((s) => ({ ...s, played: parseFloat(e.target.value) }))
},
[setVideoState],
)
const handleSeekMouseDown = useCallback(() => {
setVideoState((s) => ({ ...s, seeking: true }))
}, [setVideoState])
const handleSeekMouseUp = useCallback(
(e: React.MouseEvent<HTMLInputElement>) => {
setVideoState((s) => ({ ...s, seeking: false }))
seekTo(parseFloat((e.target as HTMLInputElement).value))
},
[seekTo, setVideoState],
)
const percent = useMemo(() => (played / MAX) * 100, [played])
return (
<InputRange
type="range"
min={MIN}
max={MAX}
step="any"
value={played}
onMouseDown={handleSeekMouseDown}
onMouseUp={handleSeekMouseUp}
onChange={handleSeekChange}
percent={percent}
mode={colorMode}
_dark={{
bg: 'gray.700',
}}
/>
)
}
type InputRangeProps = {
percent: number
mode: 'light' | 'dark'
}
const InputRange = chakra(styled.input<InputRangeProps>`
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
&:focus {
outline: none;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
cursor: pointer;
border-radius: 27px;
background-image: ${(props) =>
props.mode === 'dark'
? `linear-gradient(90deg, #edf2f7 ${props.percent}%, #1a202c ${props.percent}%)`
: `linear-gradient(90deg, #1a202c ${props.percent}%, #edf2f7 ${props.percent}%)`};
}
&::-webkit-slider-thumb {
box-shadow: 1px 1px 2px #a0aec0;
border: 4px solid #ffffff;
height: 14px;
width: 14px;
border-radius: 50px;
background: #1a202c;
cursor: pointer;
-webkit-appearance: none;
margin-top: -7px;
}
&::-moz-range-track {
width: 100%;
height: 2px;
cursor: pointer;
box-shadow: 0 0 0 #000000;
background: #edf2f7;
border-radius: 27px;
border: 0 solid #000000;
}
&::-moz-range-thumb {
box-shadow: 1px 1px 2px #a0aec0;
border: 4px solid #ffffff;
height: 14px;
width: 14px;
border-radius: 50px;
background: #1a202c;
cursor: pointer;
}
&::-ms-track {
width: 100%;
height: 2px;
cursor: pointer;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-ms-fill-lower {
background: #1a202c;
border-radius: 54px;
}
&::-ms-fill-upper {
background: #1a202c;
border-radius: 54px;
}
&::-ms-thumb {
margin-top: 1px;
box-shadow: 1px 1px 2px #a0aec0;
border: 4px solid #ffffff;
height: 14px;
width: 14px;
border-radius: 50px;
background: #1a202c;
cursor: pointer;
}
&:focus::-ms-fill-lower {
background: #edf2f7;
}
&:focus::-ms-fill-upper {
background: #edf2f7;
}
`)
import {
AspectRatio,
Box,
Flex,
IconButton,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalOverlay,
} from '@chakra-ui/react'
import React, { useCallback, useRef, useState } from 'react'
import ReactPlayer from 'react-player'
import { Icon } from '../Icon'
import { Duration } from './Duration'
import { DurationBar } from './DurationBar'
import { useVideoPlayerContext } from './Provider'
export type State = {
played: number
playing: boolean
duration: number
seeking: boolean
}
const initialState = (): State => ({
played: 0,
playing: true,
duration: 0,
seeking: false,
})
export function VideoPlayer() {
const { isOpen, src, onClose } = useVideoPlayerContext()
const [videoState, setVideoState] = useState<State>(initialState())
const ref = useRef<ReactPlayer>(null)
const handleClose = useCallback(() => {
setVideoState(initialState())
onClose()
}, [onClose])
const handlePlay = useCallback(() => {
setVideoState((s) => ({ ...s, playing: !videoState.playing }))
}, [videoState.playing])
const handleProgress = useCallback(
(state: {
played: number
playedSeconds: number
loaded: number
loadedSeconds: number
}) => {
if (videoState.seeking) return
setVideoState((s) => ({ ...s, played: state.played }))
},
[videoState.seeking],
)
const handleDuration = useCallback((duration: number) => {
setVideoState((s) => ({ ...s, duration }))
}, [])
const seekTo = useCallback(
(amount: number, type?: 'seconds' | 'fraction') => {
ref.current?.seekTo(amount, type)
},
[ref],
)
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
onEsc={handleClose}
onOverlayClick={handleClose}
size={{ base: 'full', md: 'xl', lg: '3xl' }}
>
<ModalOverlay />
<ModalContent>
<ModalBody p={0}>
<AspectRatio ratio={16 / 9}>
<Box w="full" borderTopRadius="md">
<ReactPlayer
ref={ref}
url={src}
width="100%"
height="100%"
playing={videoState.playing}
onProgress={handleProgress}
onDuration={handleDuration}
/>
</Box>
</AspectRatio>
</ModalBody>
<ModalFooter px={4} py={2} justifyContent="flex-start">
<Flex flex={1}>
<IconButton
borderRadius="full"
aria-label="play button"
icon={
<Icon
icon={videoState.playing ? 'pause' : 'play'}
mr={videoState.playing ? 0 : -1}
/>
}
mr={4}
onClick={handlePlay}
/>
<Duration
mr={3}
seconds={videoState.duration * videoState.played}
/>
<Flex flex={1} mr={3}>
<DurationBar
played={videoState.played}
seekTo={seekTo}
setVideoState={setVideoState}
/>
</Flex>
<Duration seconds={videoState.duration * (1 - videoState.played)} />
</Flex>
</ModalFooter>
</ModalContent>
</Modal>
)
}
import type { PropsWithChildren } from 'react'
import { VideoPlayerProvider } from './VideoPlayerProvider'
export function Provider({ children }: PropsWithChildren) {
return <VideoPlayerProvider>{children}</VideoPlayerProvider>
}
import { useCallback, useState } from 'react'
import { createProvider } from '../../createProvider'
type ContextProps = {
isOpen: boolean
src: string
setSrc: (src: string) => void
onClose: () => void
onOpen: () => void
}
const useValue = (): ContextProps => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [src, setSrc] = useState<string>('')
const onClose = useCallback(() => {
setIsOpen(false)
}, [])
const onOpen = useCallback(() => {
setIsOpen(true)
}, [])
return {
isOpen,
src,
setSrc,
onClose,
onOpen,
}
}
useValue.__PROVIDER__ = 'VideoPlayerProvider.tsx'
export const {
Provider: VideoPlayerProvider,
useContext: useVideoPlayerContext,
} = createProvider(useValue)