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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: block;
|
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');
|
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...
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { createWriteStream } from "node:fs";
|
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 { Readable } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
@@ -168,11 +168,13 @@ const icons = [
|
|||||||
"open_in_new",
|
"open_in_new",
|
||||||
"pan_zoom",
|
"pan_zoom",
|
||||||
"panorama",
|
"panorama",
|
||||||
|
"pause",
|
||||||
"pedal_bike",
|
"pedal_bike",
|
||||||
"person",
|
"person",
|
||||||
"pets",
|
"pets",
|
||||||
"phone",
|
"phone",
|
||||||
"photo_camera",
|
"photo_camera",
|
||||||
|
"play_arrow",
|
||||||
"pool",
|
"pool",
|
||||||
"print",
|
"print",
|
||||||
"radio",
|
"radio",
|
||||||
@@ -220,6 +222,8 @@ const icons = [
|
|||||||
"upload",
|
"upload",
|
||||||
"visibility_off",
|
"visibility_off",
|
||||||
"visibility",
|
"visibility",
|
||||||
|
"volume_off",
|
||||||
|
"volume_up",
|
||||||
"ward",
|
"ward",
|
||||||
"warning",
|
"warning",
|
||||||
"water_full",
|
"water_full",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
|
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
|
||||||
hash=3e9207ba
|
hash=fa2883c6
|
||||||
created=2025-11-25T08:52:11.965Z
|
created=2025-12-01T10:31:09.165Z
|
||||||
|
|||||||
Binary file not shown.
BIN
shared/fonts/material-symbols/rounded-fa2883c6.woff2
Normal file
BIN
shared/fonts/material-symbols/rounded-fa2883c6.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user