feat(BOOK-257): Added VideoPlayer component
Approved-by: Christel Westerberg Approved-by: Bianca Widstam
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
179
packages/design-system/lib/components/VideoPlayer/index.tsx
Normal file
179
packages/design-system/lib/components/VideoPlayer/index.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
16
packages/design-system/public/video/captions_en.vtt
Normal file
16
packages/design-system/public/video/captions_en.vtt
Normal 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...
|
||||
16
packages/design-system/public/video/captions_sv.vtt
Normal file
16
packages/design-system/public/video/captions_sv.vtt
Normal 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...
|
||||
Reference in New Issue
Block a user