Skip to content

Commit

Permalink
VideoPlayer follow-up (#655)
Browse files Browse the repository at this point in the history
* document programatic VideoPlayer control

* add custom play icon

* update changeset

* fix missing function

* VideoPlayer dynamically adds a provider if one is not provided

* address PR feedback

* github-actions[bot] Regenerated snapshots

---------

Co-authored-by: joshfarrant <[email protected]>
  • Loading branch information
joshfarrant and joshfarrant authored Jul 19, 2024
1 parent 43716e7 commit e462ffd
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 54 deletions.
1 change: 1 addition & 0 deletions .changeset/kind-foxes-begin.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Refactored `VideoPlayer` component to make it more modular and customisable.
- Where you previously passed `showTitle={false}`, you should now pass `visuallyHiddenTitle={true}`.
- The `branding` prop has been renamed to `showBranding`.
- Individual video controls can be optionally hidden by setting any of the `showPlayPauseButton`, `showSeekControl`, `showCCButton`, `showMuteButton`, `showVolumeControl`, and `showFullScreenButton` props to `false`.
- A custom play icon can be provided using the `playIcon` prop.
116 changes: 107 additions & 9 deletions apps/docs/content/components/VideoPlayer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,108 @@ import {VideoPlayer} from '@primer/react-brand'
</VideoPlayer>
```

## Custom play icon

```jsx live
<VideoPlayer
title="GitHub media player"
playIcon={() => <PlayIcon size={96} />}
>
<VideoPlayer.Source
src="https://primer.github.io/brand/assets/example.mp4"
type="video/mp4"
/>
<VideoPlayer.Track src="https://primer.github.io/brand/assets/example.vtt" />
</VideoPlayer>
```

## Controlling programmatically

The `VideoPlayer` component exposes a `useVideo` hook that can be used to control the video programmatically. To use the hook, the `VideoPlayer` component must be wrapped in a `VideoPlayer.Provider` component.

Full documentation for the `useVideo` hook can be found [below](#usevideo-context).

```tsx live
() => {
const MyVideoPlayer = () => {
const {isPlaying, togglePlaying, seek} = useVideo()

return (
<Stack direction="vertical">
<VideoPlayer
title="GitHub media player"
showPlayPauseButton={false}
showSeekControl={false}
showCCButton={false}
showMuteButton={false}
showVolumeControl={false}
showFullScreenButton={false}
>
<VideoPlayer.Source
src="https://primer.github.io/brand/assets/example.mp4"
type="video/mp4"
/>
<VideoPlayer.Track src="https://primer.github.io/brand/assets/example.vtt" />
</VideoPlayer>
<Stack direction="horizontal">
<Button onClick={() => togglePlaying()}>
{isPlaying ? 'Pause' : 'Play'}
</Button>
<Button onClick={() => seek(0)}>Go to start</Button>
<Button onClick={() => seek((t) => t + 5)}>Skip 5 seconds</Button>
</Stack>
</Stack>
)
}

return (
<VideoPlayer.Provider>
<MyVideoPlayer />
</VideoPlayer.Provider>
)
}
```

## useVideo Context

The `useVideo` context provides a comprehensive API for managing video playback, volume, closed captioning, and fullscreen mode.

The context can be accessed by using the `useVideo` hook in any component that is a child of `VideoPlayer.Provider`.

Below is a detailed description of each property and method available in the `useVideo` context.

| Name | Type | Description |
| :----------------- | :------------------------------------------------- | :--------------------------------------------------------------------------------------------- |
| `ref` | `RefObject<HTMLVideoElement>` | A reference to the video element. |
| `isPlaying` | `boolean` | Indicates if the video is currently playing. |
| `volume` | `number` | The current volume of the video, ranging from 0 to 1. |
| `isMuted` | `boolean` | Indicates if the video is currently muted. |
| `volumeBeforeMute` | `number` | The volume of the video before it was muted, allowing for easy unmuting to the previous level. |
| `duration` | `number` | The total duration of the video in seconds. |
| `ccEnabled` | `boolean` | Indicates if closed captions are enabled. |
| `isFullScreen` | `boolean` | Indicates if the video is currently in fullscreen mode. |
| `play` | `() => void` | Plays the video. |
| `pause` | `() => void` | Pauses the video. |
| `togglePlaying` | `() => void` | Toggles between playing and pausing the video. |
| `setVolume` | `(volumeValOrFn: SetStateAction<number>) => void` | Sets the volume of the video. |
| `mute` | `() => void` | Mutes the video. |
| `unmute` | `() => void` | Unmutes the video. |
| `toggleMute` | `() => void` | Toggles between muting and unmuting the video. |
| `setDuration` | `(duration: number) => void` | Sets the duration of the video. |
| `seekToPercent` | `(percent: number) => void` | Seeks the video to a specific percentage of its duration. |
| `seek` | `(secondsValOrFn: SetStateAction<number>) => void` | Seeks the video to an absolute time, or to a relative time if passed a function. |
| `enableCC` | `() => void` | Enables closed captions. |
| `disableCC` | `() => void` | Disables closed captions. |
| `toggleCC` | `() => void` | Toggles closed captions on and off. |
| `enterFullScreen` | `() => void` | Enters fullscreen mode. |
| `exitFullScreen` | `() => void` | Exits fullscreen mode. |
| `toggleFullScreen` | `() => void` | Toggles between entering and exiting fullscreen mode. |

## Component props

<h3>
VideoPlayer <Label>Required</Label>
</h3>

### VideoPlayer <Label>Required</Label>


`VideoPlayer` provides a React alternative to the native HTML `<video />`.

Expand All @@ -128,15 +225,12 @@ import {VideoPlayer} from '@primer/react-brand'

The component API supports all standard HTML attribute props, while providing some additional behavior as described above.

<h3>
VideoPlayer.Source <Label>Required</Label>
</h3>
### VideoPlayer.Source <Label>Required</Label>

`VideoPlayer.Source` provides a React alternative to the native HTML `<source />`. The component API supports all standard HTML attribute props.

<h3>
VideoPlayer.Track <Label>Required</Label>
</h3>

### VideoPlayer.Track <Label>Required</Label>

`VideoPlayer.Track` provides a React alternative to the native HTML `<track />`.

Expand All @@ -145,3 +239,7 @@ The component API supports all standard HTML attribute props, while providing so
| `kind` | `'subtitles'`, `'captions'`, `'descriptions'`, `'chapters'`, `'metadata'` | `'captions'` | `false` | Sets how the text track is meant to be used |

The component API supports all standard HTML attribute props, while providing some additional behavior as described above.

### VideoPlayer.Provider

`VideoPlayer.Provider` can be used in conjunction with the `useVideo` hook to enable programmatic access to features such as video playback, volume, closed captioning, and fullscreen mode.
47 changes: 46 additions & 1 deletion packages/react/src/VideoPlayer/VideoPlayer.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import React from 'react'
import {Meta} from '@storybook/react'
import posterImage from '../fixtures/images/example-poster.png'
import {PlayIcon} from '@primer/octicons-react'

import posterImage from '../fixtures/images/example-poster.png'
import {VideoPlayer} from '.'
import {Stack} from '../Stack'
import {Button} from '../Button'
import {useVideo} from './hooks'

export default {
title: 'Components/VideoPlayer/Features',
Expand Down Expand Up @@ -58,3 +62,44 @@ export const Minimal = () => (
<VideoPlayer.Track src="https://primer.github.io/brand/assets/example.vtt" />
</VideoPlayer>
)

const MyVideoPlayer = () => {
const {isPlaying, togglePlaying, seek} = useVideo()

return (
<Stack direction="vertical">
<VideoPlayer
title="GitHub media player"
showPlayPauseButton={false}
showSeekControl={false}
showCCButton={false}
showMuteButton={false}
showVolumeControl={false}
showFullScreenButton={false}
>
<VideoPlayer.Source src="https://primer.github.io/brand/assets/example.mp4" type="video/mp4" />
<VideoPlayer.Track src="https://primer.github.io/brand/assets/example.vtt" />
</VideoPlayer>
<Stack direction="horizontal">
<Button onClick={() => togglePlaying()}>{isPlaying ? 'Pause' : 'Play'}</Button>
<Button onClick={() => seek(0)}>Go to start</Button>
<Button onClick={() => seek(t => t + 5)}>Skip 5 seconds</Button>
</Stack>
</Stack>
)
}

export const ControlledProgrammatically = () => {
return (
<VideoPlayer.Provider>
<MyVideoPlayer />
</VideoPlayer.Provider>
)
}

export const CustomPlayIcon = () => (
<VideoPlayer title="GitHub media player" playIcon={() => <PlayIcon size={96} />}>
<VideoPlayer.Source src="https://primer.github.io/brand/assets/example.mp4" type="video/mp4" />
<VideoPlayer.Track src="https://primer.github.io/brand/assets/example.vtt" />
</VideoPlayer>
)
1 change: 0 additions & 1 deletion packages/react/src/VideoPlayer/VideoPlayer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export default {
args: {
poster: posterImage,
title: 'GitHub media player',
branding: true,
visuallyHiddenTitle: false,
showBranding: true,
showControlsWhenPaused: true,
Expand Down
47 changes: 20 additions & 27 deletions packages/react/src/VideoPlayer/VideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import React, {useRef, forwardRef, type HTMLProps} from 'react'
import React, {useRef, forwardRef, useContext, type HTMLProps, type FunctionComponent} from 'react'
import clsx from 'clsx'
import {Text} from '../Text'
import {type AnimateProps} from '../animation'
import {
Captions,
CCButton,
Controls,
ControlsBar,
FullScreenButton,
IconControl,
MuteButton,
PauseIcon,
PlayIcon,
PlayIcon as DefaultPlayIcon,
PlayPauseButton,
Range,
SeekControl,
VolumeControl,
} from './components'
Expand All @@ -28,7 +24,7 @@ import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/compone
/** * Main Stylesheet (as a CSS Module) */
import styles from './VideoPlayer.module.css'
import {useVideoResizeObserver} from './hooks/'
import {useVideo, VideoProvider} from './hooks/useVideo'
import {useVideo, VideoContext, VideoProvider} from './hooks/useVideo'

type VideoPlayerProps = {
title: string
Expand All @@ -42,6 +38,7 @@ type VideoPlayerProps = {
showMuteButton?: boolean
showVolumeControl?: boolean
showFullScreenButton?: boolean
playIcon?: FunctionComponent
} & HTMLProps<HTMLVideoElement>

const Root = ({
Expand All @@ -57,12 +54,14 @@ const Root = ({
showMuteButton = true,
showVolumeControl = true,
showFullScreenButton = true,
playIcon: PlayIcon = () => <DefaultPlayIcon className={styles.VideoPlayer__playButtonOverlay} />,
...rest
}: VideoPlayerProps) => {
const videoWrapperRef = useRef<HTMLDivElement>(null)
const isSmall = useVideoResizeObserver({videoWrapperRef, className: styles['VideoPlayer__container--small']})

const useVideoContext = useVideo()
const {ccEnabled, isPlaying, ref, togglePlaying} = useVideoContext
const isSmall = useVideoResizeObserver({videoWrapperRef, className: styles['VideoPlayer__container--small']})

const hideControls = !isPlaying && !showControlsWhenPaused

Expand All @@ -85,7 +84,7 @@ const Root = ({
onClick={togglePlaying}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{!isPlaying && <VideoPlayer.PlayIcon className={styles.VideoPlayer__playButtonOverlay} />}
{!isPlaying && <PlayIcon />}
</button>
<div className={styles.VideoPlayer__controls}>
{ccEnabled && <Captions />}
Expand All @@ -110,26 +109,20 @@ const VideoPlayerTrack = ({kind = 'captions', ...rest}: React.HTMLProps<HTMLTrac
<track kind={kind} {...rest} />
)

const RootWithProvider = forwardRef<HTMLVideoElement, VideoPlayerProps>((props, ref) => (
<VideoProvider ref={ref}>
<Root {...props} />
</VideoProvider>
))
const RootWithProvider = forwardRef<HTMLVideoElement, VideoPlayerProps>((props, ref) => {
const context = useContext(VideoContext)

return context ? (
<Root {...props} ref={ref} />
) : (
<VideoProvider>
<Root {...props} ref={ref} />
</VideoProvider>
)
})

export const VideoPlayer = Object.assign(RootWithProvider, {
Source: VideoPlayerSource,
Track: VideoPlayerTrack,
Captions,
CCButton,
Controls,
ControlsBar,
FullScreenButton,
IconControl,
MuteButton,
PauseIcon,
PlayIcon,
PlayPauseButton,
Range,
SeekControl,
VolumeControl,
Provider: VideoProvider,
})
18 changes: 18 additions & 0 deletions packages/react/src/VideoPlayer/VideoPlayer.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ test.describe('Visual Comparison: VideoPlayer', () => {
expect(await page.screenshot()).toMatchSnapshot()
})

test('VideoPlayer / Controlled Programmatically', async ({page}) => {
await page.goto(
'http://localhost:6006/iframe.html?args=&id=components-videoplayer-features--controlled-programmatically&viewMode=story',
)

await page.waitForTimeout(500)
expect(await page.screenshot()).toMatchSnapshot()
})

test('VideoPlayer / Custom Play Icon', async ({page}) => {
await page.goto(
'http://localhost:6006/iframe.html?args=&id=components-videoplayer-features--custom-play-icon&viewMode=story',
)

await page.waitForTimeout(500)
expect(await page.screenshot()).toMatchSnapshot()
})

test('VideoPlayer / Playground', async ({page}) => {
await page.goto('http://localhost:6006/iframe.html?args=&id=components-videoplayer--playground&viewMode=story')

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 6 additions & 10 deletions packages/react/src/VideoPlayer/hooks/useVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ export type UseVideoContext = VideoState & {
unmute: () => void
toggleMute: () => void
setVolume: (volumeValOrFn: SetStateAction<number>) => void
seek: (time: number) => void
seekToPercent: (percent: number) => void
seekRelative: (secondsValOrFn: SetStateAction<number>) => void
seek: (secondsValOrFn: SetStateAction<number>) => void
enableCC: () => void
disableCC: () => void
toggleCC: () => void
Expand All @@ -52,7 +51,7 @@ type Action =
| {type: 'disableCC'}
| {type: 'setDuration'; payload: number}

const VideoContext = createContext<UseVideoContext | null>(null)
export const VideoContext = createContext<UseVideoContext | null>(null)

const videoReducer = (state: VideoState, action: Action): VideoState => {
const video = state.ref.current
Expand Down Expand Up @@ -103,7 +102,9 @@ export const useVideo = () => {
const context = useContext(VideoContext)

if (!context) {
throw new Error('useVideo must be used within a VideoContext')
throw new Error(
'useVideo must be used within a VideoProvider. Did you forget to wrap your component in a <VideoProvider>?',
)
}

return context
Expand Down Expand Up @@ -150,19 +151,14 @@ export const VideoProvider = forwardRef<HTMLVideoElement, VideoProviderProps>(({
exitFullScreen: () => setIsFullScreen(false),
toggleFullScreen: () => setIsFullScreen(prev => !prev),
setDuration: (duration: number) => dispatch({type: 'setDuration', payload: duration}),
seek: time => {
const videoRef = state.ref.current
if (!videoRef) return

videoRef.currentTime = time
},
seekToPercent: percent => {
const videoRef = state.ref.current
if (!videoRef) return

videoRef.currentTime = (percent / 100) * videoRef.duration
},
seekRelative: secondsValOrFn => {
seek: secondsValOrFn => {
const videoRef = state.ref.current
if (!videoRef) return

Expand Down
Loading

0 comments on commit e462ffd

Please sign in to comment.