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%;
|
height: 100%;
|
||||||
align-content: end;
|
align-content: end;
|
||||||
padding-bottom: var(--Space-x15);
|
padding-bottom: var(--Space-x15);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
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 = {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
sources: {
|
||||||
src: string
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const Video = gql`
|
|||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
url
|
url
|
||||||
|
content_type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user