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:
@@ -22,6 +22,11 @@
|
||||
height: 100%;
|
||||
align-content: end;
|
||||
padding-bottom: var(--Space-x15);
|
||||
pointer-events: none;
|
||||
|
||||
& > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function CollectionPage() {
|
||||
<div className={styles.videoWrapper}>
|
||||
<HeroVideo
|
||||
className={styles.heroVideo}
|
||||
src={hero_video.src}
|
||||
sources={hero_video.sources}
|
||||
focalPoint={hero_video.focalPoint}
|
||||
captions={hero_video.captions}
|
||||
isFullWidth
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function ContentPage() {
|
||||
{hero_video ? (
|
||||
<HeroVideo
|
||||
className={styles.hero}
|
||||
src={hero_video.src}
|
||||
sources={hero_video.sources}
|
||||
focalPoint={hero_video.focalPoint}
|
||||
captions={hero_video.captions}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { VideoPlayer } from '.'
|
||||
import { config as videoPlayerConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof VideoPlayer> = {
|
||||
title: 'Core Components/🚧 Video 🚧/VideoPlayer',
|
||||
title: 'Core Components/Video/VideoPlayer',
|
||||
component: VideoPlayer,
|
||||
|
||||
parameters: {
|
||||
@@ -23,11 +23,12 @@ const meta: Meta<typeof VideoPlayer> = {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
src: {
|
||||
sources: {
|
||||
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: {
|
||||
table: {
|
||||
@@ -77,7 +78,16 @@ export default meta
|
||||
type Story = StoryObj<typeof VideoPlayer>
|
||||
|
||||
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: [
|
||||
{
|
||||
src: './video/captions_en.vtt',
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { VideoWithCard } from '.'
|
||||
import { config } from './variants'
|
||||
|
||||
const meta: Meta<typeof VideoWithCard> = {
|
||||
title: 'Core Components/🚧 Video 🚧/VideoWithCard',
|
||||
title: 'Core Components/Video/VideoWithCard',
|
||||
component: VideoWithCard,
|
||||
parameters: {
|
||||
docs: {
|
||||
@@ -80,7 +80,7 @@ const meta: Meta<typeof VideoWithCard> = {
|
||||
table: {
|
||||
type: {
|
||||
summary:
|
||||
'{ src: string; captions?: Caption[]; focalPoint?: FocalPoint}',
|
||||
'{ sources: { src: string; type: string }[]; captions?: Caption[]; focalPoint?: FocalPoint}',
|
||||
},
|
||||
},
|
||||
description:
|
||||
@@ -94,7 +94,16 @@ export default meta
|
||||
type Story = StoryObj<typeof VideoWithCard>
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -18,7 +18,7 @@ interface QuoteCardProps {
|
||||
|
||||
type VideoWithCardProps = VariantProps<typeof variants> &
|
||||
(TextCardProps | QuoteCardProps) & {
|
||||
video: Pick<VideoPlayerProps, 'src' | 'captions' | 'focalPoint'>
|
||||
video: Pick<VideoPlayerProps, 'sources' | 'captions' | 'focalPoint'>
|
||||
}
|
||||
|
||||
export function VideoWithCard(props: VideoWithCardProps) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
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 { FocalPoint } from '@scandic-hotels/common/utils/imageVault'
|
||||
@@ -17,7 +23,10 @@ interface Caption {
|
||||
}
|
||||
|
||||
export interface VideoPlayerProps extends VariantProps<typeof variants> {
|
||||
sources: {
|
||||
src: string
|
||||
type: string
|
||||
}[]
|
||||
className?: string
|
||||
captions?: Caption[]
|
||||
focalPoint?: FocalPoint
|
||||
@@ -26,7 +35,7 @@ export interface VideoPlayerProps extends VariantProps<typeof variants> {
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
sources,
|
||||
captions,
|
||||
focalPoint = { x: 50, y: 50 },
|
||||
className,
|
||||
@@ -36,29 +45,51 @@ export function VideoPlayer({
|
||||
}: VideoPlayerProps) {
|
||||
const intl = useIntl()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isActivated, setIsActivated] = useState(
|
||||
const [isInteractedWith, setIsInteractedWith] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(
|
||||
(variant === 'hero' && (autoPlay ?? true)) || !!autoPlay
|
||||
)
|
||||
const [isPlaying, setIsPlaying] = useState(autoPlay ?? false)
|
||||
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({
|
||||
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 {
|
||||
setIsActivated(true)
|
||||
setIsInteractedWith(true)
|
||||
videoElement.play()
|
||||
}
|
||||
}
|
||||
@@ -81,22 +112,41 @@ export function VideoPlayer({
|
||||
}
|
||||
}
|
||||
|
||||
const showPlayButton =
|
||||
variant === 'hero' || (variant === 'inline' && !isActivated)
|
||||
const showMuteButton = variant === 'inline' && isActivated
|
||||
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.isActivated]: isActivated },
|
||||
{ [styles.hasOverlay]: hasOverlay }
|
||||
)}
|
||||
>
|
||||
<div className={cx(classNames, { [styles.hasOverlay]: hasOverlay })}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={styles.video}
|
||||
src={src}
|
||||
style={
|
||||
focalPoint
|
||||
? { objectPosition: `${focalPoint.x}% ${focalPoint.y}%` }
|
||||
@@ -107,6 +157,9 @@ export function VideoPlayer({
|
||||
onVolumeChange={handleVolumeChangeEvent}
|
||||
{...defaultProps}
|
||||
>
|
||||
{sortedSources.map(({ src, type }) => (
|
||||
<source key={src} src={src} type={type} />
|
||||
))}
|
||||
{captions?.length
|
||||
? captions.map(({ src, srcLang, isDefault }) => (
|
||||
<track
|
||||
@@ -162,7 +215,7 @@ export function VideoPlayer({
|
||||
|
||||
function getVideoPropsByVariant(
|
||||
variant: VideoPlayerProps['variant'],
|
||||
isActive: boolean,
|
||||
isInteractedWith: boolean,
|
||||
autoPlay?: boolean
|
||||
): VideoHTMLAttributes<HTMLVideoElement> {
|
||||
switch (variant) {
|
||||
@@ -173,15 +226,17 @@ function getVideoPropsByVariant(
|
||||
autoPlay: autoPlay ?? true,
|
||||
muted: true,
|
||||
loop: true,
|
||||
playsInline: true,
|
||||
}
|
||||
case 'inline':
|
||||
default:
|
||||
return {
|
||||
controls: isActive,
|
||||
controls: isInteractedWith,
|
||||
controlsList: 'nodownload noremoteplayback',
|
||||
autoPlay: autoPlay ?? isActive,
|
||||
autoPlay: autoPlay ?? isInteractedWith,
|
||||
muted: true,
|
||||
loop: false,
|
||||
playsInline: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const Video = gql`
|
||||
edges {
|
||||
node {
|
||||
url
|
||||
content_type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const videoSchema = z.object({
|
||||
z.object({
|
||||
node: z.object({
|
||||
url: z.string().url(),
|
||||
content_type: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
@@ -36,9 +37,13 @@ export const videoSchema = z.object({
|
||||
export const transformedVideoSchema = videoSchema
|
||||
.nullish()
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -51,7 +56,7 @@ export const transformedVideoSchema = videoSchema
|
||||
}))
|
||||
|
||||
return {
|
||||
src,
|
||||
sources,
|
||||
focalPoint: video.focal_point
|
||||
? { x: video.focal_point.x, y: video.focal_point.y }
|
||||
: { x: 50, y: 50 },
|
||||
|
||||
Reference in New Issue
Block a user