diff --git a/packages/design-system/lib/components/VideoPlayer/Button/button.module.css b/packages/design-system/lib/components/VideoPlayer/Button/button.module.css new file mode 100644 index 000000000..185f21523 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/Button/button.module.css @@ -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); +} diff --git a/packages/design-system/lib/components/VideoPlayer/Button/index.tsx b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx new file mode 100644 index 000000000..24d759ff4 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx @@ -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 ( +
+ + + + + + +
+ ) +} diff --git a/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx new file mode 100644 index 000000000..9d3e32bb3 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx @@ -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 = { + 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 + +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) => ( +
+ +
+ ), +} diff --git a/packages/design-system/lib/components/VideoPlayer/index.tsx b/packages/design-system/lib/components/VideoPlayer/index.tsx new file mode 100644 index 000000000..41d07f24d --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/index.tsx @@ -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 { + 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(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) { + 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 ( +
+ + {showPlayButton ? ( + + ) : null} + {showMuteButton ? ( + + ) : null} +
+ ) +} + +function getVideoPropsByVariant( + variant: VideoPlayerProps['variant'], + isActive: boolean, + autoPlay?: boolean +): VideoHTMLAttributes { + 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, + } + } +} diff --git a/packages/design-system/lib/components/VideoPlayer/variants.ts b/packages/design-system/lib/components/VideoPlayer/variants.ts new file mode 100644 index 000000000..fc47e05ec --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/variants.ts @@ -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) diff --git a/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css new file mode 100644 index 000000000..f881c5a98 --- /dev/null +++ b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css @@ -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); + } +} diff --git a/packages/design-system/lib/fonts.css b/packages/design-system/lib/fonts.css index feb992639..5a43f5b3d 100644 --- a/packages/design-system/lib/fonts.css +++ b/packages/design-system/lib/fonts.css @@ -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'); } diff --git a/packages/design-system/public/video/captions_en.vtt b/packages/design-system/public/video/captions_en.vtt new file mode 100644 index 000000000..d849f4343 --- /dev/null +++ b/packages/design-system/public/video/captions_en.vtt @@ -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... diff --git a/packages/design-system/public/video/captions_sv.vtt b/packages/design-system/public/video/captions_sv.vtt new file mode 100644 index 000000000..53070f4e5 --- /dev/null +++ b/packages/design-system/public/video/captions_sv.vtt @@ -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... diff --git a/scripts/material-symbols-update.mts b/scripts/material-symbols-update.mts index 748b8654a..ecdcb1ab5 100644 --- a/scripts/material-symbols-update.mts +++ b/scripts/material-symbols-update.mts @@ -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", diff --git a/shared/fonts/material-symbols/.auto-generated b/shared/fonts/material-symbols/.auto-generated index 0fc4617e0..640087447 100644 --- a/shared/fonts/material-symbols/.auto-generated +++ b/shared/fonts/material-symbols/.auto-generated @@ -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 diff --git a/shared/fonts/material-symbols/rounded-3e9207ba.woff2 b/shared/fonts/material-symbols/rounded-3e9207ba.woff2 deleted file mode 100644 index e6a4d619d..000000000 Binary files a/shared/fonts/material-symbols/rounded-3e9207ba.woff2 and /dev/null differ diff --git a/shared/fonts/material-symbols/rounded-fa2883c6.woff2 b/shared/fonts/material-symbols/rounded-fa2883c6.woff2 new file mode 100644 index 000000000..16e34efb3 Binary files /dev/null and b/shared/fonts/material-symbols/rounded-fa2883c6.woff2 differ