Implemented basic video player

main
Guus van Meerveld 3 years ago
parent ba48608135
commit 6b8322e3fd
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -1,5 +1,5 @@
<p align="center">
<img src="src/svg/logo.svg" height="96"/>
<img src="https://raw.githubusercontent.com/Guusvanmeerveld/MaterialTube/master/src/svg/logo.svg" height="96"/>
</p>
<h1 align="center">MaterialTube</h1>

@ -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)

@ -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",

@ -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<HTMLVideoElement | null>;
}> = ({ 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 (
<Box
sx={{
position: "absolute",
display: "flex",
width: "100%",
height: 40,
px: 1.5,
bottom: 5,
left: 0
}}
>
<Box sx={{ flexGrow: 1, display: "flex", alignItems: "center" }}>
<Tooltip title={playing == VideoStatus.Playing ? "Pause" : "Play"}>
<Box
sx={{
...iconStyles,
display: "flex",
alignItems: "center"
}}
onClick={() => togglePlaying()}
>
{playing == VideoStatus.Playing ? <Pause /> : <PlayArrow />}
</Box>
</Tooltip>
<Tooltip title="Play next video">
<SkipNext sx={iconStyles} />
</Tooltip>
<Tooltip title={muted ? "Unmute" : "Mute"}>
<VolumeUp onClick={() => toggleMuted()} sx={iconStyles} />
</Tooltip>
<Time duration={duration} videoRef={videoRef} />
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title="Turn on captions">
<Subtitles sx={iconStyles} />
</Tooltip>
<Tooltip title="Change quality">
<Settings sx={iconStyles} />
</Tooltip>
<Tooltip title="Fullscreen">
<Fullscreen
sx={{
...iconStyles,
transition: "font-size .2s",
"&:hover": { fontSize: "2rem" }
}}
/>
</Tooltip>
</Box>
</Box>
);
};
export default Actions;

@ -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<HTMLVideoElement | null>;
}> = ({ videoRef, duration }) => {
const theme = useTheme();
const [buffer, setBuffer] = useState<number>(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 (
<Box
sx={{
position: "absolute",
cursor: "pointer",
width: "98%",
backgroundColor,
height,
left: "1%",
bottom: 45,
"&:hover": {}
}}
>
<Box sx={{ position: "relative" }}>
<Box
sx={{
backgroundColor: theme.palette.primary.main,
position: "absolute",
height,
zIndex: 10
}}
style={{ width: `${progress}%` }}
></Box>
<Box
sx={{
backgroundColor: bufferColor,
position: "absolute",
height
}}
style={{ width: `${buffer}%` }}
></Box>
</Box>
</Box>
);
};
export default ProgressBar;

@ -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<HTMLVideoElement | null>;
duration: number;
}> = ({ videoRef, duration }) => {
const theme = useTheme();
const progress = useVideoState((state) => state.progress);
return (
<Typography variant="subtitle1" color={theme.palette.text.secondary}>
{formatTime(Math.round(progress))}
<> / </>
{formatTime(duration)}
</Typography>
);
};
export default Time;

@ -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<HTMLVideoElement | null>(null);
const audioRef = useRef<HTMLAudioElement>(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 (
<Box
sx={{
...sx,
maxWidth: "fit-content",
position: "relative"
}}
>
{error && (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}
>
{error}
</Box>
)}
<video
src={videoStream}
ref={videoRef}
style={{
height: "100%"
}}
autoPlay={playing == VideoStatus.Playing}
>
Your browser does not support video playback.
</video>
<Box
onClick={() => 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%"
}}
></Box>
<ProgressBar videoRef={videoRef} duration={duration} />
<Actions videoRef={videoRef} duration={duration} />
</Box>
);
};
export default Player;

@ -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<VideoModel> = (video) => {
thumbnail: authorThumbnail
} = useAuthorThumbnail(video.author.id, 100);
const router = useRouter();
return (
<Card sx={{ width: "100%" }}>
<CardActionArea
onClick={() =>
router.push({ pathname: "/watch", query: { v: video.id } })
}
>
<CardMedia
height="270"
component="img"
image={video.thumbnail}
alt="video thumbnail"
/>
<CardContent>
<Tooltip title={video.title}>
<Typography noWrap gutterBottom variant="h6" component="div">
{video.title}
</Typography>
</Tooltip>
<Link passHref href={`/channel/${video.author.id}`}>
<a>
<Box ref={ref} sx={{ display: "flex", alignItems: "center" }}>
{isLoading && <CircularProgress sx={{ mr: 2 }} />}
{!isLoading && (
<Avatar
sx={{ mr: 2 }}
alt={video.author.name}
src={authorThumbnail}
/>
)}
<Typography
color={theme.palette.text.secondary}
variant="subtitle1"
>
{video.author.name}
</Typography>
<Link href={{ pathname: "/watch", query: { v: video.id } }}>
<a>
<CardActionArea>
<Box sx={{ position: "relative" }}>
<CardMedia
height="270"
component="img"
image={video.thumbnail}
alt="video thumbnail"
/>
<Box
sx={{
p: 0.5,
borderRadius: "3px",
backgroundColor: "#000",
position: "absolute",
bottom: 10,
right: 10
}}
>
{formatTime(video.length)}
</Box>
</a>
</Link>
<Typography
sx={{ mt: 2 }}
color={theme.palette.text.secondary}
variant="body2"
>
{!(video.live || video.upcoming) && (
<>
{abbreviateNumber(video.views)} Views Published{" "}
{video.published.text}
</>
)}
</Typography>
</CardContent>
</CardActionArea>
</Box>
<CardContent>
<Tooltip title={video.title}>
<Typography noWrap gutterBottom variant="h6" component="div">
{video.title}
</Typography>
</Tooltip>
<Link passHref href={`/channel/${video.author.id}`}>
<a>
<Box ref={ref} sx={{ display: "flex", alignItems: "center" }}>
{isLoading && <CircularProgress sx={{ mr: 2 }} />}
{!isLoading && (
<Avatar
sx={{ mr: 2 }}
alt={video.author.name}
src={authorThumbnail}
/>
)}
<Typography
color={theme.palette.text.secondary}
variant="subtitle1"
>
{video.author.name}
</Typography>
</Box>
</a>
</Link>
<Typography
sx={{ mt: 2 }}
color={theme.palette.text.secondary}
variant="body2"
>
{!(video.live || video.upcoming) && (
<>
{abbreviateNumber(video.views)} Views Published{" "}
{video.published.text}
</>
)}
</Typography>
</CardContent>
</CardActionArea>
</a>
</Link>
</Card>
);
};

@ -7,6 +7,7 @@ interface Settings {
storageType: StorageType;
customServer?: string;
password?: string;
autoPlay: boolean;
}
export enum StorageType {

@ -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"
}

@ -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: {

@ -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";

@ -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";

@ -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<string | undefined>();
const [selectedCategory, setCategory] = useState("all");
const [settings] = useSettings();
const { isLoading, error, data } = useQuery<
const { isLoading, error, data, isFetching } = useQuery<
VideoTrending[],
AxiosError<Error>
>(
"trendingData",
["trendingData", selectedCategory],
() => fetchTrending(settings.invidiousServer, selectedCategory),
{
initialData: props.trending
@ -75,10 +76,8 @@ const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => {
{error && <Box>{error.response?.data.error}</Box>}
{!isLoading && !error && data && (
<>
<Box sx={{ my: 2 }}>
<Typography sx={{ display: "inline-block", mr: 1 }}>
Categories:
</Typography>
<Box sx={{ my: 2, display: "flex", alignItems: "center" }}>
<Typography sx={{ mr: 1 }}>Categories:</Typography>
{["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 && <CircularProgress size={25} />}
</Box>
<Grid videos={data.map(apiToVideo)} />
</>
@ -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
};
};

@ -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 = () => {
<>
<NextSeo title={data ? data.title : "Not Found"} />
<Layout>{}</Layout>
<Layout>
{data && (
<>
<Player
streams={data.formatStreams}
formats={data.adaptiveFormats}
captions={data.captions}
length={data.lengthSeconds}
videoId={data.videoId}
sx={{ height: "75vh", margin: "auto", mt: 2 }}
/>
<Container sx={{ pt: 2 }}>
<Typography variant="h4">{data.title}</Typography>
</Container>
</>
)}
</Layout>
</>
);
};

@ -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<SetStateAction<Settings>>
] => {
@ -24,3 +25,5 @@ export const useSettings = (): [
return [settings, setSettings];
};
export default useSettings;

@ -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<VideoState>((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;

@ -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) =>

@ -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 }[];

@ -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==

Loading…
Cancel
Save