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
This commit is contained in:
Erik Tiekstra
2025-12-16 09:09:17 +00:00
parent 713ca6562e
commit bf7a2ac2fe
10 changed files with 121 additions and 118 deletions

View File

@@ -22,6 +22,11 @@
height: 100%; height: 100%;
align-content: end; align-content: end;
padding-bottom: var(--Space-x15); padding-bottom: var(--Space-x15);
pointer-events: none;
& > * {
pointer-events: auto;
}
} }
} }

View File

@@ -50,7 +50,7 @@ export async function CollectionPage() {
<div className={styles.videoWrapper}> <div className={styles.videoWrapper}>
<HeroVideo <HeroVideo
className={styles.heroVideo} className={styles.heroVideo}
src={hero_video.src} sources={hero_video.sources}
focalPoint={hero_video.focalPoint} focalPoint={hero_video.focalPoint}
captions={hero_video.captions} captions={hero_video.captions}
isFullWidth isFullWidth

View File

@@ -84,7 +84,7 @@ export async function ContentPage() {
{hero_video ? ( {hero_video ? (
<HeroVideo <HeroVideo
className={styles.hero} className={styles.hero}
src={hero_video.src} sources={hero_video.sources}
focalPoint={hero_video.focalPoint} focalPoint={hero_video.focalPoint}
captions={hero_video.captions} captions={hero_video.captions}
/> />

View File

@@ -5,7 +5,7 @@ import { VideoPlayer } from '.'
import { config as videoPlayerConfig } from './variants' import { config as videoPlayerConfig } from './variants'
const meta: Meta<typeof VideoPlayer> = { const meta: Meta<typeof VideoPlayer> = {
title: 'Core Components/🚧 Video 🚧/VideoPlayer', title: 'Core Components/Video/VideoPlayer',
component: VideoPlayer, component: VideoPlayer,
parameters: { parameters: {
@@ -23,11 +23,12 @@ const meta: Meta<typeof VideoPlayer> = {
disable: true, disable: true,
}, },
}, },
src: { sources: {
table: { table: {
type: { summary: 'string' }, type: { summary: '{src: string; type: string}[]' },
}, },
description: 'The source URL of the video.', description:
'The different sources of the video, including their formats.',
}, },
captions: { captions: {
table: { table: {
@@ -77,7 +78,16 @@ export default meta
type Story = StoryObj<typeof VideoPlayer> type Story = StoryObj<typeof VideoPlayer>
const defaultArgs = { const defaultArgs = {
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltad0fe3c2ce340947/68eced6c14e5a8150ebba18c/Scandic_EB_Master.mp4', sources: [
{
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltc3aa53ac9bf6798c/693ad4b65b0889d6348893f3/Test_video.mp4',
type: 'video/mp4',
},
{
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/blt029be07ddd444eea/693c251c09e17b33c93c1dd6/hero-banner-1920-vp9.webm',
type: 'video/webm',
},
],
captions: [ captions: [
{ {
src: './video/captions_en.vtt', src: './video/captions_en.vtt',

View File

@@ -1,82 +0,0 @@
import { VariantProps } from 'class-variance-authority'
import { Typography } from '../../Typography'
import { variants } from './variants'
import { VideoPlayer, VideoPlayerProps } from '..'
import styles from './videoWithCard.module.css'
interface TextCardProps {
variant: 'text'
heading: string
text?: string
}
interface QuoteCardProps {
variant: 'quote'
quote: string
author: string
authorDescription?: string
}
type VideoWithCardProps = VariantProps<typeof variants> &
(TextCardProps | QuoteCardProps) & {
video: Pick<VideoPlayerProps, 'src' | 'captions' | 'focalPoint'>
}
export function VideoWithCard(props: VideoWithCardProps) {
const { variant, style, video } = props
const classNames = variants({
variant,
style,
})
return (
<div className={styles.videoWithCardWrapper}>
<div className={styles.videoWithCard}>
<VideoPlayer variant="inline" {...video} />
<article className={classNames}>
<CardContent {...props} />
</article>
</div>
</div>
)
}
function CardContent(props: VideoWithCardProps) {
if (props.variant === 'quote') {
const { quote, author, authorDescription } = props
return (
<>
<Typography variant="Title/smLowCase">
<blockquote className={styles.blockquote}>{quote}</blockquote>
</Typography>
<cite className={styles.cite}>
<Typography variant="Body/Paragraph/mdBold">
<span>{author}</span>
</Typography>
{authorDescription ? (
<Typography variant="Body/Paragraph/mdRegular">
<span>{authorDescription}</span>
</Typography>
) : null}
</cite>
</>
)
}
const { heading, text } = props
return (
<>
<Typography variant="Title/smLowCase">
<h3 className={styles.heading}>{heading}</h3>
</Typography>
{text ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{text}</p>
</Typography>
) : null}
</>
)
}

View File

@@ -4,7 +4,7 @@ import { VideoWithCard } from '.'
import { config } from './variants' import { config } from './variants'
const meta: Meta<typeof VideoWithCard> = { const meta: Meta<typeof VideoWithCard> = {
title: 'Core Components/🚧 Video 🚧/VideoWithCard', title: 'Core Components/Video/VideoWithCard',
component: VideoWithCard, component: VideoWithCard,
parameters: { parameters: {
docs: { docs: {
@@ -80,7 +80,7 @@ const meta: Meta<typeof VideoWithCard> = {
table: { table: {
type: { type: {
summary: summary:
'{ src: string; captions?: Caption[]; focalPoint?: FocalPoint}', '{ sources: { src: string; type: string }[]; captions?: Caption[]; focalPoint?: FocalPoint}',
}, },
}, },
description: description:
@@ -94,7 +94,16 @@ export default meta
type Story = StoryObj<typeof VideoWithCard> type Story = StoryObj<typeof VideoWithCard>
const videoProps = { const videoProps = {
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltad0fe3c2ce340947/68eced6c14e5a8150ebba18c/Scandic_EB_Master.mp4', sources: [
{
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltc3aa53ac9bf6798c/693ad4b65b0889d6348893f3/Test_video.mp4',
type: 'video/mp4',
},
{
src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/blt029be07ddd444eea/693c251c09e17b33c93c1dd6/hero-banner-1920-vp9.webm',
type: 'video/webm',
},
],
} }
const quoteCardProps = { const quoteCardProps = {

View File

@@ -18,7 +18,7 @@ interface QuoteCardProps {
type VideoWithCardProps = VariantProps<typeof variants> & type VideoWithCardProps = VariantProps<typeof variants> &
(TextCardProps | QuoteCardProps) & { (TextCardProps | QuoteCardProps) & {
video: Pick<VideoPlayerProps, 'src' | 'captions' | 'focalPoint'> video: Pick<VideoPlayerProps, 'sources' | 'captions' | 'focalPoint'>
} }
export function VideoWithCard(props: VideoWithCardProps) { export function VideoWithCard(props: VideoWithCardProps) {

View File

@@ -1,7 +1,13 @@
'use client' 'use client'
import { cx, VariantProps } from 'class-variance-authority' import { cx, VariantProps } from 'class-variance-authority'
import { useRef, useState, VideoHTMLAttributes } from 'react' import {
useCallback,
useEffect,
useRef,
useState,
VideoHTMLAttributes,
} from 'react'
import { Lang, languages } from '@scandic-hotels/common/constants/language' import { Lang, languages } from '@scandic-hotels/common/constants/language'
import { FocalPoint } from '@scandic-hotels/common/utils/imageVault' import { FocalPoint } from '@scandic-hotels/common/utils/imageVault'
@@ -17,7 +23,10 @@ interface Caption {
} }
export interface VideoPlayerProps extends VariantProps<typeof variants> { export interface VideoPlayerProps extends VariantProps<typeof variants> {
src: string sources: {
src: string
type: string
}[]
className?: string className?: string
captions?: Caption[] captions?: Caption[]
focalPoint?: FocalPoint focalPoint?: FocalPoint
@@ -26,7 +35,7 @@ export interface VideoPlayerProps extends VariantProps<typeof variants> {
} }
export function VideoPlayer({ export function VideoPlayer({
src, sources,
captions, captions,
focalPoint = { x: 50, y: 50 }, focalPoint = { x: 50, y: 50 },
className, className,
@@ -36,29 +45,51 @@ export function VideoPlayer({
}: VideoPlayerProps) { }: VideoPlayerProps) {
const intl = useIntl() const intl = useIntl()
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [isActivated, setIsActivated] = useState( const [isInteractedWith, setIsInteractedWith] = useState(false)
const [isPlaying, setIsPlaying] = useState(
(variant === 'hero' && (autoPlay ?? true)) || !!autoPlay (variant === 'hero' && (autoPlay ?? true)) || !!autoPlay
) )
const [isPlaying, setIsPlaying] = useState(autoPlay ?? false)
const [isMuted, setIsMuted] = useState(true) const [isMuted, setIsMuted] = useState(true)
const defaultProps = getVideoPropsByVariant(variant, isActivated, autoPlay) const [userPaused, setUserPaused] = useState(false)
const defaultProps = getVideoPropsByVariant(
variant,
isInteractedWith,
autoPlay
)
const classNames = variants({ const classNames = variants({
className, className,
variant, 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() { function togglePlay() {
const videoElement = videoRef.current const videoElement = videoRef.current
if (videoElement) { if (videoElement) {
if (variant === 'hero') { if (variant === 'hero') {
if (videoElement.paused) { if (videoElement.paused) {
setUserPaused(false)
videoElement.play() videoElement.play()
} else { } else {
setUserPaused(true)
videoElement.pause() videoElement.pause()
} }
} else { } else {
setIsActivated(true) setIsInteractedWith(true)
videoElement.play() videoElement.play()
} }
} }
@@ -81,22 +112,41 @@ export function VideoPlayer({
} }
} }
const showPlayButton = useEffect(() => {
variant === 'hero' || (variant === 'inline' && !isActivated) const videoElement = videoRef.current
const showMuteButton = variant === 'inline' && isActivated
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 ( return (
<div <div className={cx(classNames, { [styles.hasOverlay]: hasOverlay })}>
className={cx(
classNames,
{ [styles.isActivated]: isActivated },
{ [styles.hasOverlay]: hasOverlay }
)}
>
<video <video
ref={videoRef} ref={videoRef}
className={styles.video} className={styles.video}
src={src}
style={ style={
focalPoint focalPoint
? { objectPosition: `${focalPoint.x}% ${focalPoint.y}%` } ? { objectPosition: `${focalPoint.x}% ${focalPoint.y}%` }
@@ -107,6 +157,9 @@ export function VideoPlayer({
onVolumeChange={handleVolumeChangeEvent} onVolumeChange={handleVolumeChangeEvent}
{...defaultProps} {...defaultProps}
> >
{sortedSources.map(({ src, type }) => (
<source key={src} src={src} type={type} />
))}
{captions?.length {captions?.length
? captions.map(({ src, srcLang, isDefault }) => ( ? captions.map(({ src, srcLang, isDefault }) => (
<track <track
@@ -162,7 +215,7 @@ export function VideoPlayer({
function getVideoPropsByVariant( function getVideoPropsByVariant(
variant: VideoPlayerProps['variant'], variant: VideoPlayerProps['variant'],
isActive: boolean, isInteractedWith: boolean,
autoPlay?: boolean autoPlay?: boolean
): VideoHTMLAttributes<HTMLVideoElement> { ): VideoHTMLAttributes<HTMLVideoElement> {
switch (variant) { switch (variant) {
@@ -173,15 +226,17 @@ function getVideoPropsByVariant(
autoPlay: autoPlay ?? true, autoPlay: autoPlay ?? true,
muted: true, muted: true,
loop: true, loop: true,
playsInline: true,
} }
case 'inline': case 'inline':
default: default:
return { return {
controls: isActive, controls: isInteractedWith,
controlsList: 'nodownload noremoteplayback', controlsList: 'nodownload noremoteplayback',
autoPlay: autoPlay ?? isActive, autoPlay: autoPlay ?? isInteractedWith,
muted: true, muted: true,
loop: false, loop: false,
playsInline: true,
} }
} }
} }

View File

@@ -8,6 +8,7 @@ export const Video = gql`
edges { edges {
node { node {
url url
content_type
} }
} }
} }

View File

@@ -11,6 +11,7 @@ export const videoSchema = z.object({
z.object({ z.object({
node: z.object({ node: z.object({
url: z.string().url(), url: z.string().url(),
content_type: z.string(),
}), }),
}) })
), ),
@@ -36,9 +37,13 @@ export const videoSchema = z.object({
export const transformedVideoSchema = videoSchema export const transformedVideoSchema = videoSchema
.nullish() .nullish()
.transform((video) => { .transform((video) => {
const src = video?.sourceConnection.edges[0]?.node.url || "" const sources =
video?.sourceConnection.edges.map((edge) => ({
src: edge.node.url,
type: edge.node.content_type,
})) || []
if (!video || !src) { if (!video || !sources.length) {
return null return null
} }
@@ -51,7 +56,7 @@ export const transformedVideoSchema = videoSchema
})) }))
return { return {
src, sources,
focalPoint: video.focal_point focalPoint: video.focal_point
? { x: video.focal_point.x, y: video.focal_point.y } ? { x: video.focal_point.x, y: video.focal_point.y }
: { x: 50, y: 50 }, : { x: 50, y: 50 },