From c21aa2dc73e831d0d0eba5645fef04b1174ece1a Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Fri, 19 Dec 2025 12:41:00 +0000 Subject: [PATCH] Merged in fix/BOOK-257-video-player (pull request #3373) Fix/BOOK-257 video player * fix(BOOK-257): Fixes to VideoPlayerButton and added stories * fix(BOOK-257): Hiding mute button when the user has interacted with it * fix(BOOK-257): Added support for poster image * fix(BOOK-257): add crossOrigin attr to videoplayer * fix(BOOK-257): comment Approved-by: Anton Gunnarsson --- .../Button/VideoPlayerButton.stories.tsx | 95 ++++++++++++ .../components/VideoPlayer/Button/index.tsx | 60 ++++---- .../components/VideoPlayer/Button/types.ts | 22 +++ .../components/VideoPlayer/Button/variants.ts | 18 +++ ...odule.css => videoPlayerButton.module.css} | 42 +++++- .../VideoPlayer/VideoPlayer.stories.tsx | 43 ++++-- .../VideoWithCard/VideoWithCard.stories.tsx | 19 ++- .../VideoPlayer/VideoWithCard/index.tsx | 3 +- .../lib/components/VideoPlayer/index.tsx | 141 +++++++++--------- .../lib/components/VideoPlayer/types.ts | 27 ++++ .../VideoPlayer/useVideoDimensions.ts | 39 +++++ .../lib/components/VideoPlayer/utils.ts | 30 ++++ .../VideoPlayer/videoPlayer.module.css | 5 + .../lib/graphql/Fragments/Video.graphql.ts | 1 + .../lib/routers/contentstack/schemas/video.ts | 8 + 15 files changed, 436 insertions(+), 117 deletions(-) create mode 100644 packages/design-system/lib/components/VideoPlayer/Button/VideoPlayerButton.stories.tsx create mode 100644 packages/design-system/lib/components/VideoPlayer/Button/types.ts create mode 100644 packages/design-system/lib/components/VideoPlayer/Button/variants.ts rename packages/design-system/lib/components/VideoPlayer/Button/{button.module.css => videoPlayerButton.module.css} (68%) create mode 100644 packages/design-system/lib/components/VideoPlayer/types.ts create mode 100644 packages/design-system/lib/components/VideoPlayer/useVideoDimensions.ts create mode 100644 packages/design-system/lib/components/VideoPlayer/utils.ts diff --git a/packages/design-system/lib/components/VideoPlayer/Button/VideoPlayerButton.stories.tsx b/packages/design-system/lib/components/VideoPlayer/Button/VideoPlayerButton.stories.tsx new file mode 100644 index 000000000..9414dc5b0 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/Button/VideoPlayerButton.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import { fn } from 'storybook/test' +import { VideoPlayerButton } from '.' +import { videoPlayerButtonIconNames } from './types' +import { config } from './variants' + +const meta: Meta = { + title: 'Core Components/Video/VideoPlayerButton', + component: VideoPlayerButton, + parameters: { + docs: { + description: { + component: + 'A component to display a VideoPlayer and content inside a card connected to the video. The size and gaps are determined by the parent container.', + }, + }, + }, + argTypes: { + onPress: { + table: { + type: { summary: 'function' }, + }, + defaultValue: { summary: 'undefined' }, + }, + size: { + control: 'select', + options: Object.keys(config.variants.size), + table: { + defaultValue: { + summary: config.defaultVariants.size, + }, + type: { + summary: Object.keys(config.variants.size).join(' | '), + }, + }, + description: 'The size of the button.', + }, + iconName: { + control: 'select', + options: videoPlayerButtonIconNames, + table: { + defaultValue: { + summary: 'undefined', + }, + type: { + summary: videoPlayerButtonIconNames.join(' | '), + }, + }, + description: + 'This decides the background color and text color of the card.', + }, + }, +} + +export default meta + +function renderAllIcons(args: Story['args']) { + return ( +
+ {videoPlayerButtonIconNames.map((iconName) => ( + + ))} +
+ ) +} + +type Story = StoryObj + +export const Default: Story = { + args: { iconName: 'play_arrow', onPress: fn() }, +} + +export const Small: Story = { + args: { ...Default.args, size: 'sm' }, + render: (args) => renderAllIcons(args), +} + +export const Medium: Story = { + args: { ...Default.args, size: 'md' }, + render: (args) => renderAllIcons(args), +} + +export const Large: Story = { + args: { ...Default.args, size: 'lg' }, + render: (args) => renderAllIcons(args), +} diff --git a/packages/design-system/lib/components/VideoPlayer/Button/index.tsx b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx index 24d759ff4..a8eac9d71 100644 --- a/packages/design-system/lib/components/VideoPlayer/Button/index.tsx +++ b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx @@ -2,38 +2,44 @@ import { Button as ButtonRAC } from 'react-aria-components' import { MaterialIcon } from '../../Icons/MaterialIcon' -import styles from './button.module.css' - -interface VideoPlayerButtonProps { - onPress: () => void - iconName: 'play_arrow' | 'pause' | 'volume_up' | 'volume_off' - ariaLabel: string - className?: string -} +import { VideoPlayerButtonProps } from './types' +import { variants } from './variants' +import styles from './videoPlayerButton.module.css' export function VideoPlayerButton({ - onPress, - ariaLabel, iconName, + size, className, + ...props }: VideoPlayerButtonProps) { + const classNames = variants({ + size, + className, + }) + return ( -
- - - - - - -
+ + + + + + ) } + +function getIconSize(size: VideoPlayerButtonProps['size']) { + switch (size) { + case 'sm': + return 28 + case 'lg': + return 40 + case 'md': + default: + return 32 + } +} diff --git a/packages/design-system/lib/components/VideoPlayer/Button/types.ts b/packages/design-system/lib/components/VideoPlayer/Button/types.ts new file mode 100644 index 000000000..c843559c8 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/Button/types.ts @@ -0,0 +1,22 @@ +import type { VariantProps } from 'class-variance-authority' +import { Button as ButtonRAC } from 'react-aria-components' + +import { ComponentProps } from 'react' +import type { SymbolCodepoints } from '../../Icons/MaterialIcon/MaterialSymbol/types' +import type { variants } from './variants' + +export const videoPlayerButtonIconNames = [ + 'play_arrow', + 'pause', + 'volume_up', + 'volume_off', +] satisfies SymbolCodepoints[] + +type VideoPlayerButtonIconName = (typeof videoPlayerButtonIconNames)[number] + +export interface VideoPlayerButtonProps + extends + Omit, 'children'>, + VariantProps { + iconName: VideoPlayerButtonIconName +} diff --git a/packages/design-system/lib/components/VideoPlayer/Button/variants.ts b/packages/design-system/lib/components/VideoPlayer/Button/variants.ts new file mode 100644 index 000000000..132ec73dc --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/Button/variants.ts @@ -0,0 +1,18 @@ +import { cva } from 'class-variance-authority' + +import styles from './videoPlayerButton.module.css' + +export const config = { + variants: { + size: { + sm: styles['size-sm'], + md: styles['size-md'], + lg: styles['size-lg'], + }, + }, + defaultVariants: { + size: 'md', + }, +} as const + +export const variants = cva(styles.videoPlayerButton, config) diff --git a/packages/design-system/lib/components/VideoPlayer/Button/button.module.css b/packages/design-system/lib/components/VideoPlayer/Button/videoPlayerButton.module.css similarity index 68% rename from packages/design-system/lib/components/VideoPlayer/Button/button.module.css rename to packages/design-system/lib/components/VideoPlayer/Button/videoPlayerButton.module.css index 6c81df387..c25ed7db2 100644 --- a/packages/design-system/lib/components/VideoPlayer/Button/button.module.css +++ b/packages/design-system/lib/components/VideoPlayer/Button/videoPlayerButton.module.css @@ -7,7 +7,6 @@ align-items: center; justify-content: center; position: relative; - overflow: hidden; cursor: pointer; z-index: 0; border-width: 0; @@ -29,11 +28,45 @@ } &:focus-visible { - border-width: 2px; outline: 2px solid var(--Border-Inverted); + outline-offset: 2px; - .transparentBackground { - background-color: var(--Base-Border-Subtle); + &::before { + content: ''; + position: absolute; + inset: -2px; + border: 2px solid var(--Border-Interactive-Focus); + border-radius: inherit; + pointer-events: none; + } + } + + &.size-sm { + height: 52px; + width: 52px; + + .iconWrapper { + width: 40px; + height: 40px; + } + } + + &.size-md { + height: 56px; + width: 56px; + + .iconWrapper { + width: 43px; + height: 43px; + } + } + &.size-lg { + height: 72px; + width: 72px; + + .iconWrapper { + width: 56px; + height: 56px; } } } @@ -54,5 +87,4 @@ display: flex; align-items: center; justify-content: center; - padding: var(--Space-x05); } diff --git a/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx index 34c39247e..76f098950 100644 --- a/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx +++ b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx @@ -30,6 +30,16 @@ const meta: Meta = { description: 'The different sources of the video, including their formats.', }, + poster: { + table: { + type: { + summary: + '{src: string, dimensions?: { width: number; height: number }}', + }, + }, + description: + 'The poster image to be displayed before playback. Default behavior in iOS is that the first frame of the video is not visible until playback starts, so providing a poster image is recommended for better user experience.', + }, captions: { table: { type: { @@ -77,17 +87,29 @@ export default meta type Story = StoryObj +const inlineSources = [ + { + src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltf1f715c41793a9fb/6943e943ca0c69c3d00bd620/Scandic_EB_Video.mp4', + type: 'video/mp4', + }, +] + +const heroSources = [ + { + 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 defaultArgs = { - 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', - }, - ], + sources: inlineSources, + poster: { + src: 'https://imagevault.scandichotels.com/publishedmedia/dtpv2wgm6jhix2pqpp88/Scandic_Downtown_Camper_restaurang_bar_The_Nest_lounge_eld.jpg', + }, captions: [ { src: './video/captions_en.vtt', @@ -114,6 +136,7 @@ export const BareHero: Story = { args: { ...Default.args, variant: 'hero', + sources: heroSources, }, name: 'Hero (barebones)', parameters: { diff --git a/packages/design-system/lib/components/VideoPlayer/VideoWithCard/VideoWithCard.stories.tsx b/packages/design-system/lib/components/VideoPlayer/VideoWithCard/VideoWithCard.stories.tsx index 370362315..0743a1a34 100644 --- a/packages/design-system/lib/components/VideoPlayer/VideoWithCard/VideoWithCard.stories.tsx +++ b/packages/design-system/lib/components/VideoPlayer/VideoWithCard/VideoWithCard.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { Lang } from '@scandic-hotels/common/constants/language' import { VideoWithCard } from '.' import { config } from './variants' @@ -80,7 +81,7 @@ const meta: Meta = { table: { type: { summary: - '{ sources: { src: string; type: string }[]; captions?: Caption[]; focalPoint?: FocalPoint}', + '{ sources: { src: string; type: string }[]; poster?: { src: string; dimensions?: { width: number; height: number } }; captions?: Caption[]; focalPoint?: FocalPoint}', }, }, description: @@ -104,6 +105,22 @@ const videoProps = { type: 'video/webm', }, ], + poster: { + src: 'https://imagevault.scandichotels.com/publishedmedia/dtpv2wgm6jhix2pqpp88/Scandic_Downtown_Camper_restaurang_bar_The_Nest_lounge_eld.jpg', + }, + captions: [ + { + src: './video/captions_en.vtt', + srcLang: Lang.en, + isDefault: false, + }, + { + src: './video/captions_sv.vtt', + srcLang: Lang.sv, + isDefault: false, + }, + ], + fullHeight: true, } const quoteCardProps = { diff --git a/packages/design-system/lib/components/VideoPlayer/VideoWithCard/index.tsx b/packages/design-system/lib/components/VideoPlayer/VideoWithCard/index.tsx index fd20261a2..13b290886 100644 --- a/packages/design-system/lib/components/VideoPlayer/VideoWithCard/index.tsx +++ b/packages/design-system/lib/components/VideoPlayer/VideoWithCard/index.tsx @@ -2,7 +2,8 @@ import { VariantProps } from 'class-variance-authority' import { Typography } from '../../Typography' import { variants } from './variants' -import { VideoPlayer, VideoPlayerProps } from '..' +import { VideoPlayer } from '..' +import { VideoPlayerProps } from '../types' import styles from './videoWithCard.module.css' interface TextCardProps { variant: 'text' diff --git a/packages/design-system/lib/components/VideoPlayer/index.tsx b/packages/design-system/lib/components/VideoPlayer/index.tsx index 40d251950..61312c210 100644 --- a/packages/design-system/lib/components/VideoPlayer/index.tsx +++ b/packages/design-system/lib/components/VideoPlayer/index.tsx @@ -1,68 +1,59 @@ 'use client' -import { cx, VariantProps } from 'class-variance-authority' -import { - useCallback, - useEffect, - useRef, - useState, - VideoHTMLAttributes, -} from 'react' +import { cx } from 'class-variance-authority' +import { useCallback, useEffect, useRef, useState } from 'react' -import { Lang, languages } from '@scandic-hotels/common/constants/language' -import { FocalPoint } from '@scandic-hotels/common/utils/imageVault' +import { languages } from '@scandic-hotels/common/constants/language' import { useIntl } from 'react-intl' +import Image from '../Image' import { VideoPlayerButton } from './Button' +import { VideoPlayerProps } from './types' +import { useVideoDimensions } from './useVideoDimensions' +import { getVideoPropsByVariant } from './utils' import { variants } from './variants' import styles from './videoPlayer.module.css' -interface Caption { - src: string - srcLang: Lang - isDefault: boolean -} - -export interface VideoPlayerProps extends VariantProps { - 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', + poster, autoPlay, hasOverlay, }: VideoPlayerProps) { const intl = useIntl() const videoRef = useRef(null) - const [isInteractedWith, setIsInteractedWith] = useState(false) - const [isPlaying, setIsPlaying] = useState( + const shouldAutoPlay = (variant === 'hero' && (autoPlay ?? true)) || !!autoPlay - ) + const [hasManuallyPlayed, setHasManuallyPlayed] = useState(false) + const [hasToggledMute, setHasToggledMute] = useState(false) + const [isPlaying, setIsPlaying] = useState(shouldAutoPlay) const [isMuted, setIsMuted] = useState(true) const [userPaused, setUserPaused] = useState(false) + const [showPoster, setShowPoster] = useState(!shouldAutoPlay) + const { + containerRef, + handleMetadataLoaded, + containerWidth, + hasError, + handleError, + } = useVideoDimensions() const defaultProps = getVideoPropsByVariant( variant, - isInteractedWith, - autoPlay + hasManuallyPlayed, + shouldAutoPlay ) const classNames = variants({ className, variant, }) const showPlayButton = - variant === 'hero' || (variant === 'inline' && !isInteractedWith) - const showMuteButton = variant === 'inline' && isInteractedWith + !hasError && + (variant === 'hero' || (variant === 'inline' && !hasManuallyPlayed)) + const showMuteButton = + !hasError && variant === 'inline' && hasManuallyPlayed && !hasToggledMute const handleIntersection = useCallback( (entries: IntersectionObserverEntry[]) => { @@ -89,7 +80,7 @@ export function VideoPlayer({ videoElement.pause() } } else { - setIsInteractedWith(true) + setHasManuallyPlayed(true) videoElement.play() } } @@ -101,6 +92,7 @@ export function VideoPlayer({ const currentlyMuted = videoElement.muted videoElement.muted = !currentlyMuted setIsMuted(!currentlyMuted) + setHasToggledMute(true) } } @@ -112,6 +104,11 @@ export function VideoPlayer({ } } + function handlePlay() { + setShowPoster(false) + setIsPlaying(true) + } + useEffect(() => { const videoElement = videoRef.current @@ -143,18 +140,27 @@ export function VideoPlayer({ }) return ( -
+
+ {(showPoster || hasError) && poster ? ( + + ) : null} {showPlayButton ? ( ) } - -function getVideoPropsByVariant( - variant: VideoPlayerProps['variant'], - isInteractedWith: boolean, - autoPlay?: boolean -): VideoHTMLAttributes { - 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, - } - } -} diff --git a/packages/design-system/lib/components/VideoPlayer/types.ts b/packages/design-system/lib/components/VideoPlayer/types.ts new file mode 100644 index 000000000..e969ead1a --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/types.ts @@ -0,0 +1,27 @@ +import { Lang } from '@scandic-hotels/common/constants/language' +import { FocalPoint } from '@scandic-hotels/common/utils/imageVault' +import { VariantProps } from 'class-variance-authority' +import { variants } from './variants' + +interface Caption { + src: string + srcLang: Lang + isDefault: boolean +} + +export interface VideoPlayerProps extends VariantProps { + sources: { + src: string + type: string + }[] + poster?: { + src: string + dimensions?: { width: number; height: number } + } | null + className?: string + captions?: Caption[] + focalPoint?: FocalPoint + autoPlay?: boolean + hasOverlay?: boolean + fullHeight?: boolean +} diff --git a/packages/design-system/lib/components/VideoPlayer/useVideoDimensions.ts b/packages/design-system/lib/components/VideoPlayer/useVideoDimensions.ts new file mode 100644 index 000000000..804420640 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/useVideoDimensions.ts @@ -0,0 +1,39 @@ +import { useRef, useState } from 'react' + +/** + * Hook to measure container width for optimizing poster image sizes. + * Captures the container width when video metadata loads to pass to Image component. + */ +export function useVideoDimensions() { + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(null) + const [hasError, setHasError] = useState(false) + + function handleMetadataLoaded() { + const container = containerRef.current + if (!container || containerWidth) { + return + } + + setContainerWidth(container.getBoundingClientRect().width) + } + + function handleError() { + setHasError(true) + + const container = containerRef.current + if (!container || containerWidth) { + return + } + + setContainerWidth(container.getBoundingClientRect().width) + } + + return { + containerRef, + handleMetadataLoaded, + containerWidth, + handleError, + hasError, + } +} diff --git a/packages/design-system/lib/components/VideoPlayer/utils.ts b/packages/design-system/lib/components/VideoPlayer/utils.ts new file mode 100644 index 000000000..38cc40b81 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/utils.ts @@ -0,0 +1,30 @@ +import { VideoHTMLAttributes } from 'react' +import { VideoPlayerProps } from './types' + +export function getVideoPropsByVariant( + variant: VideoPlayerProps['variant'], + hasManuallyPlayed: boolean, + shouldAutoPlay: boolean +): VideoHTMLAttributes { + switch (variant) { + case 'hero': + return { + controls: false, + controlsList: 'nodownload nofullscreen noremoteplayback', + autoPlay: shouldAutoPlay, + muted: true, + loop: true, + playsInline: true, + } + case 'inline': + default: + return { + controls: hasManuallyPlayed, + controlsList: 'nodownload noremoteplayback', + autoPlay: shouldAutoPlay, + muted: true, + loop: false, + playsInline: true, + } + } +} diff --git a/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css index 933a08e1c..ba0e81506 100644 --- a/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css +++ b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css @@ -14,6 +14,7 @@ &.hero { height: 100%; + .overlay { display: contents; } @@ -35,6 +36,10 @@ rgba(31, 28, 27, 0.8) 100% ); } + + &.hasError .video { + aspect-ratio: 16 / 9; + } } .video { diff --git a/packages/trpc/lib/graphql/Fragments/Video.graphql.ts b/packages/trpc/lib/graphql/Fragments/Video.graphql.ts index 53e6bb99a..22963d753 100644 --- a/packages/trpc/lib/graphql/Fragments/Video.graphql.ts +++ b/packages/trpc/lib/graphql/Fragments/Video.graphql.ts @@ -12,6 +12,7 @@ export const Video = gql` } } } + poster_image focal_point { x y diff --git a/packages/trpc/lib/routers/contentstack/schemas/video.ts b/packages/trpc/lib/routers/contentstack/schemas/video.ts index e57ba955e..8218196b5 100644 --- a/packages/trpc/lib/routers/contentstack/schemas/video.ts +++ b/packages/trpc/lib/routers/contentstack/schemas/video.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" import { focalPointSchema } from "@scandic-hotels/common/utils/focalPoint" +import { transformedImageVaultAssetSchema } from "@scandic-hotels/common/utils/imageVault" import { assetSystemSchema } from "./system" @@ -16,6 +17,7 @@ export const videoSchema = z.object({ }) ), }), + poster_image: transformedImageVaultAssetSchema.nullish(), focal_point: focalPointSchema.nullish(), captions: z.array( z.object({ @@ -57,6 +59,12 @@ export const transformedVideoSchema = videoSchema return { sources, + poster: video.poster_image?.url + ? { + src: video.poster_image.url, + dimensions: video.poster_image.dimensions, + } + : null, focalPoint: video.focal_point ? { x: video.focal_point.x, y: video.focal_point.y } : { x: 50, y: 50 },