diff --git a/assets/screenshot1.png b/assets/screenshot1.png index 859770f..4169001 100644 Binary files a/assets/screenshot1.png and b/assets/screenshot1.png differ diff --git a/assets/screenshot2.png b/assets/screenshot2.png index 8cbaf94..e421c59 100644 Binary files a/assets/screenshot2.png and b/assets/screenshot2.png differ diff --git a/assets/screenshot3.png b/assets/screenshot3.png index 546f7fc..43e3663 100644 Binary files a/assets/screenshot3.png and b/assets/screenshot3.png differ diff --git a/backend/src/index.ts b/backend/src/index.ts index dde7764..76359db 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -113,8 +113,12 @@ const noVerifyHttpsAgent = new https.Agent({ while (true) { const r = await axios.get(`${process.env.PLEX_SERVER ?? "http://localhost:32400"}/identity`, { timeout: 5000, - }).catch(() => null); - + httpAgent: process.env.DISABLE_TLS_VERIFY === "true" ? noVerifyHttpsAgent : undefined, + httpsAgent: process.env.DISABLE_TLS_VERIFY === "true" ? noVerifyHttpsAgent : undefined, + }).catch((e) => { + console.error('Error reaching PLEX_SERVER:', e.message); + return null; + }); if (r && r.status === 200) { status.error = false; return resolve(); diff --git a/frontend/src/components/HeroDisplay.tsx b/frontend/src/components/HeroDisplay.tsx index 70f2f2f..44f7e57 100644 --- a/frontend/src/components/HeroDisplay.tsx +++ b/frontend/src/components/HeroDisplay.tsx @@ -11,7 +11,7 @@ import { useSearchParams, useNavigate } from "react-router-dom"; import { usePreviewPlayer } from "../states/PreviewPlayerState"; import ReactPlayer from "react-player"; import { useBigReader } from "./BigReader"; -import { WatchListButton } from "./MovieItem"; +import { HeroWatchListButton } from "./MovieItem"; import { getBackendURL } from "../backendURL"; import { queryBuilder } from "../plex/QuickFunctions"; import { getTranscodeImageURL } from "../plex"; @@ -318,7 +318,7 @@ function HeroDisplay({ item }: { item: Plex.Metadata }) { - + diff --git a/frontend/src/components/MetaScreen.tsx b/frontend/src/components/MetaScreen.tsx index 86be36a..bae48a5 100755 --- a/frontend/src/components/MetaScreen.tsx +++ b/frontend/src/components/MetaScreen.tsx @@ -50,7 +50,7 @@ import { usePreviewPlayer } from "../states/PreviewPlayerState"; import MovieItem from "./MovieItem"; import { useBigReader } from "./BigReader"; import { useInView } from "react-intersection-observer"; -import { WatchListButton } from "./MovieItem"; +import { HeroWatchListButton } from "./MovieItem"; import { alpha } from "@mui/material/styles"; import { AnimatePresence, motion } from "framer-motion"; import { useConfirmModal } from "./ConfirmModal"; @@ -668,7 +668,7 @@ function MetaScreen() { - + {data && } diff --git a/frontend/src/components/MovieItem.tsx b/frontend/src/components/MovieItem.tsx index 2161fdc..8939e87 100644 --- a/frontend/src/components/MovieItem.tsx +++ b/frontend/src/components/MovieItem.tsx @@ -7,6 +7,7 @@ import { CheckCircleRounded, VolumeOffRounded, VolumeUpRounded, + StarRounded, } from "@mui/icons-material"; import { Box, @@ -360,7 +361,7 @@ function MovieItem({ display: "flex", flexDirection: "column", alignItems: "flex-start", - justifyContent: "flex-end", + justifyContent: "flex-start", width: itemsPerPage ? `calc((100vw / ${itemsPerPage}) - 10px - (5vw / ${itemsPerPage}))` : "100%", @@ -370,38 +371,34 @@ function MovieItem({ backgroundColor: "rgba(18, 18, 22, 0.7)", backdropFilter: "blur(15px)", border: "1px solid rgba(255,255,255,0.08)", - - borderRadius: "6px", + borderRadius: "8px", overflow: "hidden", - mb: "0px", position: "relative", boxShadow: "0px 2px 8px rgba(0, 0, 0, 0.3)", + willChange: "transform", + backfaceVisibility: "hidden", + transformOrigin: + itemsPerPage && index !== undefined + ? (index % itemsPerPage) === 0 + ? "left center" + : (index % itemsPerPage) === itemsPerPage - 1 + ? "right center" + : "center center" + : "center center", + transition: + "transform 0.4s cubic-bezier(0.25,0.10,0.25,1.00), box-shadow 0.4s cubic-bezier(0.25,0.10,0.25,1.00), border-color 0.4s cubic-bezier(0.25,0.10,0.25,1.00)", + cursor: "pointer", "&:hover": { - transform: "scale(1.08)", - transition: "all 0.4s cubic-bezier(0.25,0.10,0.25,1.00)", + transform: "scale(1.15)", zIndex: 10, - boxShadow: "0px 4px 15px rgba(0, 0, 0, 0.4)", + boxShadow: "0px 8px 24px rgba(0, 0, 0, 0.5)", border: "1px solid rgba(255,255,255,0.15)", - backgroundColor: "rgba(18, 18, 22, 0.85)", - position: "relative", - pb: "10px", - mb: "-42px", }, - [`&:hover > :nth-child(${ - item.type === "episode" || - (item.type === "movie" && item.viewOffset) - ? 4 - : 3 - })`]: { - height: "32px", - opacity: 1, + "&:hover .movie-item-hover-overlay": { + opacity: "1 !important", }, - - transition: - "all 0.4s cubic-bezier(0.25,0.10,0.25,1.00), transform 0.4s cubic-bezier(0.25,0.10,0.25,1.00)", - cursor: "pointer", }} onContextMenu={(e) => { e.preventDefault(); @@ -411,10 +408,7 @@ function MovieItem({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, } - : // repeated contextmenu when it is already open closes it with Chrome 84 on Ubuntu - // Other native context menus might behave different. - // With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus. - null + : null ); }} onClick={async () => { @@ -444,35 +438,40 @@ function MovieItem({ setHovered(false); }} > + {/* Thumbnail area */} + {/* Background image */} + + {/* Preview playback overlay */} + @@ -500,21 +499,76 @@ function MovieItem({ /> + {/* Hover overlay with buttons */} + + { + e.stopPropagation(); + await handlePlay(); + }} + > + {playButtonLoading ? ( + + ) : ( + + )} + + + + + + {/* Mute button for preview */} { e.stopPropagation(); @@ -527,44 +581,88 @@ function MovieItem({ }} > {MetaScreenPlayerMuted ? ( - + ) : ( - + )} + {/* Watched badge */} {((item.type === "show" && item.leafCount === item.viewedLeafCount) || (item.type === "movie" && item?.viewCount !== undefined && item.viewCount > 0)) && ( - theme.palette.primary.light, - }} - /> + + theme.palette.primary.light, + }} + /> + + Watched + + )} + + {/* Content rating badge on thumbnail */} + {item.contentRating && ( + + + {item.contentRating} + + + )} + + {/* Progress bar */} {(item.type === "episode" || (item.type === "movie" && item.viewOffset)) && ( theme.palette.primary.main, - borderRadius: "2px", }, }} /> )} + + {/* Info section */} - theme.palette.primary.light, - textTransform: "uppercase", - opacity: 0.9, - mb: 0.5, + width: "100%", + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + mb: "2px", }} > - {item.type} {item.type === "episode" && item.index} - + theme.palette.primary.light, + textTransform: "uppercase", + opacity: 0.9, + lineHeight: 1, + }} + > + {item.type === "episode" + ? `S${item.parentIndex} · E${item.index}` + : item.type} + + + {item.audienceRating && ( + + + + {item.audienceRating.toFixed(1)} + + + )} + + {/* Title */} theme.palette.text.primary, textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", - maxLines: 1, - maxInlineSize: "100%", - marginTop: ["episode"].includes(item.type) ? "2px" : "0px", + width: "100%", lineHeight: 1.3, "@media (max-width: 2000px)": { - fontSize: "1rem", + fontSize: "0.9rem", }, }} > {item.title} + {/* Show title for episodes */} {["episode"].includes(item.type) && item.grandparentTitle && ( { @@ -642,85 +780,76 @@ function MovieItem({ }); }} sx={{ - fontSize: "0.85rem", + fontSize: "0.8rem", fontWeight: "500", color: (theme) => theme.palette.text.secondary, - opacity: 0.8, - mb: 1, - transition: "all 0.4s cubic-bezier(0.25,0.10,0.25,1.00)", + opacity: 0.7, + transition: "all 0.3s ease", cursor: "pointer", "&:hover": { opacity: 1, color: (theme) => theme.palette.primary.light, - textDecoration: "none", }, textOverflow: "ellipsis", overflow: "hidden", - maxLines: 1, - maxInlineSize: "100%", + whiteSpace: "nowrap", + width: "100%", }} > {item.grandparentTitle} )} + + {/* Metadata row */} - {/* {item.rating && ( - - {item.rating} - - )} - {item.contentRating && ( - - {item.contentRating} - - )} */} - {/* {item.type === "episode" && item.index && ( - - S{item.parentIndex} E{item.index} - - )} */} + {item.year && ( + theme.palette.text.secondary, + opacity: 0.7, + flexShrink: 0, + }} + > + {item.year} + + )} + {item.year && + ((item.duration && ["episode", "movie"].includes(item.type)) || + (item.type === "show" && + item.leafCount && + (item.seasonCount ?? item.childCount))) && ( + + · + + )} {item.duration && ["episode", "movie"].includes(item.type) && ( theme.palette.text.secondary, - opacity: 0.8, + opacity: 0.7, + flexShrink: 0, }} > {durationToText(item.duration)} @@ -731,91 +860,49 @@ function MovieItem({ (item.seasonCount ?? item.childCount) && ( theme.palette.text.secondary, - opacity: 0.8, + opacity: 0.7, + flexShrink: 0, }} > {(item.seasonCount ?? item.childCount ?? 1) > 1 ? `${item.childCount} Seasons` - : `${item.leafCount} Episode${ - item.leafCount > 1 ? "s" : "" - }`} + : `${item.leafCount} Ep${item.leafCount > 1 ? "s" : ""}`} )} - {item.year && ( - theme.palette.text.secondary, - opacity: 0.8, - }} - > - {item.year} - + {item.Genre && item.Genre.length > 0 && ( + <> + + · + + theme.palette.text.secondary, + opacity: 0.7, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + minWidth: 0, + }} + > + {item.Genre[0].tag} + + )} - - - - - - - - ); @@ -828,16 +915,20 @@ export function WatchListButton({ item }: { item: Plex.Metadata }) { const [isLoading, setIsLoading] = React.useState(false); return ( -