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}
+
+
+
+
-
- Play
+
+
+ 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" : ""}`}
-
- )}
-
-
+
+
+
-
- {
- setPlayButtonLoading(true);
-
- switch (item.type) {
- case "movie":
- case "episode":
- navigate(
- `/watch/${item.ratingKey}${
- item.viewOffset ? `?t=${item.viewOffset}` : ""
- }`
+ disabled={playButtonLoading}
+ onClick={async (e) => {
+ if(!item) return;
+ setPlayButtonLoading(true);
+
+ let PlexTvSrcData: Plex.Metadata | null = null;
+ if (PlexTvSource) {
+ PlexTvSrcData = await getItemByGUID(item.guid);
+
+ if (!PlexTvSrcData) {
+ useBigReader
+ .getState()
+ .setBigReader(
+ `"${item.title}" is not available on this Plex Server`
);
-
- setPlayButtonLoading(false);
- break;
- case "show":
- {
- const data = await getLibraryMeta(item.ratingKey);
-
- if (!data) {
- setPlayButtonLoading(false);
- return;
- }
-
- if (data.OnDeck?.Metadata) {
- navigate(
- `/watch/${data.OnDeck.Metadata.ratingKey}${
- data.OnDeck.Metadata.viewOffset
- ? `?t=${data.OnDeck.Metadata.viewOffset}`
- : ""
- }`
- );
-
- setPlayButtonLoading(false);
- return;
- } else {
- if (
- data.Children?.size === 0 ||
- !data.Children?.Metadata[0]
- )
- return setPlayButtonLoading(false);
- // play first episode
- const episodes = await getLibraryMetaChildren(
- data.Children?.Metadata[0].ratingKey
- );
- if (episodes?.length === 0)
- return setPlayButtonLoading(false);
-
- navigate(`/watch/${episodes[0].ratingKey}`);
- }
+ return;
+ }
+ }
+
+ if(PlexTvSource && !PlexTvSrcData) return;
+
+ let localItem = PlexTvSource ? PlexTvSrcData as Plex.Metadata : item;
+
+ switch (item.type) {
+ case "movie":
+ case "episode":
+ navigate(
+ `/watch/${localItem.ratingKey}${
+ localItem.viewOffset ? `?t=${localItem.viewOffset}` : ""
+ }`
+ );
+
+ setPlayButtonLoading(false);
+ break;
+ case "show":
+ {
+ const data = await getLibraryMeta(localItem.ratingKey);
+
+ if (!data) {
+ setPlayButtonLoading(false);
+ return;
}
- break;
+
+ if (data.OnDeck?.Metadata) {
+ navigate(
+ `/watch/${data.OnDeck.Metadata.ratingKey}${
+ data.OnDeck.Metadata.viewOffset
+ ? `?t=${data.OnDeck.Metadata.viewOffset}`
+ : ""
+ }`
+ );
+
+ setPlayButtonLoading(false);
+ return;
+ } else {
+ if (
+ data.Children?.size === 0 ||
+ !data.Children?.Metadata[0]
+ )
+ return setPlayButtonLoading(false);
+ // play first episode
+ const episodes = await getLibraryMetaChildren(
+ data.Children?.Metadata[0].ratingKey
+ );
+ if (episodes?.length === 0)
+ return setPlayButtonLoading(false);
+
+ navigate(`/watch/${episodes[0].ratingKey}`);
+ }
+ }
+ break;
+ }
+ }}
+ >
+ {playButtonLoading ? (
+
+ ) : (
+ <>
+ Play
+ >
+ )}
+
+
+ {
+ if (PlexTvSource) {
+ const data = await getItemByGUID(item.guid);
+ if (!data) {
+ useBigReader
+ .getState()
+ .setBigReader(
+ `"${item.title}" is not available on this Plex Server`
+ );
+ return;
}
- }}
- >
- {playButtonLoading ? (
-
- ) : (
- <>
- Play
- >
- )}
-
-
- {
- if (item.grandparentRatingKey && ["episode"].includes(item.type))
+
+ setSearchParams({ mid: data.ratingKey.toString() });
+ } else {
+ if (
+ item.grandparentRatingKey &&
+ ["episode"].includes(item.type)
+ )
return setSearchParams({ mid: item.grandparentRatingKey });
-
+
setSearchParams({ mid: item.ratingKey.toString() });
- }}
- >
- More Info
-
-
- {/* {}}
- >
-
- */}
-
+ }
+ }}
+ >
+
+
+
+
- {/*
+ {/* */}
-
- );
- }
+
+ );
+}
+
+export default MovieItem;
+
+function WatchListButton({ item }: { item: Plex.Metadata }) {
+ const WatchList = useWatchListCache();
+
+ return (
+ {
+ if (!item) return;
+ if (WatchList.isOnWatchList(item.guid))
+ return WatchList.removeItem(item.guid);
-export default MovieItem
\ No newline at end of file
+ WatchList.addItem(item);
+ }}
+ >
+ {WatchList.isOnWatchList(item.guid) ? (
+
+ ) : (
+
+ )}
+
+ );
+}
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" : ""}`}
+
+ )}
+
+
+
+
+
+ {
+ setPlayButtonLoading(true);
+
+ switch (item.type) {
+ case "movie":
+ case "episode":
+ navigate(
+ `/watch/${item.ratingKey}${
+ item.viewOffset ? `?t=${item.viewOffset}` : ""
+ }`
+ );
+
+ setPlayButtonLoading(false);
+ break;
+ case "show":
+ {
+ const data = await getLibraryMeta(item.ratingKey);
+
+ if (!data) {
+ setPlayButtonLoading(false);
+ return;
+ }
+
+ if (data.OnDeck?.Metadata) {
+ navigate(
+ `/watch/${data.OnDeck.Metadata.ratingKey}${
+ data.OnDeck.Metadata.viewOffset
+ ? `?t=${data.OnDeck.Metadata.viewOffset}`
+ : ""
+ }`
+ );
+
+ setPlayButtonLoading(false);
+ return;
+ } else {
+ if (
+ data.Children?.size === 0 ||
+ !data.Children?.Metadata[0]
+ )
+ return setPlayButtonLoading(false);
+ // play first episode
+ const episodes = await getLibraryMetaChildren(
+ data.Children?.Metadata[0].ratingKey
+ );
+ if (episodes?.length === 0)
+ return setPlayButtonLoading(false);
+
+ navigate(`/watch/${episodes[0].ratingKey}`);
+ }
+ }
+ break;
+ }
+ }}
+ >
+ {playButtonLoading ? (
+
+ ) : (
+ <>
+ Play
+ >
+ )}
+
+
+ {
+ if (item.grandparentRatingKey && ["episode"].includes(item.type))
+ return setSearchParams({ mid: item.grandparentRatingKey });
+
+ setSearchParams({ mid: item.ratingKey.toString() });
+ }}
+ >
+
+
+
+ {}}
+ >
+
+
+
+
+ {/* */}
+
+ );
+}
+
+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