From 6b8322e3fde0c5bdedf97e3e2549c196e2d6c217 Mon Sep 17 00:00:00 2001 From: Guusvanmeerveld Date: Wed, 20 Apr 2022 13:45:32 +0200 Subject: [PATCH] Implemented basic video player --- README.md | 2 +- ...E.sync-conflict-20220415-104253-DF2U2GH.md | 13 ++ package.json | 8 +- src/components/Player/Actions.tsx | 89 ++++++++ src/components/Player/ProgressBar.tsx | 74 +++++++ src/components/Player/Time.tsx | 27 +++ src/components/Player/index.tsx | 198 ++++++++++++++++++ src/components/Video/index.tsx | 121 ++++++----- src/interfaces/settings.ts | 1 + src/interfaces/videoPlayer.ts | 11 + src/pages/_app.tsx | 2 +- src/pages/results.tsx | 2 +- src/pages/settings.tsx | 2 +- src/pages/trending.tsx | 28 +-- src/pages/watch.tsx | 26 ++- src/utils/{hooks.ts => hooks/useSettings.ts} | 7 +- src/utils/hooks/useVideoState.ts | 50 +++++ src/utils/index.ts | 8 + src/utils/requests.ts | 2 +- yarn.lock | 15 ++ 20 files changed, 608 insertions(+), 78 deletions(-) create mode 100644 README.sync-conflict-20220415-104253-DF2U2GH.md create mode 100644 src/components/Player/Actions.tsx create mode 100644 src/components/Player/ProgressBar.tsx create mode 100644 src/components/Player/Time.tsx create mode 100644 src/components/Player/index.tsx create mode 100644 src/interfaces/videoPlayer.ts rename src/utils/{hooks.ts => hooks/useSettings.ts} (85%) create mode 100644 src/utils/hooks/useVideoState.ts diff --git a/README.md b/README.md index 6fada84..f83f902 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

MaterialTube

diff --git a/README.sync-conflict-20220415-104253-DF2U2GH.md b/README.sync-conflict-20220415-104253-DF2U2GH.md new file mode 100644 index 0000000..9f09b50 --- /dev/null +++ b/README.sync-conflict-20220415-104253-DF2U2GH.md @@ -0,0 +1,13 @@ +# MaterialTube + +![Deploy site](https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/deploy.yml/badge.svg) +![CodeQL](https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/codeql-analysis.yml/badge.svg) + +![Docker pulls](https://shields.io/docker/pulls/guusvanmeerveld/materialtube) + +### MaterialTube is a simple client-side only web-client for Invidious servers. It supports using an Invidious account, but also allows you to store all of your data locally. It's main goal is to provide an even greater level of privacy and improve on the current Invidious UI. + +### Made using + +![Typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) +![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) diff --git a/package.json b/package.json index 8165bcb..c30f987 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "repository": { "url": "https://github.com/Guusvanmeerveld/MaterialTube" }, + "cacheDirectories": [ + ".next/cache" + ], "scripts": { "dev": "next dev", "build": "next build", @@ -20,16 +23,19 @@ "@mui/icons-material": "^5.5.1", "@mui/material": "^5.5.1", "axios": "^0.26.1", + "luxon": "^2.3.1", "next": "^12.1.0", "next-seo": "^5.1.0", "react": "^17.0.2", "react-dom": "^17.0.2", "react-intersection-observer": "^8.33.1", "react-query": "^3.34.16", - "use-local-storage-state": "^16.0.1" + "use-local-storage-state": "^16.0.1", + "zustand": "^3.7.2" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^3.2.0", + "@types/luxon": "^2.3.1", "@types/node": "^17.0.21", "@types/react": "^17.0.40", "@types/react-dom": "^17.0.13", diff --git a/src/components/Player/Actions.tsx b/src/components/Player/Actions.tsx new file mode 100644 index 0000000..78aa9ae --- /dev/null +++ b/src/components/Player/Actions.tsx @@ -0,0 +1,89 @@ +import { FC, MutableRefObject } from "react"; + +import Box from "@mui/material/Box"; +import Tooltip from "@mui/material/Tooltip"; + +import Fullscreen from "@mui/icons-material/Fullscreen"; +import Pause from "@mui/icons-material/Pause"; +import PlayArrow from "@mui/icons-material/PlayArrow"; +import Settings from "@mui/icons-material/Settings"; +import SkipNext from "@mui/icons-material/SkipNext"; +import Subtitles from "@mui/icons-material/Subtitles"; +import VolumeUp from "@mui/icons-material/VolumeUp"; + +import { VideoStatus } from "@interfaces/videoPlayer"; + +import useVideoState from "@utils/hooks/useVideoState"; + +import Time from "@components/Player/Time"; + +const iconStyles = { + mr: 1.5, + cursor: "pointer" +}; + +const Actions: FC<{ + duration: number; + videoRef: MutableRefObject; +}> = ({ duration, videoRef }) => { + const togglePlaying = useVideoState((state) => state.togglePlaying); + const playing = useVideoState((state) => state.playing); + + const muted = useVideoState((state) => state.muted); + const toggleMuted = useVideoState((state) => state.toggleMuted); + + return ( + + + + togglePlaying()} + > + {playing == VideoStatus.Playing ? : } + + + + + + + toggleMuted()} sx={iconStyles} /> + + + + + + + + + + + + + + + ); +}; + +export default Actions; diff --git a/src/components/Player/ProgressBar.tsx b/src/components/Player/ProgressBar.tsx new file mode 100644 index 0000000..e9dbd8f --- /dev/null +++ b/src/components/Player/ProgressBar.tsx @@ -0,0 +1,74 @@ +import { FC, MutableRefObject, useEffect, useState } from "react"; + +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; + +import useVideoState from "@utils/hooks/useVideoState"; + +const ProgressBar: FC<{ + duration: number; + videoRef: MutableRefObject; +}> = ({ videoRef, duration }) => { + const theme = useTheme(); + + const [buffer, setBuffer] = useState(1); + + const height = 5; + + const bufferColor = "rgba(200, 200, 200, 0.5)"; + const backgroundColor = "rgba(132, 132, 132, 0.5)"; + + const progress = (useVideoState((state) => state.progress) / duration) * 100; + + useEffect(() => { + if (!videoRef.current) return; + + const buffered = videoRef.current.buffered; + + if (buffered.length != 0) { + const newBuffer = + ((buffered.end(0) - buffered.start(0)) / duration) * 100; + + if (newBuffer != buffer) { + setBuffer(newBuffer); + } + } + }, [buffer, duration, videoRef, videoRef.current?.buffered]); + + return ( + + + + + + + ); +}; + +export default ProgressBar; diff --git a/src/components/Player/Time.tsx b/src/components/Player/Time.tsx new file mode 100644 index 0000000..963b6de --- /dev/null +++ b/src/components/Player/Time.tsx @@ -0,0 +1,27 @@ +import { FC, MutableRefObject } from "react"; + +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; + +import { formatTime } from "@src/utils/"; + +import useVideoState from "@utils/hooks/useVideoState"; + +const Time: FC<{ + videoRef: MutableRefObject; + duration: number; +}> = ({ videoRef, duration }) => { + const theme = useTheme(); + + const progress = useVideoState((state) => state.progress); + + return ( + + {formatTime(Math.round(progress))} + <> / + {formatTime(duration)} + + ); +}; + +export default Time; diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx new file mode 100644 index 0000000..71ae2ff --- /dev/null +++ b/src/components/Player/index.tsx @@ -0,0 +1,198 @@ +import { FC, MutableRefObject, useEffect, useRef, useState } from "react"; + +import Box from "@mui/material/Box"; +import { SxProps } from "@mui/material/styles"; + +import { AdaptiveFormat, Caption, FormatStream } from "@interfaces/video"; +import { PausedBy, VideoStatus } from "@interfaces/videoPlayer"; + +import useSettings from "@utils/hooks/useSettings"; +import useVideoState from "@utils/hooks/useVideoState"; + +import Actions from "@components/Player/Actions"; +import ProgressBar from "@components/Player/ProgressBar"; + +const Player: FC<{ + formats: AdaptiveFormat[]; + streams: FormatStream[]; + captions: Caption[]; + length: number; + videoId: string; + sx: SxProps; +}> = ({ formats, length: duration, sx }) => { + const [settings] = useSettings(); + + const playing = useVideoState((state) => state.playing); + const togglePlaying = useVideoState((state) => state.togglePlaying); + const setPlaying = useVideoState((state) => state.setPlaying); + + const speed = useVideoState((state) => state.speed); + const muted = useVideoState((state) => state.muted); + + const error = useVideoState((state) => state.error); + const setError = useVideoState((state) => state.setError); + + const waiting = useVideoState((state) => state.waiting); + const setWaiting = useVideoState((state) => state.setWaiting); + + const setProgress = useVideoState((state) => state.setProgress); + + const pausedBy = useVideoState((state) => state.pausedBy); + + const videoStream = formats.find( + (format) => format.qualityLabel == "2160p" || format.qualityLabel == "1080p" + )?.url; + + const audioStream = formats.find((format) => + format.type.includes("audio/mp4") + )?.url as string; + + const videoRef = useRef(null); + const audioRef = useRef(new Audio(audioStream)); + + useEffect(() => { + const audio = audioRef.current; + + audio.volume = 0.25; + + const video = videoRef.current; + + if (!video) return; + + video.playbackRate = speed; + + video.currentTime = 0; + + const handleError = (e: ErrorEvent) => { + setError(e.message || "An unknown error occurred"); + setPlaying(VideoStatus.Paused, PausedBy.Player); + }; + + const handleWaiting = (e: Event) => { + setWaiting(true); + + if (playing == VideoStatus.Playing) + setPlaying(VideoStatus.Paused, PausedBy.Player); + }; + + const handleFinishedWaiting = (e: Event) => { + setWaiting(false); + + if (pausedBy == PausedBy.Player) setPlaying(VideoStatus.Playing); + }; + + const onTimeUpdate = () => { + setProgress(video.currentTime ?? 0); + }; + + const handlePause = () => { + setPlaying(VideoStatus.Paused); + }; + + if (!videoStream) setError("Could not find video stream"); + + video.addEventListener("waiting", handleWaiting); + video.addEventListener("canplaythrough", handleFinishedWaiting); + video.addEventListener("error", handleError); + video.addEventListener("pause", handlePause); + video.addEventListener("timeupdate", onTimeUpdate); + + audio.addEventListener("waiting", handleWaiting); + audio.addEventListener("canplaythrough", handleFinishedWaiting); + audio.addEventListener("pause", handlePause); + + return () => { + audio.srcObject = null; + + video.removeEventListener("waiting", handleWaiting); + video.removeEventListener("canplaythrough", handleFinishedWaiting); + video.removeEventListener("error", handleError); + video.removeEventListener("pause", handlePause); + video.removeEventListener("timeupdate", onTimeUpdate); + + audio.removeEventListener("waiting", handleWaiting); + audio.removeEventListener("canplaythrough", handleFinishedWaiting); + audio.removeEventListener("pause", handlePause); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setPlaying(settings.autoPlay ? VideoStatus.Playing : VideoStatus.Paused); + }, [setPlaying, settings.autoPlay]); + + useEffect(() => { + if (!videoRef.current || !audioRef.current) return; + + if (playing == VideoStatus.Paused) { + videoRef.current.pause(); + audioRef.current.pause(); + } else { + videoRef.current.play(); + audioRef.current.play(); + } + }, [error, playing, waiting]); + + useEffect(() => { + if (!videoRef.current) return; + + videoRef.current.playbackRate = speed; + }, [speed]); + + useEffect(() => { + if (!audioRef.current) return; + + audioRef.current.muted = muted; + }, [muted, audioRef]); + + return ( + + {error && ( + + {error} + + )} + + togglePlaying(PausedBy.User)} + sx={{ + boxShadow: "0px -15px 30px 0px rgba(0,0,0,0.75) inset", + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%" + }} + > + + + + ); +}; + +export default Player; diff --git a/src/components/Video/index.tsx b/src/components/Video/index.tsx index 8748170..dae52c7 100644 --- a/src/components/Video/index.tsx +++ b/src/components/Video/index.tsx @@ -1,5 +1,6 @@ +import { DateTime } from "luxon"; + import Link from "next/link"; -import { useRouter } from "next/router"; import { FC } from "react"; @@ -14,7 +15,7 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; -import { abbreviateNumber } from "@src/utils"; +import { abbreviateNumber, formatTime } from "@src/utils"; import VideoModel from "@interfaces/video"; @@ -29,61 +30,73 @@ const Video: FC = (video) => { thumbnail: authorThumbnail } = useAuthorThumbnail(video.author.id, 100); - const router = useRouter(); - return ( - - router.push({ pathname: "/watch", query: { v: video.id } }) - } - > - - - - - {video.title} - - - - - - {isLoading && } - {!isLoading && ( - - )} - - {video.author.name} - + + + + + + + {formatTime(video.length)} - - - - {!(video.live || video.upcoming) && ( - <> - {abbreviateNumber(video.views)} Views • Published{" "} - {video.published.text} - - )} - - - + + + + + {video.title} + + + + + + {isLoading && } + {!isLoading && ( + + )} + + {video.author.name} + + + + + + {!(video.live || video.upcoming) && ( + <> + {abbreviateNumber(video.views)} Views • Published{" "} + {video.published.text} + + )} + + + + + ); }; diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index 74d4af6..4ad041e 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -7,6 +7,7 @@ interface Settings { storageType: StorageType; customServer?: string; password?: string; + autoPlay: boolean; } export enum StorageType { diff --git a/src/interfaces/videoPlayer.ts b/src/interfaces/videoPlayer.ts new file mode 100644 index 0000000..ce6d0d3 --- /dev/null +++ b/src/interfaces/videoPlayer.ts @@ -0,0 +1,11 @@ +export type VideoSpeed = 0.25 | 0.5 | 1.0 | 1.25 | 1.5 | 1.75 | 2.0; + +export enum VideoStatus { + Playing = "playing", + Paused = "paused" +} + +export enum PausedBy { + User = "user", + Player = "player" +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 48baffd..11f4b92 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -14,7 +14,7 @@ import "@src/globals.css"; import SEO from "@src/next-seo.config"; import createTheme from "@src/theme"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 249ce58..dd703e2 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -27,7 +27,7 @@ import Result, { } from "@interfaces/api/search"; import { apiToVideo } from "@utils/conversions"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; import Channel from "@components/Channel/Inline"; import Layout from "@components/Layout"; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 92a69e9..1d8fade 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -34,7 +34,7 @@ import { } from "@interfaces/api/instances"; import { StorageType } from "@interfaces/settings"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; import Layout from "@components/Layout"; import MaterialColorPicker from "@components/MaterialColorPicker"; diff --git a/src/pages/trending.tsx b/src/pages/trending.tsx index 514acd2..2de000e 100644 --- a/src/pages/trending.tsx +++ b/src/pages/trending.tsx @@ -9,19 +9,20 @@ import axios, { AxiosError } from "axios"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import { Error } from "@interfaces/api"; import VideoTrending from "@interfaces/api/trending"; import { apiToVideo } from "@utils/conversions"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; import Layout from "@components/Layout"; import Loading from "@components/Loading"; import Grid from "@components/Video/Grid"; -const fetchTrending = (server: string, category = "") => +const fetchTrending = (server: string, category: string) => axios .get(`https://${server}/api/v1/trending`, { params: { @@ -48,15 +49,15 @@ const fetchTrending = (server: string, category = "") => .then((res) => res.data); const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => { - const [selectedCategory, setCategory] = useState(); + const [selectedCategory, setCategory] = useState("all"); const [settings] = useSettings(); - const { isLoading, error, data } = useQuery< + const { isLoading, error, data, isFetching } = useQuery< VideoTrending[], AxiosError >( - "trendingData", + ["trendingData", selectedCategory], () => fetchTrending(settings.invidiousServer, selectedCategory), { initialData: props.trending @@ -75,10 +76,8 @@ const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => { {error && {error.response?.data.error}} {!isLoading && !error && data && ( <> - - - Categories: - + + Categories: {["Music", "Gaming", "News", "Movies"].map((category) => { const name = category.toLowerCase(); const isSelected = name == selectedCategory; @@ -89,10 +88,13 @@ const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => { key={category} color={isSelected ? "primary" : "default"} label={category} - onClick={() => setCategory(isSelected ? undefined : name)} + onClick={() => { + setCategory(isSelected ? "all" : name); + }} /> ); })} + {isFetching && } @@ -105,11 +107,13 @@ const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => { export const getStaticProps: GetStaticProps = async ({}) => { const trending = await fetchTrending( - process.env.NEXT_PUBLIC_DEFAULT_SERVER as string + process.env.NEXT_PUBLIC_DEFAULT_SERVER as string, + "all" ); return { - props: { trending: trending.slice(0, 10) } + props: { trending: trending.slice(0, 10) }, + revalidate: 30 }; }; diff --git a/src/pages/watch.tsx b/src/pages/watch.tsx index 50a3e65..15c0b75 100644 --- a/src/pages/watch.tsx +++ b/src/pages/watch.tsx @@ -4,20 +4,22 @@ import { NextPage } from "next"; import { NextSeo } from "next-seo"; import { useRouter } from "next/router"; -import { useEffect } from "react"; - import { useQuery } from "react-query"; import axios, { AxiosError } from "axios"; +import Container from "@mui/material/Container"; +import Typography from "@mui/material/Typography"; + import { Error } from "@interfaces/api"; import VideoAPI from "@interfaces/api/video"; import { videoToVideo } from "@utils/conversions"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; import Layout from "@components/Layout"; import Loading from "@components/Loading"; +import Player from "@components/Player"; const Watch: NextPage = () => { const { query, isReady } = useRouter(); @@ -58,7 +60,23 @@ const Watch: NextPage = () => { <> - {} + + {data && ( + <> + + + {data.title} + + + )} + ); }; diff --git a/src/utils/hooks.ts b/src/utils/hooks/useSettings.ts similarity index 85% rename from src/utils/hooks.ts rename to src/utils/hooks/useSettings.ts index 091dc14..f285a22 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks/useSettings.ts @@ -10,10 +10,11 @@ const defaultSettings: Settings = { primaryColor: red[800], accentColor: red[800], invidiousServer: process.env.NEXT_PUBLIC_DEFAULT_SERVER as string, - storageType: StorageType.Local + storageType: StorageType.Local, + autoPlay: true }; -export const useSettings = (): [ +const useSettings = (): [ settings: Settings, setSetting: Dispatch> ] => { @@ -24,3 +25,5 @@ export const useSettings = (): [ return [settings, setSettings]; }; + +export default useSettings; diff --git a/src/utils/hooks/useVideoState.ts b/src/utils/hooks/useVideoState.ts new file mode 100644 index 0000000..9cb1d3b --- /dev/null +++ b/src/utils/hooks/useVideoState.ts @@ -0,0 +1,50 @@ +import create from "zustand"; + +import { PausedBy, VideoSpeed, VideoStatus } from "@interfaces/videoPlayer"; + +interface VideoState { + progress: number; + setProgress: (progress: number) => void; + speed: VideoSpeed; + setSpeed: (speed: VideoSpeed) => void; + error?: string; + setError: (error: string) => void; + waiting: boolean; + setWaiting: (waiting: boolean) => void; + muted: boolean; + toggleMuted: () => void; + pausedBy?: PausedBy; + playing: VideoStatus; + togglePlaying: (pausedBy?: PausedBy) => void; + setPlaying: (playing: VideoStatus, pausedBy?: PausedBy) => void; +} + +const useVideoState = create((set) => ({ + progress: 0, + setProgress: (progress) => set(() => ({ progress })), + speed: 1, + setSpeed: (speed) => set(() => ({ speed })), + error: undefined, + setError: (error) => set(() => ({ error })), + waiting: true, + setWaiting: (waiting) => set(() => ({ waiting })), + muted: false, + toggleMuted: () => set((state) => ({ muted: !state.muted })), + pausedBy: undefined, + playing: VideoStatus.Paused, + togglePlaying: (pausedBy?: PausedBy) => + set((state) => ({ + playing: + state.playing == VideoStatus.Playing + ? VideoStatus.Paused + : VideoStatus.Playing, + pausedBy: state.playing == VideoStatus.Playing ? pausedBy : undefined + })), + setPlaying: (playing, pausedBy) => + set(() => ({ + playing, + pausedBy: playing == VideoStatus.Paused ? pausedBy : undefined + })) +})); + +export default useVideoState; diff --git a/src/utils/index.ts b/src/utils/index.ts index f224b9c..06ea476 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +import { DateTime } from "luxon"; + export const abbreviateNumber = (value: number): string => { const suffixes = ["", "K", "M", "B", "T"]; @@ -14,6 +16,12 @@ export const abbreviateNumber = (value: number): string => { export const formatNumber = (number: number) => number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); +export const formatTime = (timestamp: number) => + DateTime.fromSeconds(timestamp) + .toUTC() + .toFormat("H:mm:ss") + .replace(/^(0:)/g, ""); + export const toCamelCase = (string: string): string => string .replace(/(?:^\w|[A-Z]|\b\w)/g, (leftTrim: string, index: number) => diff --git a/src/utils/requests.ts b/src/utils/requests.ts index 26f4bc0..b779b38 100644 --- a/src/utils/requests.ts +++ b/src/utils/requests.ts @@ -5,7 +5,7 @@ import { useQuery } from "react-query"; import axios, { AxiosError } from "axios"; -import { useSettings } from "@utils/hooks"; +import useSettings from "@utils/hooks/useSettings"; interface Channel { authorThumbnails: { url: string; width: number; height: number }[]; diff --git a/yarn.lock b/yarn.lock index 8df7b81..0d536d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -623,6 +623,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/luxon@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-2.3.1.tgz#e34763178b46232e4c5f079f1706e18692415519" + integrity sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ== + "@types/node@^17.0.21": version "17.0.21" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" @@ -2007,6 +2012,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.3.1.tgz#f276b1b53fd9a740a60e666a541a7f6dbed4155a" + integrity sha512-I8vnjOmhXsMSlNMZlMkSOvgrxKJl0uOsEzdGgGNZuZPaS9KlefpE9KV95QFftlJSC+1UyCC9/I69R02cz/zcCA== + match-sorter@^6.0.2: version "6.3.1" resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda" @@ -2717,3 +2727,8 @@ yaml@^1.7.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +zustand@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.7.2.tgz#7b44c4f4a5bfd7a8296a3957b13e1c346f42514d" + integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==