Skip to content

Commit 1fbda4b

Browse files
authored
Merge pull request #72 from kaseyvee/dev
feat(video): create Video component
2 parents 21c54bc + e4c917d commit 1fbda4b

File tree

21 files changed

+999
-120
lines changed

21 files changed

+999
-120
lines changed

.github/workflows/validate-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ jobs:
2525
uses: cypress-io/github-action@v6
2626
with:
2727
component: true
28+
browser: chrome

docs/quickStart.stories.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Follow these steps to quickly integrate Rustic UI Components into your applicati
5050
StreamingText,
5151
Table,
5252
Text,
53+
Video,
5354
YoutubeVideo,
5455
} from '@rustic-ai/ui-components'
5556
@@ -73,7 +74,8 @@ Follow these steps to quickly integrate Rustic UI Components into your applicati
7374
table: Table,
7475
calendar: FCCalendar,
7576
codeSnippet: CodeSnippet,
76-
audio: Sound
77+
audio: Sound,
78+
video: Video
7779
}}
7880
// Include other components as needed
7981
/>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"scripts": {
104104
"build": "webpack --config webpack.prod.config.js",
105105
"setup-dev-env": "husky install",
106-
"test": "LANG='en-US' TZ='America/Vancouver' cypress run --component",
106+
"test": "LANG='en-US' TZ='America/Vancouver' cypress run --browser chrome --component",
107107
"test:interactive": "LANG='en-US' TZ='America/Vancouver' cypress open",
108108
"storybook": "storybook dev -p 6006",
109109
"build-storybook": "storybook build"
503 KB
Binary file not shown.
8.63 MB
Binary file not shown.

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import OpenLayersMap from './map/openLayersMap'
77
import MarkedMarkdown from './markdown/markedMarkdown'
88
import MarkedStreamingMarkdown from './markdown/markedStreamingMarkdown'
99
import Sound from './media/audio/sound'
10+
import Video from './media/video/video'
1011
import PopoverMenu from './menu/popoverMenu'
1112
import MessageCanvas from './messageCanvas/messageCanvas'
1213
import MessageSpace from './messageSpace/messageSpace'
@@ -39,6 +40,7 @@ export {
3940
Text,
4041
TextInput,
4142
Timestamp,
43+
Video,
4244
YoutubeVideo,
4345
}
4446

src/components/media/audio/sound.cy.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Sound from './sound'
66

77
describe('Sound', () => {
88
const audioElement = '[data-cy=audio-element]'
9-
const muteButton = '[data-cy=volumeUp-button]'
9+
const muteButton = '[data-cy=mute-button]'
1010
const playbackRateButton = '[data-cy=playback-rate-button]'
1111
const volumeSlider = '[data-cy=volume-slider]'
1212
const pauseButton = '[data-cy=pause-button]'
@@ -45,9 +45,9 @@ describe('Sound', () => {
4545
})
4646
it(`should go forwards and backwards 10 seconds when clicking the forward/back buttons on ${viewport} screen`, () => {
4747
cy.viewport(viewport)
48-
cy.get('[data-cy=forward-button]').click()
48+
cy.get('[data-cy=forward-ten-seconds-button]').click()
4949
cy.get(audioElement).its('0.currentTime').should('equal', 10)
50-
cy.get('[data-cy=replay-button]').click()
50+
cy.get('[data-cy=replay-ten-seconds-button]').click()
5151
cy.get(audioElement).its('0.currentTime').should('equal', 0)
5252
})
5353
it(`should increase the playback speed when clicking the playback rate button then go back to 1x after 2x on ${viewport} screen`, () => {
@@ -95,7 +95,7 @@ describe('Sound', () => {
9595
captions={captionsPath}
9696
/>
9797
)
98-
cy.get('[data-cy=captions-toggle]').click()
98+
cy.get('[data-cy=show-captions-button]').click()
9999
cy.get('track').should('exist').should('have.attr', 'src', captionsPath)
100100
})
101101
it(`should display an error message when no valid sources are found on ${viewport} screen`, () => {

src/components/media/audio/sound.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
import './sound.css'
22

3-
import ClosedCaptionDisabledRoundedIcon from '@mui/icons-material/ClosedCaptionDisabledRounded'
4-
import ClosedCaptionRoundedIcon from '@mui/icons-material/ClosedCaptionRounded'
53
import { useMediaQuery, useTheme } from '@mui/material'
64
import Alert from '@mui/material/Alert'
75
import CircularProgress from '@mui/material/CircularProgress'
8-
import IconButton from '@mui/material/IconButton'
6+
import Fade from '@mui/material/Fade'
97
import Typography from '@mui/material/Typography'
108
import { Box } from '@mui/system'
119
import React, { useEffect, useRef, useState } from 'react'
1210

1311
import type { AudioFormat } from '../../types'
1412
import {
1513
MoveTenSecondsButton,
16-
PausePlayToggle,
1714
PlaybackRateButton,
15+
PlayOrPauseButton,
1816
ProgressSlider,
19-
TranscriptToggle,
17+
ToggleTranscriptButton,
2018
VolumeSettings,
21-
} from '../controls/controls'
19+
} from '../controls/commonControls'
20+
import { MediaIconButton } from '../controls/mediaIconButton'
2221
import TimeIndicator from '../timeIndicator/timeIndicator'
2322
import Transcript from '../transcript/transcript'
2423

@@ -28,6 +27,7 @@ export default function Sound(props: AudioFormat) {
2827
const [isLoading, setIsLoading] = useState(true)
2928
const [elapsedTime, setElapsedTime] = useState(0)
3029
const [errorMessage, setErrorMessage] = useState('')
30+
const [controlErrorMessage, setControlErrorMessage] = useState('')
3131

3232
const theme = useTheme()
3333
const audioRef = useRef<HTMLVideoElement>(null)
@@ -75,18 +75,13 @@ export default function Sound(props: AudioFormat) {
7575

7676
function renderCaptionsToggle() {
7777
if (props.captions && props.captions.length > 0) {
78+
const action = areCaptionsShown ? 'captionsOff' : 'captionsOn'
79+
7880
return (
79-
<IconButton
81+
<MediaIconButton
82+
action={action}
8083
onClick={() => setAreCaptionsShown(!areCaptionsShown)}
81-
aria-label={areCaptionsShown ? 'Hide captions' : 'Show captions'}
82-
data-cy="captions-toggle"
83-
>
84-
{areCaptionsShown ? (
85-
<ClosedCaptionRoundedIcon color="primary" />
86-
) : (
87-
<ClosedCaptionDisabledRoundedIcon color="primary" />
88-
)}
89-
</IconButton>
84+
/>
9085
)
9186
}
9287
}
@@ -99,7 +94,10 @@ export default function Sound(props: AudioFormat) {
9994
mediaElement={audioRef.current}
10095
movement="replay"
10196
/>
102-
<PausePlayToggle mediaElement={audioRef.current} />
97+
<PlayOrPauseButton
98+
mediaElement={audioRef.current}
99+
onError={setControlErrorMessage}
100+
/>
103101
<MoveTenSecondsButton
104102
mediaElement={audioRef.current}
105103
movement="forward"
@@ -118,9 +116,11 @@ export default function Sound(props: AudioFormat) {
118116
function renderTranscriptToggle() {
119117
if (props.transcript) {
120118
return (
121-
<TranscriptToggle
122-
isTranscriptShown={isTranscriptShown}
123-
setIsTranscriptShown={() => setIsTranscriptShown(!isTranscriptShown)}
119+
<ToggleTranscriptButton
120+
isTranscriptVisible={isTranscriptShown}
121+
setIsTranscriptVisible={() =>
122+
setIsTranscriptShown(!isTranscriptShown)
123+
}
124124
/>
125125
)
126126
}
@@ -234,6 +234,11 @@ export default function Sound(props: AudioFormat) {
234234
<Box className="rustic-sound" data-cy="audio">
235235
{renderVideoElement()}
236236
{renderControls()}
237+
<Fade in={controlErrorMessage.length > 0}>
238+
<Alert severity="error" onClose={() => setControlErrorMessage('')}>
239+
{controlErrorMessage}
240+
</Alert>
241+
</Fade>
237242
</Box>
238243
)
239244
}

src/components/media/controls/controls.css renamed to src/components/media/controls/commonControls.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
gap: 24px;
44
align-items: center;
55
position: relative;
6+
width: 100%;
67
}
78

89
.rustic-progress-slider {

src/components/media/controls/controls.tsx renamed to src/components/media/controls/commonControls.tsx

Lines changed: 44 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,28 @@
1-
import './controls.css'
1+
import './commonControls.css'
22

33
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
44
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
55
import Box from '@mui/material/Box'
66
import Button from '@mui/material/Button'
7-
import Icon from '@mui/material/Icon'
8-
import IconButton from '@mui/material/IconButton'
97
import LinearProgress from '@mui/material/LinearProgress'
108
import Slider from '@mui/material/Slider'
119
import Typography from '@mui/material/Typography'
1210
import React, { useState } from 'react'
1311

1412
import { formatDurationTime } from '../../helper'
13+
import { MediaIconButton } from './mediaIconButton'
1514

16-
interface MediaIconButtonProps {
17-
onClick: () => void
18-
action: 'play' | 'pause' | 'forward' | 'replay' | 'volumeUp' | 'volumeOff'
19-
className?: string
15+
export interface MediaControls {
16+
mediaElement: HTMLMediaElement
2017
}
2118

22-
interface MediaControls {
23-
mediaElement: HTMLMediaElement
19+
interface ToggleTranscriptButtonProps {
20+
isTranscriptVisible: boolean
21+
setIsTranscriptVisible: () => void
2422
}
2523

26-
interface TranscriptToggleProps {
27-
isTranscriptShown: boolean
28-
setIsTranscriptShown: () => void
24+
interface PlayOrPauseButtonProps extends MediaControls {
25+
onError: (errorMessage: string) => void
2926
}
3027

3128
interface MoveTenSecondsButtonProps extends MediaControls {
@@ -34,31 +31,6 @@ interface MoveTenSecondsButtonProps extends MediaControls {
3431

3532
const percentMultiple = 100
3633

37-
export function MediaIconButton(props: MediaIconButtonProps) {
38-
const controls = {
39-
play: { symbol: 'play_circle', label: 'play' },
40-
pause: { symbol: 'pause_circle', label: 'pause' },
41-
forward: { symbol: 'forward_10', label: 'forward ten seconds' },
42-
replay: { symbol: 'replay_10', label: 'replay ten seconds' },
43-
volumeUp: { symbol: 'volume_up', label: 'mute' },
44-
volumeOff: { symbol: 'volume_off', label: 'unmute' },
45-
}
46-
return (
47-
<IconButton
48-
onClick={props.onClick}
49-
aria-label={`click to ${controls[props.action].label}`}
50-
className={props.className}
51-
data-cy={`${props.action}-button`}
52-
>
53-
<Icon color="primary">
54-
<span className="material-symbols-rounded">
55-
{controls[props.action].symbol}
56-
</span>
57-
</Icon>
58-
</IconButton>
59-
)
60-
}
61-
6234
export function ProgressSlider(props: MediaControls) {
6335
const formattedElapsedTime = formatDurationTime(
6436
props.mediaElement.currentTime
@@ -116,30 +88,23 @@ export function ProgressSlider(props: MediaControls) {
11688
}
11789

11890
export function VolumeSettings(props: MediaControls) {
119-
const [volumeFraction, setVolumeFraction] = useState(1)
91+
const [volumeFraction, setVolumeFraction] = useState(
92+
props.mediaElement.volume
93+
)
94+
const [previousVolume, setPreviousVolume] = useState(
95+
props.mediaElement.volume
96+
)
12097

121-
const isMuted = props.mediaElement.muted
122-
const action = isMuted ? 'volumeOff' : 'volumeUp'
98+
const action = props.mediaElement.muted ? 'volumeOff' : 'volumeUp'
99+
100+
function handleMuteToggle() {
101+
props.mediaElement.muted = !props.mediaElement.muted
123102

124-
props.mediaElement.onvolumechange = function () {
125103
if (props.mediaElement.muted) {
104+
setPreviousVolume(props.mediaElement.volume)
126105
setVolumeFraction(0)
127106
} else {
128-
setVolumeFraction(props.mediaElement.volume)
129-
}
130-
}
131-
132-
function handleMuteToggle() {
133-
if (isMuted && props.mediaElement.volume === 0) {
134-
// If audio was muted and volume was 0, unmute and restore to full volume
135-
props.mediaElement.muted = false
136-
props.mediaElement.volume = 1
137-
} else if (isMuted) {
138-
// If audio was muted, unmute, restoring previous volume
139-
props.mediaElement.muted = false
140-
} else {
141-
// If audio was unmuted, mute
142-
props.mediaElement.muted = true
107+
setVolumeFraction(previousVolume)
143108
}
144109
}
145110

@@ -151,6 +116,7 @@ export function VolumeSettings(props: MediaControls) {
151116

152117
props.mediaElement.muted = updatedVolume === 0
153118
props.mediaElement.volume = updatedVolume as number
119+
setVolumeFraction(props.mediaElement.volume)
154120
}
155121

156122
return (
@@ -175,55 +141,64 @@ export function VolumeSettings(props: MediaControls) {
175141
)
176142
}
177143

178-
export function TranscriptToggle(props: TranscriptToggleProps) {
179-
const Icon = props.isTranscriptShown
144+
export function ToggleTranscriptButton(props: ToggleTranscriptButtonProps) {
145+
const Icon = props.isTranscriptVisible
180146
? KeyboardArrowUpIcon
181147
: KeyboardArrowDownIcon
182148

183-
const buttonText = `${props.isTranscriptShown ? 'Hide' : 'Show'} Transcript`
149+
const buttonText = `${props.isTranscriptVisible ? 'Hide' : 'Show'} Transcript`
184150

185151
return (
186152
<Button
187153
className="rustic-transcript-toggle"
188154
data-cy="transcript-toggle"
189-
onClick={props.setIsTranscriptShown}
155+
onClick={props.setIsTranscriptVisible}
190156
endIcon={<Icon />}
191157
>
192158
<Typography variant="overline">{buttonText}</Typography>
193159
</Button>
194160
)
195161
}
196162

197-
export function PausePlayToggle(props: MediaControls) {
198-
const [isPlaying, setIsPlaying] = useState(false)
163+
export function PlayOrPauseButton(props: PlayOrPauseButtonProps) {
164+
const [isPlaying, setIsPlaying] = useState(!props.mediaElement.paused)
199165

200166
const action = isPlaying ? 'pause' : 'play'
201167

202-
function handlePausePlayToggle() {
168+
function handlePlayOrPauseToggle() {
203169
if (isPlaying) {
204170
props.mediaElement.pause()
205-
setIsPlaying(false)
206171
} else {
207-
props.mediaElement.play()
208-
setIsPlaying(true)
172+
props.mediaElement.play().catch((error: DOMException) => {
173+
props.onError(`Failed to play the media. Error: ${error.message}`)
174+
})
209175
}
210176
}
211177

178+
// State is updated by event listeners so that the icon is displayed correctly, even when play/pause is not initiated by the user or user initiates without direct use of this toggle (e.g. automatically pauses when picture-in-picture is exited, or pause and play can be toggled in the picture-in-picture window).
212179
props.mediaElement.onended = function () {
213180
setIsPlaying(false)
214181
}
182+
props.mediaElement.onpause = function () {
183+
setIsPlaying(false)
184+
}
185+
props.mediaElement.onplay = function () {
186+
setIsPlaying(true)
187+
}
215188

216189
return (
217190
<MediaIconButton
218-
onClick={handlePausePlayToggle}
191+
onClick={handlePlayOrPauseToggle}
219192
action={action}
220193
className="rustic-pause-play-icon"
221194
/>
222195
)
223196
}
224197

225198
export function PlaybackRateButton(props: MediaControls) {
226-
const [playbackRate, setPlaybackRate] = useState(1)
199+
const [playbackRate, setPlaybackRate] = useState(
200+
props.mediaElement.playbackRate
201+
)
227202

228203
function handlePlaybackRateChange() {
229204
let newPlaybackRate = 1
@@ -245,9 +220,7 @@ export function PlaybackRateButton(props: MediaControls) {
245220
aria-label={`Playback rate: ${playbackRate}x, click to change`}
246221
data-cy="playback-rate-button"
247222
>
248-
<Typography variant="body1" color="primary.main">
249-
{playbackRate}X
250-
</Typography>
223+
<Typography variant="body1">{playbackRate}X</Typography>
251224
</Button>
252225
)
253226
}

0 commit comments

Comments
 (0)