feat(BOOK-257): Added VideoPlayer component

Approved-by: Christel Westerberg
Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2025-12-02 07:35:38 +00:00
parent b1ccabb0b6
commit 84593438e6
13 changed files with 513 additions and 5 deletions

View File

@@ -0,0 +1,53 @@
.videoPlayerButton {
border-radius: var(--Corner-radius-Rounded);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
height: 56px;
width: 56px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
cursor: pointer;
z-index: 0;
border-width: 0;
border-style: solid;
border-color: var(--Base-Border-Subtle);
background-color: transparent;
color: var(--Component-Button-Inverted-On-fill-Default);
@media (hover: hover) {
&:hover .iconWrapper {
background-color: var(--Component-Button-Inverted-Fill-Hover);
color: var(--Component-Button-Inverted-On-fill-Hover);
}
}
&:focus-visible {
border-width: 2px;
outline: 2px solid var(--Border-Inverted);
.transparentBackground {
background-color: var(--Base-Border-Subtle);
}
}
}
.transparentBackground {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Rounded);
opacity: 0.5;
position: absolute;
inset: 0;
z-index: -1;
}
.iconWrapper {
background: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Rounded);
display: flex;
align-items: center;
justify-content: center;
padding: var(--Space-x05);
}

View File

@@ -0,0 +1,39 @@
'use client'
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
}
export function VideoPlayerButton({
onPress,
ariaLabel,
iconName,
className,
}: VideoPlayerButtonProps) {
return (
<div className={className}>
<ButtonRAC
className={styles.videoPlayerButton}
onPress={onPress}
aria-label={ariaLabel}
>
<span className={styles.transparentBackground} />
<span className={styles.iconWrapper}>
<MaterialIcon
icon={iconName}
size={32}
color="CurrentColor"
isFilled
/>
</span>
</ButtonRAC>
</div>
)
}

View File

@@ -0,0 +1,136 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Lang } from '@scandic-hotels/common/constants/language'
import { VideoPlayer } from '.'
import { config as videoPlayerConfig } from './variants'
const meta: Meta<typeof VideoPlayer> = {
title: 'Components/🚧 VideoPlayer 🚧',
component: VideoPlayer,
parameters: {
docs: {
description: {
component:
'This component is not ready for production use. It is still under development and may undergo significant changes.',
},
},
},
argTypes: {
className: {
table: {
disable: true,
},
},
src: {
table: {
type: { summary: 'string' },
},
description: 'The source URL of the video.',
},
captions: {
table: {
type: {
summary: 'Caption[]',
detail: '{ src: string; srcLang: Lang; isDefault: boolean }[]',
},
},
description:
'An array of caption objects for the video. Since this functionality only works when the controls are visible, captions are only supported in the inline variant.',
},
variant: {
control: 'select',
options: Object.keys(videoPlayerConfig.variants.variant),
table: {
defaultValue: {
summary: videoPlayerConfig.defaultVariants.variant,
},
type: {
summary: 'string',
detail: Object.keys(videoPlayerConfig.variants.variant).join(' | '),
},
},
description:
'The variant of the video player, which determines its style and behavior. The hero variant is typically used for large, prominent video displays and defaults to autoplay and muted playback.',
},
focalPoint: {
table: {
type: { summary: 'FocalPoint', detail: '{ x: number; y: number }' },
defaultValue: { summary: '{ x: 50, y: 50 }' },
},
description: 'The focal point of the video thumbnail.',
},
autoPlay: {
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
description:
'Whether the video should autoplay. Note that autoplay might be ignored by browsers unless the video is muted, which is the default behavior for this component.',
},
},
}
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',
captions: [
{
src: './video/captions_en.vtt',
srcLang: Lang.en,
isDefault: false,
},
{
src: './video/captions_sv.vtt',
srcLang: Lang.sv,
isDefault: false,
},
],
}
export const Default: Story = {
args: { ...defaultArgs },
}
export const Inline: Story = {
args: { ...Default.args, variant: 'inline' },
}
export const BareHero: Story = {
args: {
...Default.args,
variant: 'hero',
},
name: 'Hero (barebones)',
parameters: {
docs: {
description: {
story:
'The Hero variant is intended for use as a large video player, typically placed at the top of a page or section. It features autoplay and muted playback. It is pretty bare bones and requires a "wrapper" to override its size and aspect ratio. See the example below.',
},
},
},
}
export const Hero: Story = {
args: {
...BareHero.args,
},
render: (args) => (
<div
style={{
width: 'min(100%, 1200px)',
height: 'max(20vh, 300px)',
borderRadius: 'var(--Corner-radius-lg)',
margin: 'auto',
overflow: 'hidden',
}}
>
<VideoPlayer {...args} />
</div>
),
}

View File

@@ -0,0 +1,179 @@
'use client'
import { cx, VariantProps } from 'class-variance-authority'
import { 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
}
interface VideoPlayerProps extends VariantProps<typeof variants> {
src: string
className?: string
captions?: Caption[]
focalPoint?: FocalPoint
autoPlay?: boolean
}
export function VideoPlayer({
src,
captions,
focalPoint = { x: 50, y: 50 },
className,
variant = 'inline',
autoPlay,
}: VideoPlayerProps) {
const intl = useIntl()
const videoRef = useRef<HTMLVideoElement>(null)
const [isActivated, setIsActivated] = useState(
(variant === 'hero' && (autoPlay ?? true)) || !!autoPlay
)
const [isPlaying, setIsPlaying] = useState(autoPlay ?? false)
const [isMuted, setIsMuted] = useState(true)
const defaultProps = getVideoPropsByVariant(variant, isActivated, autoPlay)
const classNames = variants({
className,
variant,
})
function togglePlay() {
const videoElement = videoRef.current
if (videoElement) {
if (variant === 'hero') {
if (videoElement.paused) {
videoElement.play()
} else {
videoElement.pause()
}
} else {
setIsActivated(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)
}
}
const showPlayButton =
variant === 'hero' || (variant === 'inline' && !isActivated)
const showMuteButton = variant === 'inline' && isActivated
return (
<div className={cx(classNames, { [styles.isActivated]: isActivated })}>
<video
ref={videoRef}
className={styles.video}
src={src}
style={
focalPoint
? { objectPosition: `${focalPoint.x}% ${focalPoint.y}%` }
: undefined
}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onVolumeChange={handleVolumeChangeEvent}
{...defaultProps}
>
{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'],
isActive: boolean,
autoPlay?: boolean
): VideoHTMLAttributes<HTMLVideoElement> {
switch (variant) {
case 'hero':
return {
controls: false,
controlsList: 'nodownload nofullscreen noremoteplayback',
autoPlay: autoPlay ?? true,
muted: true,
loop: true,
}
case 'inline':
default:
return {
controls: isActive,
controlsList: 'nodownload noremoteplayback',
autoPlay: autoPlay ?? isActive,
muted: true,
loop: false,
}
}
}

View File

@@ -0,0 +1,17 @@
import { cva } from 'class-variance-authority'
import styles from './videoPlayer.module.css'
export const config = {
variants: {
variant: {
inline: styles.inline,
hero: styles.hero,
},
},
defaultVariants: {
variant: 'inline',
},
} as const
export const variants = cva(styles.videoPlayer, config)

View File

@@ -0,0 +1,48 @@
.videoPlayer {
position: relative;
width: 100%;
aspect-ratio: auto;
display: flex;
justify-content: center;
align-items: center;
&.inline {
border-radius: var(--Corner-radius-md);
overflow: hidden;
}
&.hero {
height: 100%;
.overlay {
display: contents;
}
.playButton {
position: absolute;
bottom: var(--Space-x2);
right: var(--Space-x2);
}
}
}
.video {
width: 100%;
height: 100%;
object-fit: cover;
}
.playButton {
position: absolute;
}
.muteButton {
position: absolute;
top: var(--Space-x2);
right: var(--Space-x2);
}
@media screen and (min-width: 768px) {
.videoPlayer.hero .playButton {
bottom: var(--Space-x4);
right: var(--Space-x4);
}
}

View File

@@ -276,7 +276,7 @@
font-style: normal;
font-weight: 400;
font-display: block;
src: url(/_static/shared/fonts/material-symbols/rounded-3e9207ba.woff2)
src: url(/_static/shared/fonts/material-symbols/rounded-fa2883c6.woff2)
format('woff2');
}

View File

@@ -0,0 +1,16 @@
WEBVTT
00:00:01.000 --> 00:00:10.000
EN: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
00:00:13.000 --> 00:00:25.000
EN: Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.
00:00:28.000 --> 00:00:48.000
EN: Cras in tellus et ligula posuere ullamcorper.
00:00:51.000 --> 00:01:18.000
EN: Praesent pulvinar rutrum metus ut gravida.
00:01:25.000 --> 00:02:10.000
The end...

View File

@@ -0,0 +1,16 @@
WEBVTT
00:00:01.000 --> 00:00:10.000
SV: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
00:00:13.000 --> 00:00:25.000
SV: Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.
00:00:28.000 --> 00:00:48.000
SV: Cras in tellus et ligula posuere ullamcorper.
00:00:51.000 --> 00:01:18.000
SV: Praesent pulvinar rutrum metus ut gravida.
00:01:25.000 --> 00:02:10.000
Slut...

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { resolve, join } from "node:path";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -168,11 +168,13 @@ const icons = [
"open_in_new",
"pan_zoom",
"panorama",
"pause",
"pedal_bike",
"person",
"pets",
"phone",
"photo_camera",
"play_arrow",
"pool",
"print",
"radio",
@@ -220,6 +222,8 @@ const icons = [
"upload",
"visibility_off",
"visibility",
"volume_off",
"volume_up",
"ward",
"warning",
"water_full",

View File

@@ -1,3 +1,3 @@
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
hash=3e9207ba
created=2025-11-25T08:52:11.965Z
hash=fa2883c6
created=2025-12-01T10:31:09.165Z

Binary file not shown.