Files
web/packages/design-system/lib/components/VideoPlayer/index.tsx
Erik Tiekstra bf7a2ac2fe Fix/BOOK-240 video fixes
* fix(BOOK-240): Added support for multiple sources and fixed issue with play/pause on mobile

* fix(BOOK-240): Pausing hero video when scrolling out of view


Approved-by: Christel Westerberg
2025-12-16 09:09:17 +00:00

243 lines
6.3 KiB
TypeScript

'use client'
import { cx, VariantProps } from 'class-variance-authority'
import {
useCallback,
useEffect,
useRef,
useState,
VideoHTMLAttributes,
} from 'react'
import { Lang, languages } from '@scandic-hotels/common/constants/language'
import { FocalPoint } from '@scandic-hotels/common/utils/imageVault'
import { useIntl } from 'react-intl'
import { VideoPlayerButton } from './Button'
import { variants } from './variants'
import styles from './videoPlayer.module.css'
interface Caption {
src: string
srcLang: Lang
isDefault: boolean
}
export interface VideoPlayerProps extends VariantProps<typeof variants> {
sources: {
src: string
type: string
}[]
className?: string
captions?: Caption[]
focalPoint?: FocalPoint
autoPlay?: boolean
hasOverlay?: boolean
}
export function VideoPlayer({
sources,
captions,
focalPoint = { x: 50, y: 50 },
className,
variant = 'inline',
autoPlay,
hasOverlay,
}: VideoPlayerProps) {
const intl = useIntl()
const videoRef = useRef<HTMLVideoElement>(null)
const [isInteractedWith, setIsInteractedWith] = useState(false)
const [isPlaying, setIsPlaying] = useState(
(variant === 'hero' && (autoPlay ?? true)) || !!autoPlay
)
const [isMuted, setIsMuted] = useState(true)
const [userPaused, setUserPaused] = useState(false)
const defaultProps = getVideoPropsByVariant(
variant,
isInteractedWith,
autoPlay
)
const classNames = variants({
className,
variant,
})
const showPlayButton =
variant === 'hero' || (variant === 'inline' && !isInteractedWith)
const showMuteButton = variant === 'inline' && isInteractedWith
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.intersectionRatio >= 0.1 && !userPaused) {
videoRef.current?.play()
} else if (entry.intersectionRatio < 0.1) {
videoRef.current?.pause()
}
})
},
[userPaused]
)
function togglePlay() {
const videoElement = videoRef.current
if (videoElement) {
if (variant === 'hero') {
if (videoElement.paused) {
setUserPaused(false)
videoElement.play()
} else {
setUserPaused(true)
videoElement.pause()
}
} else {
setIsInteractedWith(true)
videoElement.play()
}
}
}
function handleMuteToggle() {
const videoElement = videoRef.current
if (videoElement) {
const currentlyMuted = videoElement.muted
videoElement.muted = !currentlyMuted
setIsMuted(!currentlyMuted)
}
}
function handleVolumeChangeEvent(event: React.UIEvent<HTMLVideoElement>) {
if (event.currentTarget.muted && !isMuted) {
setIsMuted(true)
} else if (!event.currentTarget.muted && isMuted) {
setIsMuted(false)
}
}
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement || variant !== 'hero') {
return
}
const observer = new IntersectionObserver(handleIntersection, {
// Play video when at least 10% of it is visible
threshold: [0, 0.1, 1],
})
observer.observe(videoElement)
return () => {
observer.disconnect()
}
}, [variant, handleIntersection])
if (!sources.length) {
return null
}
// Sort sources to prioritize WebM format for better compression
const sortedSources = [...sources].sort((a, b) => {
const aIsWebM = a.type.includes('webm')
const bIsWebM = b.type.includes('webm')
return aIsWebM === bIsWebM ? 0 : aIsWebM ? -1 : 1
})
return (
<div className={cx(classNames, { [styles.hasOverlay]: hasOverlay })}>
<video
ref={videoRef}
className={styles.video}
style={
focalPoint
? { objectPosition: `${focalPoint.x}% ${focalPoint.y}%` }
: undefined
}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onVolumeChange={handleVolumeChangeEvent}
{...defaultProps}
>
{sortedSources.map(({ src, type }) => (
<source key={src} src={src} type={type} />
))}
{captions?.length
? captions.map(({ src, srcLang, isDefault }) => (
<track
key={src}
src={src}
kind="captions"
srcLang={srcLang}
label={languages[srcLang] || srcLang}
default={isDefault}
/>
))
: null}
</video>
{showPlayButton ? (
<VideoPlayerButton
className={styles.playButton}
onPress={togglePlay}
iconName={isPlaying ? 'pause' : 'play_arrow'}
ariaLabel={
isPlaying
? intl.formatMessage({
id: 'videoPlayer.pause',
defaultMessage: 'Pause video',
})
: intl.formatMessage({
id: 'videoPlayer.play',
defaultMessage: 'Play video',
})
}
/>
) : null}
{showMuteButton ? (
<VideoPlayerButton
className={styles.muteButton}
onPress={handleMuteToggle}
iconName={isMuted ? 'volume_off' : 'volume_up'}
ariaLabel={
isMuted
? intl.formatMessage({
id: 'videoPlayer.mute',
defaultMessage: 'Mute video',
})
: intl.formatMessage({
id: 'videoPlayer.unmute',
defaultMessage: 'Unmute video',
})
}
/>
) : null}
</div>
)
}
function getVideoPropsByVariant(
variant: VideoPlayerProps['variant'],
isInteractedWith: boolean,
autoPlay?: boolean
): VideoHTMLAttributes<HTMLVideoElement> {
switch (variant) {
case 'hero':
return {
controls: false,
controlsList: 'nodownload nofullscreen noremoteplayback',
autoPlay: autoPlay ?? true,
muted: true,
loop: true,
playsInline: true,
}
case 'inline':
default:
return {
controls: isInteractedWith,
controlsList: 'nodownload noremoteplayback',
autoPlay: autoPlay ?? isInteractedWith,
muted: true,
loop: false,
playsInline: true,
}
}
}