diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5b1f4f..6b0e56b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,10 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@fontsource-variable/inter": "^5.1.1", "@fontsource-variable/quicksand": "^5.1.0", + "@fontsource-variable/rubik": "^5.1.1", + "@fontsource/ibm-plex-sans": "^5.1.1", "@mui/icons-material": "^5.15.15", "@mui/material": "^5.15.15", "axios": "^1.6.8", @@ -2572,11 +2575,29 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, + "node_modules/@fontsource-variable/inter": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.1.1.tgz", + "integrity": "sha512-OpXFTmiH6tHkYijMvQTycFKBLK4X+SRV6tet1m4YOUH7SzIIlMqDja+ocDtiCA72UthBH/vF+3ZtlMr2rN/wIw==", + "license": "OFL-1.1" + }, "node_modules/@fontsource-variable/quicksand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/quicksand/-/quicksand-5.1.0.tgz", "integrity": "sha512-0pJgaaRitvWvADcEpDjXTvPO/oO1OUJiZdggUPtOang8ytK+tf4rJAM/L/YsTiSgRZW1565GWIqkmGdJQ/V88A==" }, + "node_modules/@fontsource-variable/rubik": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.1.1.tgz", + "integrity": "sha512-Xh6Y39Xx1v0AIERdU+JsHiGx7I7WhN349jA/dFln8Ufud3c56yA0QS/aPdGMvyH/l+bXVmNV12cWF0hQm5ZPRw==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.1.1.tgz", + "integrity": "sha512-s6xHuHCYxZbIZV0Qchw+EoucPYWCP3PgLs9+oF3u1kLQKwabWaUC3Fm30y6n3VIMCqR89dpkcS8LTqH/IGTDDQ==", + "license": "OFL-1.1" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5d6ff05..97239c2 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,10 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@fontsource-variable/inter": "^5.1.1", "@fontsource-variable/quicksand": "^5.1.0", + "@fontsource-variable/rubik": "^5.1.1", + "@fontsource/ibm-plex-sans": "^5.1.1", "@mui/icons-material": "^5.15.15", "@mui/material": "^5.15.15", "axios": "^1.6.8", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0d6839..c1e39b6 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,32 +9,54 @@ import Login from "./pages/Login"; import Search from "./pages/Search"; import Home from "./pages/Home"; import Library from "./pages/Library"; +import BigReader from "./components/BigReader"; +import { useWatchListCache } from "./states/WatchListCache"; function App() { const location = useLocation(); const navigate = useNavigate(); useEffect(() => { - if(!localStorage.getItem("accessToken") && !location.pathname.startsWith("/login")) navigate("/login") + if ( + !localStorage.getItem("accessToken") && + !location.pathname.startsWith("/login") + ) + navigate("/login"); }, [location.pathname]); + useEffect(() => { + useWatchListCache.getState().loadWatchListCache(); + + const interval = setInterval(() => { + useWatchListCache.getState().loadWatchListCache(); + }, 60000); + + return () => clearInterval(interval); + }, []); + return ( <> + } /> } /> - + } /> } /> } /> } /> } /> - } /> + } + /> diff --git a/frontend/src/components/AppBar.tsx b/frontend/src/components/AppBar.tsx index ec282be..795f336 100755 --- a/frontend/src/components/AppBar.tsx +++ b/frontend/src/components/AppBar.tsx @@ -66,6 +66,7 @@ function Appbar() { transition: "all 0.2s ease-in-out", bgcolor: scrollAtTop ? "#00000000" : `#000000AA`, + backdropFilter: scrollAtTop ? "blur(0px)" : "blur(10px)", boxShadow: scrollAtTop ? "none" : "0px 0px 10px 0px #000000AA", borderBottomLeftRadius: "10px", @@ -229,6 +230,7 @@ function HeadLink({ color: "inherit", fontWeight: 500, transition: "all 0.2s ease-in-out", + fontFamily: '"Inter Variable", sans-serif', }} aria-current={active ? "page" : undefined} > diff --git a/frontend/src/components/BigReader.tsx b/frontend/src/components/BigReader.tsx new file mode 100644 index 0000000..d196876 --- /dev/null +++ b/frontend/src/components/BigReader.tsx @@ -0,0 +1,60 @@ +import { Backdrop, Box, Button, Typography } from "@mui/material"; +import React from "react"; +import { create } from "zustand"; + +interface BigReaderState { + bigReader: string | null; + setBigReader: (bigReader: string) => void; + closeBigReader: () => void; +} + +export const useBigReader = create((set) => ({ + bigReader: null, + setBigReader: (bigReader) => set({ bigReader }), + closeBigReader: () => set({ bigReader: null }), +})); + +function BigReader() { + const { bigReader, closeBigReader } = useBigReader(); + if (!bigReader) return null; + return ( + + e.stopPropagation()} + > + + {bigReader} + + + + + + ); +} + +export default BigReader; diff --git a/frontend/src/components/CenteredSpinner.tsx b/frontend/src/components/CenteredSpinner.tsx index ec78266..bd4e0ed 100644 --- a/frontend/src/components/CenteredSpinner.tsx +++ b/frontend/src/components/CenteredSpinner.tsx @@ -1,16 +1,18 @@ -import { Box, CircularProgress } from '@mui/material' -import React from 'react' +import { Box, CircularProgress } from "@mui/material"; +import React from "react"; export default function CenteredSpinner() { return ( - - + + - ) + ); } diff --git a/frontend/src/components/HeroDisplay.tsx b/frontend/src/components/HeroDisplay.tsx index 2ef572d..837d55d 100644 --- a/frontend/src/components/HeroDisplay.tsx +++ b/frontend/src/components/HeroDisplay.tsx @@ -1,178 +1,315 @@ -import { PlayArrow, InfoOutlined } from '@mui/icons-material'; -import { Box, Typography, Button } from '@mui/material'; -import React from 'react' -import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + PlayArrow, + InfoOutlined, + VolumeOff, + VolumeUp, + Pause, +} from "@mui/icons-material"; +import { Box, Typography, Button, IconButton } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { usePreviewPlayer } from "../states/PreviewPlayerState"; +import ReactPlayer from "react-player"; +import { useBigReader } from "./BigReader"; function HeroDisplay({ item }: { item: Plex.Metadata }) { - const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); - - return ( + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const { MetaScreenPlayerMuted, setMetaScreenPlayerMuted } = + usePreviewPlayer(); + + const previewVidURL = item?.Extras?.Metadata?.[0]?.Media?.[0]?.Part?.[0]?.key + ? `${localStorage.getItem("server")}${ + item?.Extras?.Metadata?.[0]?.Media?.[0]?.Part?.[0]?.key + }&X-Plex-Token=${localStorage.getItem("accessToken")}` + : null; + + const [previewVidPlaying, setPreviewVidPlaying] = useState(false); + + useEffect(() => { + setPreviewVidPlaying(false); + + if (!previewVidURL) return; + + const timeout = setTimeout(() => { + if (window.scrollY > 100) return; + if(searchParams.has("mid")) return; + if(document.location.href.includes("mid=")) return; + setPreviewVidPlaying(true); + }, 3000); + + const onScroll = () => { + if (window.scrollY > 100) setPreviewVidPlaying(false); + else setPreviewVidPlaying(true); + }; + + window.addEventListener("scroll", onScroll); + + return () => { + clearTimeout(timeout); + window.removeEventListener("scroll", onScroll); + }; + }, []); + + return ( + + + { + setPreviewVidPlaying(!previewVidPlaying); + }} + > + {previewVidPlaying ? : } + + + { + setMetaScreenPlayerMuted(!MetaScreenPlayerMuted); + }} + > + {MetaScreenPlayerMuted ? : } + + + + { + setPreviewVidPlaying(false); + }} + pip={false} + config={{ + file: { + attributes: { disablePictureInPicture: true }, + }, + }} + /> + + + - - - - {item.type} - - + /> - {item.title} + {item.type} - + + {item.title} + + { + useBigReader.getState().setBigReader(item.summary); + }} + > + {item.summary} + + + + + - - + More Info + - - ); - } + + + ); +} -export default HeroDisplay \ No newline at end of file +export default HeroDisplay; diff --git a/frontend/src/components/MetaScreen.tsx b/frontend/src/components/MetaScreen.tsx index 9102ad5..4e2bca8 100755 --- a/frontend/src/components/MetaScreen.tsx +++ b/frontend/src/components/MetaScreen.tsx @@ -21,9 +21,11 @@ import { getSimilar, getTranscodeImageURL, } from "../plex"; -import { useQuery } from "react-query"; +import { useQuery, UseQueryResult } from "react-query"; import { Add, + Bookmark, + BookmarkBorder, CheckCircle, Close, PlayArrow, @@ -35,6 +37,8 @@ import { durationToText } from "./MovieItemSlider"; import ReactPlayer from "react-player"; import { usePreviewPlayer } from "../states/PreviewPlayerState"; import MovieItem from "./MovieItem"; +import { useBigReader } from "./BigReader"; +import { useWatchListCache } from "../states/WatchListCache"; function MetaScreen() { const [searchParams, setSearchParams] = useSearchParams(); @@ -52,6 +56,8 @@ function MetaScreen() { async () => await getSimilar(searchParams.get("mid") as string) ); + const [page, setPage] = useState(0); + const [selectedSeason, setSelectedSeason] = useState(0); const [episodes, setEpisodes] = useState(); @@ -61,6 +67,8 @@ function MetaScreen() { const [previewVidURL, setPreviewVidURL] = useState(null); const [previewVidPlaying, setPreviewVidPlaying] = useState(false); + const WatchList = useWatchListCache(); + useEffect(() => { setEpisodes(null); setSelectedSeason(0); @@ -68,6 +76,7 @@ function MetaScreen() { setSubTitles(null); setPreviewVidURL(null); setPreviewVidPlaying(false); + setPage(0); }, [data?.ratingKey]); useEffect(() => { @@ -153,6 +162,7 @@ function MetaScreen() { } break; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.ratingKey, episodes]); useEffect(() => { @@ -167,6 +177,7 @@ function MetaScreen() { setEpisodes(res); }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSeason, data?.ratingKey]); if (!searchParams.has("mid")) return <>; @@ -552,7 +563,11 @@ function MetaScreen() { }} onClick={async () => { if (data?.type === "movie") - navigate(`/watch/${data?.ratingKey}${data?.viewOffset ? `?t=${data?.viewOffset}` : ""}`); + navigate( + `/watch/${data?.ratingKey}${ + data?.viewOffset ? `?t=${data?.viewOffset}` : "" + }` + ); if (data?.type === "show") { if (data?.OnDeck && data?.OnDeck.Metadata) { @@ -595,28 +610,20 @@ function MetaScreen() { "&:hover": { backgroundColor: "primary.main", }, - cursor: "not-allowed", }} - title="Not yet Implemented" - > - - - { + if (!data) return; + if(WatchList.isOnWatchList(data?.guid as string)) + WatchList.removeItem(data?.guid as string); + else + WatchList.addItem(data); }} - title="Not yet Implemented" > - + {WatchList.isOnWatchList(data?.guid as string) ? ( + + ) : ( + + )} @@ -730,8 +737,13 @@ function MetaScreen() { WebkitLineClamp: 5, WebkitBoxOrient: "vertical", maxInlineSize: "100%", + userSelect: "none", + cursor: "zoom-in", + }} + onClick={() => { + if(!data?.summary) return; + useBigReader.getState().setBigReader(data?.summary); }} - title={data?.summary} > {data?.summary} @@ -742,229 +754,78 @@ function MetaScreen() { - - Cast & Crew - - - - - - {data?.Role?.map((role) => ( - - - - - - - - {role.tag} - - - - {role.role} - - - - - - ))} - - - - - { + setPage(0); }} - > - - {data?.type === "movie" ? "Similar Movies" : "Episodes"} - - - {data?.type === "show" && - data?.Children && - data?.Children.size > 1 && ( - - )} - - - + selected={page === 0} + text={data?.type === "movie" ? "Similar Movies" : "Episodes"} + /> - {data?.type === "movie" && similar.status === "loading" && ( - { + setPage(1); }} - > - - + selected={page === 1} + text="Recommendations" + /> )} - {data?.type === "movie" && similar.status === "success" && ( - - {similar.data?.slice(0, 10).map((movie) => ( - - - - ))} - - )} - - {data?.type === "show" && !episodes && ( - - - - )} + { + setPage(2); + }} + selected={page === 2} + text="Info" + /> - {data?.type === "show" && episodes && ( - - {episodes?.map((episode) => ( - { - navigate( - `/watch/${episode.ratingKey}${ - episode.viewOffset ? `?t=${episode.viewOffset} ` : "" - }` - ); - }} - /> - ))} - - )} + {data?.type === "show" && + data?.Children && + data?.Children.size > 1 && ( + + )} + + + + {page === 0 && MetaPage1(data, similar, episodes, navigate)} @@ -973,6 +834,85 @@ function MetaScreen() { export default MetaScreen; +function MetaPage1( + data: Plex.Metadata | undefined, + similar: UseQueryResult, + episodes: Plex.Metadata[] | null | undefined, + navigate: (path: string) => void +) { + return ( + <> + {data?.type === "movie" && similar.status === "loading" && ( + + + + )} + + {data?.type === "movie" && similar.status === "success" && ( + + {similar.data?.slice(0, 10).map((movie) => ( + + + + ))} + + )} + + {data?.type === "show" && !episodes && ( + + + + )} + + {data?.type === "show" && episodes && ( + + {episodes?.map((episode) => ( + { + navigate( + `/watch/${episode.ratingKey}${ + episode.viewOffset ? `?t=${episode.viewOffset} ` : "" + }` + ); + }} + /> + ))} + + )} + + ); +} + function EpisodeItem({ item, onClick, @@ -1029,7 +969,7 @@ function EpisodeItem({ void; + selected: boolean; +}) { + return ( + + {text} + + ); +} + /** * Calculates the number of minutes from a given duration in milliseconds. * diff --git a/frontend/src/components/MovieItem.tsx b/frontend/src/components/MovieItem.tsx index 7dd8572..e526620 100644 --- a/frontend/src/components/MovieItem.tsx +++ b/frontend/src/components/MovieItem.tsx @@ -1,34 +1,115 @@ -import { CheckCircle, PlayArrow, InfoOutlined } from '@mui/icons-material'; -import { Box, Typography, Tooltip, Button, CircularProgress } from '@mui/material'; -import React from 'react' -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { getTranscodeImageURL, getLibraryMeta, getLibraryMetaChildren } from '../plex'; -import { durationToText } from './MovieItemSlider'; +import { + CheckCircle, + PlayArrow, + InfoOutlined, + BookmarkBorder, + CheckCircleOutline, + Bookmark, +} from "@mui/icons-material"; +import { + Box, + Typography, + Tooltip, + Button, + CircularProgress, + LinearProgress, +} from "@mui/material"; +import React from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { + getTranscodeImageURL, + getLibraryMeta, + getLibraryMetaChildren, + getItemByGUID, +} from "../plex"; +import { durationToText } from "./MovieItemSlider"; +import { useWatchListCache } from "../states/WatchListCache"; +import { useBigReader } from "./BigReader"; function MovieItem({ - item, - itemsPerPage, - }: { - item: Plex.Metadata; - itemsPerPage?: number; - }): JSX.Element { - const [, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); - - const [playButtonLoading, setPlayButtonLoading] = React.useState(false); - - // 300 x 170 - return ( + item, + itemsPerPage, + index, + PlexTvSource, +}: { + item: Plex.Metadata; + itemsPerPage?: number; + index?: number; + PlexTvSource?: boolean; +}): JSX.Element { + const [, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [playButtonLoading, setPlayButtonLoading] = React.useState(false); + + // 300 x 170 + return ( + :nth-child(${ + item.type === "episode" || (item.type === "movie" && item.viewOffset) + ? 4 + : 3 + })`]: { + height: "32px", + }, + + // "&:hover > :nth-child(3)": { + // opacity: 1, + // transition: "all 0.25s ease-in", + // }, + + transition: "all 0.2s ease, transform 0.5s ease", + cursor: "pointer", + }} + // onClick={() => { + // if (["episode"].includes(item.type)) + // return navigate( + // `/watch/${item.ratingKey}${ + // item.viewOffset ? `?t=${item.viewOffset}` : "" + // }` + // ); + // setSearchParams({ mid: item.ratingKey.toString() }); + // }} + > :nth-child(2)": { - height: "32px", - }, - - "&:hover > :nth-child(3)": { - opacity: 1, - transition: "all 0.25s ease-in", - }, - - transition: "all 0.1s ease", - cursor: "pointer", }} - // onClick={() => { - // if (["episode"].includes(item.type)) - // return navigate( - // `/watch/${item.ratingKey}${ - // item.viewOffset ? `?t=${item.viewOffset}` : "" - // }` - // ); - // setSearchParams({ mid: item.ratingKey.toString() }); - // }} > - + {((item.type === "show" && item.leafCount === item.viewedLeafCount) || + (item.type === "movie" && item?.viewCount && item.viewCount > 0)) && ( - - - {item.type} - + + + + )} + + {(item.type === "episode" || + (item.type === "movie" && item.viewOffset)) && ( + + )} + + + {item.type} {item.type === "episode" && item.index} + + + + {item.title} + + + {["episode"].includes(item.type) && item.grandparentTitle && ( { + e.stopPropagation(); + if (!item.grandparentKey?.toString()) return; + setSearchParams({ + mid: (item.grandparentRatingKey as string).toString(), + }); + }} sx={{ - fontSize: "1.5rem", - fontWeight: "bold", + fontSize: "1rem", + fontWeight: "normal", color: "#FFFFFF", - textShadow: "0px 0px 10px #000000", - "@media (max-width: 2000px)": { - fontSize: "1.2rem", + opacity: 0.7, + mt: "4px", + mb: 0.5, + + transition: "all 0.5s ease", + "&:hover": { + opacity: 1, }, + textOverflow: "ellipsis", overflow: "hidden", maxLines: 1, maxInlineSize: "100%", }} > - {item.title} + {item.grandparentTitle} - {["episode"].includes(item.type) && item.grandparentTitle && ( + )} + + {/* {item.rating && ( { - e.stopPropagation(); - if (!item.grandparentKey?.toString()) return; - setSearchParams({ - mid: (item.grandparentRatingKey as string).toString(), - }); + sx={{ + fontSize: "medium", + fontWeight: "light", + color: "#FFFFFF", + textShadow: "0px 0px 10px #000000", + ml: 1, }} + > + {item.rating} + + )} + {item.contentRating && ( + + {item.contentRating} + + )} */} + {/* {item.type === "episode" && item.index && ( + - {item.grandparentTitle} + S{item.parentIndex} E{item.index} + + )} */} + {item.duration && ["episode", "movie"].includes(item.type) && ( + + {durationToText(item.duration)} + + )} + {item.type === "show" && item.leafCount && item.childCount && ( + + {item.childCount > 1 + ? `${item.childCount} Seasons` + : `${item.leafCount} Episode${item.leafCount > 1 ? "s" : ""}`} + + )} + + {item.year && ( + + {item.year} )} - - {item.tagline} - - - - {item.type === "show" && item.leafCount === item.viewedLeafCount && ( - - - - )} - {item.type === "movie" && item?.viewCount && item.viewCount > 0 && ( - - - - )} - {item.year && ( - - {item.year} - - )} - {item.rating && ( - - {item.rating} - - )} - {item.contentRating && ( - - {item.contentRating} - - )} - {item.type === "episode" && item.index && ( - - S{item.parentIndex} E{item.index} - - )} - {item.duration && ["episode", "movie"].includes(item.type) && ( - - {durationToText(item.duration)} - - )} - {item.type === "show" && item.leafCount && item.childCount && ( - - {item.childCount > 1 - ? `${item.childCount} Seasons` - : `${item.leafCount} Episode${item.leafCount > 1 ? "s" : ""}`} - - )} - - + + + - - + + - - - - {/* */} - + } + }} + > + + + + - {/* + {/* */} - - ); - } + + ); +} + +export default MovieItem; + +function WatchListButton({ item }: { item: Plex.Metadata }) { + const WatchList = useWatchListCache(); + + return ( + + ); +} diff --git a/frontend/src/components/MovieItemLegacy.tsx b/frontend/src/components/MovieItemLegacy.tsx new file mode 100644 index 0000000..00bd7c9 --- /dev/null +++ b/frontend/src/components/MovieItemLegacy.tsx @@ -0,0 +1,534 @@ +import { CheckCircle, PlayArrow, InfoOutlined, BookmarkBorder } from "@mui/icons-material"; +import { + Box, + Typography, + Tooltip, + Button, + CircularProgress, +} from "@mui/material"; +import React from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { + getTranscodeImageURL, + getLibraryMeta, + getLibraryMetaChildren, +} from "../plex"; +import { durationToText } from "./MovieItemSlider"; + +function MovieItemLegacy({ + item, + itemsPerPage, +}: { + item: Plex.Metadata; + itemsPerPage?: number; +}): JSX.Element { + const [, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [playButtonLoading, setPlayButtonLoading] = React.useState(false); + + // 300 x 170 + return ( + :nth-child(2)": { + height: "32px", + }, + + "&:hover > :nth-child(3)": { + opacity: 1, + transition: "all 0.25s ease-in", + }, + + transition: "all 0.1s ease", + cursor: "pointer", + }} + // onClick={() => { + // if (["episode"].includes(item.type)) + // return navigate( + // `/watch/${item.ratingKey}${ + // item.viewOffset ? `?t=${item.viewOffset}` : "" + // }` + // ); + // setSearchParams({ mid: item.ratingKey.toString() }); + // }} + > + + + + + {item.type} + + + + {item.title} + + {["episode"].includes(item.type) && item.grandparentTitle && ( + { + e.stopPropagation(); + if (!item.grandparentKey?.toString()) return; + setSearchParams({ + mid: (item.grandparentRatingKey as string).toString(), + }); + }} + sx={{ + fontSize: "1rem", + fontWeight: "normal", + color: "#FFFFFF", + opacity: 0.7, + mt: -0.5, + mb: 0.5, + + transition: "all 0.5s ease", + "&:hover": { + opacity: 1, + }, + + textOverflow: "ellipsis", + overflow: "hidden", + maxLines: 1, + maxInlineSize: "100%", + }} + > + {item.grandparentTitle} + + )} + + {item.tagline} + + + + {item.type === "show" && item.leafCount === item.viewedLeafCount && ( + + + + )} + {item.type === "movie" && item?.viewCount && item.viewCount > 0 && ( + + + + )} + {item.year && ( + + {item.year} + + )} + {item.rating && ( + + {item.rating} + + )} + {item.contentRating && ( + + {item.contentRating} + + )} + {item.type === "episode" && item.index && ( + + S{item.parentIndex} E{item.index} + + )} + {item.duration && ["episode", "movie"].includes(item.type) && ( + + {durationToText(item.duration)} + + )} + {item.type === "show" && item.leafCount && item.childCount && ( + + {item.childCount > 1 + ? `${item.childCount} Seasons` + : `${item.leafCount} Episode${item.leafCount > 1 ? "s" : ""}`} + + )} + + + + + + + + + + + + + {/* */} + + ); +} + +export default MovieItemLegacy; diff --git a/frontend/src/components/MovieItemSlider.tsx b/frontend/src/components/MovieItemSlider.tsx index 3bb1455..149b5fe 100755 --- a/frontend/src/components/MovieItemSlider.tsx +++ b/frontend/src/components/MovieItemSlider.tsx @@ -1,44 +1,41 @@ -import { - Box, - Typography, -} from "@mui/material"; +import { Box, Typography } from "@mui/material"; import React from "react"; -import { - getLibraryMedia, -} from "../plex"; -import { - ArrowBackIos, - ArrowForwardIos, -} from "@mui/icons-material"; +import { getLibraryMedia } from "../plex"; +import { ArrowForwardIos } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import MovieItem from "./MovieItem"; function MovieItemSlider({ title, - libraryID, dir, link, shuffle, + data, + plexTvSource, }: { title: string; - libraryID: string; - dir: string; - link: string; + dir?: string; + link?: string; shuffle?: boolean; + data?: Plex.Metadata[]; + plexTvSource?: boolean; }) { const navigate = useNavigate(); - const [items, setItems] = React.useState(null); + const [items, setItems] = React.useState( + data ?? null + ); const [currPage, setCurrPage] = React.useState(0); const calculateItemsPerPage = (width: number) => { if (width < 400) return 1; - if (width < 600) return 2; - if (width < 1200) return 3; + if (width < 600) return 1; + if (width < 1200) return 2; if (width < 1500) return 4; if (width < 2000) return 5; if (width < 3000) return 6; if (width < 4000) return 7; + if (width < 5000) return 8; return 6; }; @@ -55,18 +52,24 @@ function MovieItemSlider({ }, []); React.useEffect(() => { - getLibraryMedia(libraryID, dir).then((media) => { + if (data) { + setItems(data); + return; + } + + if(!dir) return; + + getLibraryMedia(dir).then((media) => { // cut the array down so its a multiple of itemsPerPage if (!media) return; - const roundedMedia = media.slice(0, itemsPerPage * 5); - console.log(roundedMedia.length); - - setItems(shuffle ? shuffleArray(roundedMedia) : roundedMedia); + setItems(shuffle ? shuffleArray(media) : media); }); - }, [dir, itemsPerPage, libraryID, shuffle]); + }, [data, dir, shuffle]); if (!items) return <>; + const itemCount = items.slice(0, itemsPerPage * 5).length; + return ( { - navigate(link); + if (link) navigate(link); }} > - - Browse - - + }} + > + Browse + + + )} itemsPerPage ? "visible" : "hidden", + alignItems: "flex-start", + justifyContent: "flex-start", + visibility: itemCount > itemsPerPage ? "visible" : "hidden", }} > - {Array(Math.ceil(items.length / itemsPerPage)) + {Array(Math.ceil(itemCount / itemsPerPage)) .fill(0) .map((_, i) => { return ( @@ -199,7 +204,7 @@ function MovieItemSlider({ alignItems: "center", justifyContent: "center", cursor: "pointer", - visibility: items.length > itemsPerPage ? "visible" : "hidden", + visibility: itemCount > itemsPerPage ? "visible" : "hidden", "&:hover": { backgroundColor: "#000000AA", @@ -210,27 +215,34 @@ function MovieItemSlider({ onClick={() => { setCurrPage((currPage) => currPage - 1 < 0 - ? Math.ceil(items.length / itemsPerPage) - 1 + ? Math.ceil(itemCount / itemsPerPage) - 1 : currPage - 1 ); }} > - + - {items?.map((item) => { - return ; + {items?.slice(0, itemsPerPage * 5).map((item, i) => { + return ( + + ); })} itemsPerPage ? "visible" : "hidden", + visibility: itemCount > itemsPerPage ? "visible" : "hidden", "&:hover": { backgroundColor: "#000000AA", @@ -255,7 +267,7 @@ function MovieItemSlider({ }} onClick={() => { setCurrPage( - currPage + 1 > Math.ceil(items.length / itemsPerPage) - 1 + currPage + 1 > Math.ceil(itemCount / itemsPerPage) - 1 ? 0 : currPage + 1 ); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0db6c9a..af92b6a 100755 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -11,6 +11,9 @@ import axios from "axios"; import { getBackendURL } from "./backendURL"; import "@fontsource-variable/quicksand"; +import '@fontsource-variable/rubik'; +import '@fontsource/ibm-plex-sans'; +import '@fontsource-variable/inter'; if(!localStorage.getItem("clientID")) localStorage.setItem("clientID", makeid(24)); @@ -65,7 +68,7 @@ ReactDOM.render( }, }, typography: { - fontFamily: '"Quicksand Variable", sans-serif', + fontFamily: '"Inter Variable", sans-serif', }, components: { MuiAppBar: { @@ -78,11 +81,18 @@ ReactDOM.render( MuiButton: { styleOverrides: { root: { - fontFamily: '"Quicksand Variable", sans-serif', + fontFamily: '"Inter Variable", sans-serif', borderRadius: "7px", }, }, }, + MuiBackdrop: { + styleOverrides: { + root: { + height: "100vh", + }, + }, + } } })} > diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index e62a4fc..5f0668b 100755 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,10 +1,16 @@ import { Avatar, Box, CircularProgress, Grid, Typography } from "@mui/material"; import React, { useEffect } from "react"; -import { getAllLibraries, getLibraryMedia, getLibrarySecondary } from "../plex"; +import { + getAllLibraries, + getLibraryMedia, + getLibraryMeta, + getLibrarySecondary, +} from "../plex"; import { useNavigate } from "react-router-dom"; import { shuffleArray } from "../common/ArrayExtra"; import MovieItemSlider from "../components/MovieItemSlider"; import HeroDisplay from "../components/HeroDisplay"; +import { useWatchListCache } from "../states/WatchListCache"; export default function Home() { const [libraries, setLibraries] = React.useState([]); @@ -14,6 +20,8 @@ export default function Home() { const [randomItem, setRandomItem] = React.useState( null ); + const { watchListCache } = useWatchListCache(); + const [loading, setLoading] = React.useState(true); useEffect(() => { @@ -36,7 +44,11 @@ export default function Home() { randomItemData = await getRandomItem(filteredLibraries); attempts++; } - setRandomItem(randomItemData); + + if (!randomItemData) return; + + const data = await getLibraryMeta(randomItemData?.ratingKey as string); + setRandomItem(data); } catch (error) { console.error("Error fetching data", error); } finally { @@ -159,13 +171,22 @@ export default function Home() { gap: 8, }} > + + + {watchListCache && watchListCache.length > 0 && ( + + )} + {featured && featured.map((item, index) => ( @@ -213,8 +234,9 @@ async function getRandomItem(libraries: Plex.Directory[]) { const dirs = await getLibrarySecondary(library.key, "genre"); const items = await getLibraryMedia( - library.key, - `all?genre=${dirs[Math.floor(Math.random() * dirs.length)].key}` + `/sections/${library.key}/all?genre=${ + dirs[Math.floor(Math.random() * dirs.length)].key + }` ); return items[Math.floor(Math.random() * items.length)]; diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index 813d66f..f141d62 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -28,7 +28,7 @@ export default function Library() { height: "fit-content", mt: "64px", px: 6, - + pt: 4, pb: 2, }} @@ -45,10 +45,16 @@ export default function Library() { {results && results.data.Metadata.map((item) => ( - - + + ))} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 8426c69..075656f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,4 +1,10 @@ -import { Alert, Box, CircularProgress, Collapse, Typography } from "@mui/material"; +import { + Alert, + Box, + CircularProgress, + Collapse, + Typography, +} from "@mui/material"; import React, { useEffect } from "react"; import { queryBuilder } from "../plex/QuickFunctions"; import { useSearchParams } from "react-router-dom"; @@ -31,58 +37,71 @@ export default function Login() { try { const res = await getAccessToken(query.get("pinID") as string); - if (!res.authToken) return setError("Failed to log in. Please try again."); - + if (!res.authToken) + return setError("Failed to log in. Please try again."); + console.log("1", res); - + // check token validity against the server // const tokenCheck = await ProxiedRequest(`/?${queryBuilder({ "X-Plex-Token": res.authToken })}`, "GET", {}) - + // if (tokenCheck.status === 200) { // localStorage.setItem("accessToken", res.authToken); // localStorage.setItem("accAccessToken", res.authToken); // window.location.href = "/"; // } - + // console.log("2", tokenCheck); - + const serverIdentity = await ProxiedRequest("/identity", "GET", { "X-Plex-Token": res.authToken, }); - + console.log("3", serverIdentity); - - if(!serverIdentity || !serverIdentity.data.MediaContainer) return setError(`Failed to log in: ${serverIdentity.data.errors[0].message || "Unknown error"}`); - + + if (!serverIdentity || !serverIdentity.data.MediaContainer) + return setError( + `Failed to log in: ${ + serverIdentity.data.errors[0].message || "Unknown error" + }` + ); + const serverID = serverIdentity.data.MediaContainer.machineIdentifier; - + const parser = new XMLParser({ attributeNamePrefix: "", textNodeName: "value", ignoreAttributes: false, parseAttributeValue: true, }); - + // try getting a shared server - const sharedServersXML = await axios.get(`https://plex.tv/api/resources?${queryBuilder({ "X-Plex-Token": res.authToken })}`); + const sharedServersXML = await axios.get( + `https://plex.tv/api/resources?${queryBuilder({ + "X-Plex-Token": res.authToken, + })}` + ); - - const sharedServers = parser.parse(sharedServersXML.data) + const sharedServers = parser.parse(sharedServersXML.data); console.log("4", sharedServers); - + let targetServer; - if(sharedServers.MediaContainer.size === 1) targetServer = sharedServers.MediaContainer.Device; - else targetServer = sharedServers.MediaContainer.Device.find((server: any) => server.clientIdentifier === serverID); - - if (!targetServer) return setError("You do not have access to this server."); - + if (sharedServers.MediaContainer.size === 1) + targetServer = sharedServers.MediaContainer.Device; + else + targetServer = sharedServers.MediaContainer.Device.find( + (server: any) => server.clientIdentifier === serverID + ); + + if (!targetServer) + return setError("You do not have access to this server."); + localStorage.setItem("accessToken", targetServer.accessToken); localStorage.setItem("accAccessToken", res.authToken); - + window.location.href = "/"; - } - catch (e) { + } catch (e) { console.log(e); setError("Failed to log in. Please try again."); } @@ -101,9 +120,7 @@ export default function Login() { }} > - - {error} - + {error} {!error && ( <> diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index a74ade4..004372d 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -1,12 +1,11 @@ import { Box, CircularProgress, Grid, Typography } from "@mui/material"; import React, { useEffect, useState } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { getSearch } from "../plex"; import MovieItem from "../components/MovieItem"; export default function Search() { const { query } = useParams(); - const [, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [results, setResults] = useState(null); @@ -38,7 +37,8 @@ export default function Search() { .filter((item) => item.Directory) .map((item) => item.Directory) .filter( - (directory): directory is Plex.Directory => directory !== undefined + (directory): directory is Plex.Directory => + directory !== undefined ) ); }); @@ -88,7 +88,9 @@ export default function Search() { { - navigate(`/library/${item.librarySectionID}/dir/genre/${item.id}`); + navigate( + `/library/${item.librarySectionID}/dir/genre/${item.id}` + ); }} /> @@ -100,10 +102,16 @@ export default function Search() { {results && results.map((item) => ( - - + + ))} @@ -111,8 +119,13 @@ export default function Search() { ); } - -export function DirectoryItem({ item, onClick }: { item: Plex.Directory, onClick: () => void }) { +export function DirectoryItem({ + item, + onClick, +}: { + item: Plex.Directory; + onClick: () => void; +}) { return ( - {item.librarySectionTitle} - {item.tag} + + {item.librarySectionTitle} - {item.tag} + ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Watch.tsx b/frontend/src/pages/Watch.tsx index 3558bf9..cc7993e 100755 --- a/frontend/src/pages/Watch.tsx +++ b/frontend/src/pages/Watch.tsx @@ -1384,7 +1384,11 @@ function Watch() { player.current?.seekTo(value / 1000); }} getPreviewScreenUrl={(value) => { - if (!metadata.Media || !metadata.Media[0].Part[0].indexes) return ""; + if ( + !metadata.Media || + !metadata.Media[0].Part[0].indexes + ) + return ""; return `${localStorage.getItem( "server" )}/photo/:/transcode?${queryBuilder({ diff --git a/frontend/src/pages/browse/Movie.tsx b/frontend/src/pages/browse/Movie.tsx index 8bef872..3232f5a 100755 --- a/frontend/src/pages/browse/Movie.tsx +++ b/frontend/src/pages/browse/Movie.tsx @@ -1,14 +1,15 @@ import React from "react"; -import { getLibraryMedia, getLibrarySecondary } from "../../plex"; -import { Box, Button, CircularProgress, Typography } from "@mui/material"; -import { InfoOutlined, PlayArrow } from "@mui/icons-material"; -import MovieItemSlider, { shuffleArray } from "../../components/MovieItemSlider"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { getLibraryMedia, getLibraryMeta, getLibrarySecondary } from "../../plex"; +import { Box, CircularProgress } from "@mui/material"; +import MovieItemSlider, { + shuffleArray, +} from "../../components/MovieItemSlider"; import HeroDisplay from "../../components/HeroDisplay"; function Movie({ Library }: { Library: Plex.LibraryDetails }) { - const [featuredItem, setFeaturedItem] = - React.useState(null); + const [featuredItem, setFeaturedItem] = React.useState( + null + ); const [genres, setGenres] = React.useState([]); @@ -16,9 +17,15 @@ function Movie({ Library }: { Library: Plex.LibraryDetails }) { setFeaturedItem(null); setGenres([]); - getLibraryMedia(Library.librarySectionID.toString(), "unwatched").then( + getLibraryMedia(`/sections/${Library.librarySectionID.toString()}/unwatched`).then( (media) => { - setFeaturedItem(media[Math.floor(Math.random() * media.length)]); + const item = media[Math.floor(Math.random() * media.length)]; + + getLibraryMeta(item.ratingKey).then( + (meta) => { + setFeaturedItem(meta); + } + ); } ); @@ -81,20 +88,17 @@ function Movie({ Library }: { Library: Plex.LibraryDetails }) { > {genres && @@ -102,8 +106,7 @@ function Movie({ Library }: { Library: Plex.LibraryDetails }) { @@ -113,4 +116,4 @@ function Movie({ Library }: { Library: Plex.LibraryDetails }) { ); } -export default Movie; \ No newline at end of file +export default Movie; diff --git a/frontend/src/pages/browse/Show.tsx b/frontend/src/pages/browse/Show.tsx index 5f63a20..8c1d40e 100755 --- a/frontend/src/pages/browse/Show.tsx +++ b/frontend/src/pages/browse/Show.tsx @@ -1,15 +1,9 @@ import React from "react"; -import { - getLibraryMedia, - getLibraryMeta, - getLibrarySecondary, -} from "../../plex"; -import { Box, Button, CircularProgress, Typography } from "@mui/material"; -import { InfoOutlined, PlayArrow } from "@mui/icons-material"; +import { getLibraryMedia, getLibraryMeta, getLibrarySecondary } from "../../plex"; +import { Box, CircularProgress } from "@mui/material"; import MovieItemSlider, { shuffleArray, } from "../../components/MovieItemSlider"; -import { useNavigate, useSearchParams } from "react-router-dom"; import HeroDisplay from "../../components/HeroDisplay"; function Show({ Library }: { Library: Plex.LibraryDetails }) { @@ -23,9 +17,15 @@ function Show({ Library }: { Library: Plex.LibraryDetails }) { setFeaturedItem(null); setGenres([]); - getLibraryMedia(Library.librarySectionID.toString(), "unwatched").then( + getLibraryMedia(`/sections/${Library.librarySectionID.toString()}/unwatched`).then( (media) => { - setFeaturedItem(media[Math.floor(Math.random() * media.length)]); + const item = media[Math.floor(Math.random() * media.length)]; + + getLibraryMeta(item.ratingKey).then( + (meta) => { + setFeaturedItem(meta); + } + ); } ); @@ -88,8 +88,7 @@ function Show({ Library }: { Library: Plex.LibraryDetails }) { > {genres && @@ -97,8 +96,7 @@ function Show({ Library }: { Library: Plex.LibraryDetails }) { diff --git a/frontend/src/plex/QuickFunctions.ts b/frontend/src/plex/QuickFunctions.ts index 1ce018b..d3437ef 100755 --- a/frontend/src/plex/QuickFunctions.ts +++ b/frontend/src/plex/QuickFunctions.ts @@ -84,7 +84,8 @@ export function getIncludeProps() { includeConcerts: 1, includeReviews: 1, includePreferences: 1, - includeStations: 1 + includeStations: 1, + includeRelated: 1, } } diff --git a/frontend/src/plex/index.ts b/frontend/src/plex/index.ts index 19cdddb..9155f40 100755 --- a/frontend/src/plex/index.ts +++ b/frontend/src/plex/index.ts @@ -14,8 +14,8 @@ export async function getLibrary(key: string): Promise { return res.MediaContainer; } -export async function getLibraryMedia(key: string, directory: string): Promise { - const res = await authedGet(`/library/sections/${key}/${directory}`); +export async function getLibraryMedia(path: string): Promise { + const res = await authedGet(`/library${path}`); return res.MediaContainer.Metadata; } @@ -25,6 +25,7 @@ export async function getLibrarySecondary(key: string, directory: string): Promi } export async function getLibraryMeta(id: string): Promise { + if(!id) return {} as Plex.Metadata; const res = await authedGet(`/library/metadata/${id}?${queryBuilder({ ...getIncludeProps(), ...getXPlexProps() @@ -206,4 +207,19 @@ export async function getLibraryDir(library: number, directory: string, subDir?: library: res.MediaContainer.title1, Metadata: res.MediaContainer.Metadata } +} + +export async function getItemByGUID(guid: string): Promise { + const res = await authedGet(`/library/all?${queryBuilder({ + guid, + "includeExternalMedia": 1, + "includeMeta": 1, + "includeMarkerCounts": 1, + "includeRelated": 1, + "X-Plex-Token": localStorage.getItem("accessToken") as string + })}`); + + if(res.MediaContainer.Metadata?.[0]?.guid !== guid) return null; + + return res.MediaContainer.Metadata?.[0]; } \ No newline at end of file diff --git a/frontend/src/plex/plex.d.ts b/frontend/src/plex/plex.d.ts index 8c11ea2..f0ce420 100755 --- a/frontend/src/plex/plex.d.ts +++ b/frontend/src/plex/plex.d.ts @@ -239,6 +239,33 @@ declare namespace Plex { size: number; Metadata: Metadata[]; } + Image?: { + alt: string; + type: string; + url: string; + }[] + UltraBlurColors?: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + } + Related?: { + Hub?: Hub[]; + } + } + + interface Hub { + hubKey: string; + key: string; + title: string; + type: LibaryType; + hubIdentifier: string; + context: string; + size: number; + more: boolean; + style: "shelf"; + Metadata: Metadata[]; } interface Chapter { diff --git a/frontend/src/plex/plextv.ts b/frontend/src/plex/plextv.ts new file mode 100644 index 0000000..4e518ba --- /dev/null +++ b/frontend/src/plex/plextv.ts @@ -0,0 +1,45 @@ +import axios from "axios"; +import { queryBuilder } from "./QuickFunctions"; + +export namespace PlexTv { + /** + * Adds a given item to the watchlist. + * + * @param {string} ratingKey - The rating key from the guid. + * @returns {Promise} - A promise that resolves when the item has been successfully added to the watchlist. + * + * @throws Will log an error message to the console if the request fails. + */ + export async function addToWatchlist(ratingKey: string): Promise { + try { + await axios.put(`https://discover.provider.plex.tv/actions/addToWatchlist?ratingKey=${ratingKey}`, {}, { + headers: { + "X-Plex-Token": localStorage.getItem("accAccessToken") as string + } + }) + } catch (error) { + console.error("Error adding to watchlist", error); + } + } + + export async function removeFromWatchlist(ratingKey: string): Promise { + try { + await axios.put(`https://discover.provider.plex.tv/actions/removeFromWatchlist?ratingKey=${ratingKey}`, {}, { + headers: { + "X-Plex-Token": localStorage.getItem("accAccessToken") as string + } + }) + } catch (error) { + console.error("Error removing from watchlist", error); + } + } + + export async function getWatchlist(): Promise { + const res = await axios.get(`https://discover.provider.plex.tv/library/sections/watchlist/all?${queryBuilder({ + "X-Plex-Token": localStorage.getItem("accAccessToken") as string, + "includeAdvanced": 1, + "includeMeta": 1, + })}`) + return res.data.MediaContainer.Metadata; + } +} \ No newline at end of file diff --git a/frontend/src/states/WatchListCache.ts b/frontend/src/states/WatchListCache.ts new file mode 100644 index 0000000..a22834b --- /dev/null +++ b/frontend/src/states/WatchListCache.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { PlexTv } from "../plex/plextv"; + +interface WatchListCacheState { + watchListCache: Plex.Metadata[]; + setWatchListCache: (watchListCache: Plex.Metadata[]) => void; + addItem: (item: Plex.Metadata) => void; + removeItem: (item: string) => void; + loadWatchListCache: () => void; + isOnWatchList: (item: string) => boolean; +} + +export const useWatchListCache = create((set) => ({ + watchListCache: [], + setWatchListCache: (watchListCache) => set({ watchListCache }), + addItem: async (item) => { + if (useWatchListCache.getState().watchListCache.includes(item)) return; + await PlexTv.addToWatchlist(item.guid.split("/")[3]); + set((state) => ({ watchListCache: [item, ...state.watchListCache] })) + }, + removeItem: async (item) => { + if (!useWatchListCache.getState().isOnWatchList(item)) return; + await PlexTv.removeFromWatchlist(item.split("/")[3]); + set((state) => ({ watchListCache: state.watchListCache.filter((i) => i.guid !== item) })); + }, + loadWatchListCache: async () => { + const watchList = await PlexTv.getWatchlist(); + set({ watchListCache: watchList }); + }, + isOnWatchList: (item): boolean => useWatchListCache.getState().watchListCache.find((i) => i.guid === item) !== undefined, +})); \ No newline at end of file