├── .env ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── api └── index.ts ├── app ├── browse │ └── [key] │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── page.tsx └── settings │ └── page.tsx ├── components.json ├── components ├── appbar.tsx ├── auth-provider.tsx ├── cards │ ├── collection-preview-item.tsx │ ├── element-image-preview-item.tsx │ ├── episode-preview-item.tsx │ ├── metadata-preview-item.tsx │ ├── on-deck-image-preview-item.tsx │ ├── other-image-preview-item.tsx │ └── season-preview-item.tsx ├── carousel │ ├── carousel-item-hover.tsx │ ├── carousel-item.tsx │ ├── carousel.tsx │ └── index.tsx ├── change-server-dialog.tsx ├── hero.tsx ├── hub-slider.tsx ├── providers.tsx ├── screens │ ├── library-screen.tsx │ ├── meta-screen.tsx │ └── watch-screen.tsx ├── search-provider.tsx ├── search.tsx ├── server-provider.tsx ├── settings-provider.tsx ├── theme-provier.tsx └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── checkbox.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── navigation-menu.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── tabs.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ └── tooltip.tsx ├── compose.yaml ├── constants.ts ├── demo.gif ├── demo.png ├── fonts ├── NetflixSans-Bold.otf ├── NetflixSans-Light.otf ├── NetflixSans-Medium.otf └── NetflixSans-Regular.otf ├── hooks ├── use-hub-item.ts ├── use-hubs.ts ├── use-is-at-top.ts ├── use-is-scroll-at-top.ts ├── use-is-size.ts ├── use-item-key-metadata.ts ├── use-item-metadata.ts ├── use-preview-muted.tsx └── use-session.tsx ├── lib ├── server.ts └── utils.ts ├── next.config.mjs ├── package.json ├── plex.d.ts ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── favicon.ico ├── plex.png └── plexIcon.png ├── tailwind.config.ts ├── tsconfig.json ├── type.ts └── window.d.ts /.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/.env -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript", "plugin:prettier/recommended"], 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": 1, 5 | "@next/next/no-img-element": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: ricoloic 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 14 | 15 | **Steps To Reproduce** 16 | Steps to reproduce the behavior: 17 | 23 | 24 | **Screenshots** 25 | 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. iOS] 31 | - Browser [e.g. chrome, safari] 32 | - Version [e.g. 22] 33 | 34 | 35 | **Additional context** 36 | 39 | 40 | **Console & Network Logs** (if applicable) 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Description 7 | 8 | 12 | 13 | ## Type of Changes 14 | 15 | - [ ] Bug fix (non-breaking change that fixes an issue) 16 | - [ ] New feature (non-breaking change that adds functionality) 17 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 18 | - [ ] Documentation update (changes to README or other documentation) 19 | 20 | ## Related Issues 21 | 22 | Link to any existing GitHub issues related to this pull request: 23 | - Resolves # 24 | - Related to # 25 | 26 | ## Checklist 27 | 28 | Please ensure your pull request meets the following requirements: 29 | 30 | - [ ] Documentation has been updated to reflect changes (if applicable). 31 | - [ ] All dependencies have been updated in `package.json` & `pnpm-lock.yaml` (if applicable). 32 | - [ ] The application builds and runs without errors locally. 33 | - [ ] UI components use **ShadCN** components and follow the design. 34 | 35 | ## Testing 36 | 37 | 40 | 41 | ## Screenshots 42 | 43 | If applicable, include screenshots of the new feature, UI changes, or bug fixes: 44 | 45 | ![Screenshot](link-to-screenshot) 46 | 47 | ## Additional Notes 48 | 49 | Add any additional comments, caveats, or information here: 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # jetbrains 24 | /.idea/ 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "prettier/prettier": [ 3 | "error", 4 | {}, 5 | { 6 | "usePrettierrc": false, 7 | "fileInfoOptions": { 8 | "withNodeModules": true 9 | } 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=20 2 | 3 | FROM node:${NODE_VERSION}-alpine AS base 4 | 5 | RUN npm install -g pnpm 6 | 7 | WORKDIR /usr/src/app 8 | 9 | FROM base AS build 10 | 11 | COPY package.json pnpm-lock.yaml ./ 12 | RUN pnpm install --frozen-lockfile 13 | 14 | COPY . . 15 | RUN pnpm run build 16 | 17 | FROM base AS final 18 | 19 | WORKDIR /usr/src/app 20 | 21 | ENV NODE_ENV=production 22 | 23 | COPY package.json . 24 | 25 | COPY --from=build /usr/src/app/node_modules ./node_modules 26 | COPY --from=build /usr/src/app/. ./. 27 | 28 | EXPOSE 3000 29 | 30 | CMD ["pnpm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LOIC RICO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plexy 2 | 3 | Plexy is an alternative web UI for the Plex app, designed to provide a user experience similar to Netflix. This project was inspired by [PerPlexed](https://github.com/Ipmake/PerPlexed), without which Plexy would not have been possible. 4 | 5 | ![plexy demo image](https://raw.githubusercontent.com/ricoloic/plexy/main/demo.png) 6 | 7 | ## Getting Started 8 | 9 | ### Development Setup 10 | 11 | Follow these steps to run the project locally: 12 | 13 | 1. Install dependencies: 14 | ```bash 15 | pnpm install 16 | ``` 17 | 2. Start the development server: 18 | ```bash 19 | pnpm dev 20 | ``` 21 | 3. Open http://localhost:3000 in your browser to view the app. 22 | 23 | ### Docker Setup 24 | 25 | You can also run Plexy using Docker: 26 | 27 | 1. Clone the project repository: 28 | ```bash 29 | git clone https://github.com/ricoloic/plexy.git 30 | ``` 31 | 2. Build and start the Docker container: 32 | ```bash 33 | docker compose up -d # If using Compose v1, use docker-compose up -d 34 | ``` 35 | 3. Access the application at http://localhost:3000. 36 | 37 | ## Contributing 38 | 39 | Plexy is in active development, and contributions are welcome! If you encounter any bugs or have suggestions for new features, please open a [GitHub issue](https://github.com/ricoloic/plexy/issues/new). 40 | 41 | ## Roadmap 42 | 43 | - Complete the implementation of **Mark as Watched** and **Mark as Unwatched** across all relevant areas. 44 | - Introduce additional features to enhance the user experience. 45 | - Refine the UI for better usability and polish. -------------------------------------------------------------------------------- /app/browse/[key]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ServerApi } from "@/api"; 4 | import { useParams } from "next/navigation"; 5 | import { useQuery } from "@tanstack/react-query"; 6 | import { useEffect, useState } from "react"; 7 | import { Hero } from "@/components/hero"; 8 | import { HubSlider } from "@/components/hub-slider"; 9 | import { useHubs } from "@/hooks/use-hubs"; 10 | import { cn } from "@/lib/utils"; 11 | import { APPBAR_HEIGHT } from "@/components/appbar"; 12 | 13 | export default function Page() { 14 | const params = useParams<{ key: string }>(); 15 | const library = useQuery({ 16 | queryKey: ["details", params.key], 17 | queryFn: async () => { 18 | return await ServerApi.details({ key: params.key, include: true }); 19 | }, 20 | }); 21 | const [featured, setFeatured] = useState(null); 22 | const [initialHubs, setInitialHubs] = useState([]); 23 | const { hubs, reload, append } = useHubs(initialHubs); 24 | 25 | const handleUpdate = ( 26 | updatedItem: Plex.HubMetadata, 27 | _: number, 28 | hubIndex: number, 29 | ) => { 30 | const hubsIndex = [hubIndex]; 31 | const keys = [updatedItem.ratingKey]; 32 | if (updatedItem.parentRatingKey) keys.push(updatedItem.parentRatingKey); 33 | if (updatedItem.grandparentRatingKey) 34 | keys.push(updatedItem.grandparentRatingKey); 35 | hubs?.forEach((hub, index) => { 36 | if (hubIndex === index) return; 37 | const included = hub.Metadata?.some((item) => { 38 | return keys.includes(item.ratingKey); 39 | }); 40 | if ( 41 | included || 42 | hub.context.includes("recentlyviewed") || 43 | hub.context.includes("inprogress") 44 | ) { 45 | hubsIndex.push(index); 46 | } 47 | }); 48 | hubsIndex.forEach((index) => { 49 | reload(index); 50 | }); 51 | }; 52 | 53 | useEffect(() => { 54 | setFeatured(null); 55 | setInitialHubs([]); 56 | 57 | ServerApi.random({ dir: params.key }).then((res) => { 58 | if (!res) return; 59 | setFeatured(res); 60 | }); 61 | 62 | ServerApi.hubs({ 63 | id: params.key, 64 | }).then((res) => { 65 | if (!res) return; 66 | if (res.length === 0) return; 67 | setInitialHubs( 68 | res.filter((hub) => hub.Metadata && hub.Metadata.length > 0), 69 | ); 70 | }); 71 | }, [params.key]); 72 | 73 | useEffect(() => { 74 | const updateHubs = () => { 75 | const storage = localStorage.getItem("from-meta-screen"); 76 | if (storage) { 77 | const { ratingKey, parentRatingKey, grandparentRatingKey } = JSON.parse( 78 | storage, 79 | ) as { 80 | ratingKey: string; 81 | parentRatingKey: string | null; 82 | grandparentRatingKey: string | null; 83 | }; 84 | const hubsIndex: number[] = []; 85 | const keys = [ratingKey]; 86 | if (parentRatingKey) keys.push(parentRatingKey); 87 | if (grandparentRatingKey) keys.push(grandparentRatingKey); 88 | hubs?.forEach((hub, index) => { 89 | const included = hub.Metadata?.some((item) => 90 | keys.includes(item.ratingKey), 91 | ); 92 | if ( 93 | included || 94 | hub.context.includes("recentlyviewed") || 95 | hub.context.includes("inprogress") 96 | ) { 97 | hubsIndex.push(index); 98 | } 99 | }); 100 | hubsIndex.forEach((index) => { 101 | reload(index); 102 | }); 103 | } 104 | localStorage.removeItem("from-meta-screen"); 105 | }; 106 | 107 | window.addEventListener("popstate", updateHubs); 108 | return () => { 109 | window.removeEventListener("popstate", updateHubs); 110 | }; 111 | }, [params.key, hubs]); 112 | 113 | if (!library.data) { 114 | return null; 115 | } 116 | 117 | const type = library.data.Type[0].type; 118 | 119 | if (type === "show" || type === "movie") { 120 | return ( 121 |
122 | {featured && } 123 |
133 | {hubs && 134 | hubs.map((item, i) => ( 135 | append(i, items)} 140 | onUpdate={(updatedItem, itemIndex) => 141 | handleUpdate(updatedItem, itemIndex, i) 142 | } 143 | /> 144 | ))} 145 |
146 |
147 | ); 148 | } 149 | 150 | return null; 151 | } 152 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'Netflix'; 7 | src: url('../fonts/NetflixSans-Light.otf'); 8 | font-weight: 200; 9 | font-style: normal; 10 | } 11 | 12 | @font-face { 13 | font-family: 'Netflix'; 14 | src: url('../fonts/NetflixSans-Regular.otf'); 15 | font-weight: normal; 16 | font-style: normal; 17 | } 18 | 19 | @font-face { 20 | font-family: 'Netflix'; 21 | src: url('../fonts/NetflixSans-Medium.otf'); 22 | font-weight: 600; 23 | font-style: normal; 24 | } 25 | 26 | @font-face { 27 | font-family: 'Netflix'; 28 | src: url('../fonts/NetflixSans-Bold.otf'); 29 | font-weight: bold; 30 | font-style: normal; 31 | } 32 | 33 | body { 34 | font-family: 'Netflix', sans-serif; 35 | } 36 | 37 | @layer utilities { 38 | .text-balance { 39 | text-wrap: balance; 40 | } 41 | 42 | /* Hide scrollbar for Chrome, Safari and Opera */ 43 | .no-scrollbar::-webkit-scrollbar { 44 | display: none; 45 | } 46 | /* Hide scrollbar for IE, Edge and Firefox */ 47 | .no-scrollbar { 48 | -ms-overflow-style: none; /* IE and Edge */ 49 | scrollbar-width: none; /* Firefox */ 50 | } 51 | } 52 | 53 | @layer base { 54 | :root { 55 | --plex-accent: 42 97% 46%; 56 | --background: 240 10% 3.9%; 57 | --alternative: 21, 21, 23; 58 | --foreground: 0 0% 98%; 59 | --card: 240 10% 3.9%; 60 | --card-foreground: 0 0% 98%; 61 | --popover: 240 10% 3.9%; 62 | --popover-foreground: 0 0% 98%; 63 | --primary: 0 0% 98%; 64 | --primary-foreground: 240 5.9% 10%; 65 | --secondary: 240 3.7% 15.9%; 66 | --secondary-foreground: 0 0% 98%; 67 | --muted: 240 3.7% 15.9%; 68 | --muted-foreground: 240 5% 64.9%; 69 | --accent: 240 3.7% 15.9%; 70 | --accent-foreground: 0 0% 98%; 71 | --destructive: 0 62.8% 30.6%; 72 | --destructive-foreground: 0 0% 98%; 73 | --border: 240 3.7% 15.9%; 74 | --input: 240 3.7% 15.9%; 75 | --ring: 240 4.9% 83.9%; 76 | --chart-1: 220 70% 50%; 77 | --chart-2: 160 60% 45%; 78 | --chart-3: 30 80% 55%; 79 | --chart-4: 280 65% 60%; 80 | --chart-5: 340 75% 55%; 81 | --radius: 0.5rem; 82 | } 83 | 84 | .dark { 85 | --plex-accent: 42 97% 46%; 86 | --background: 240 10% 3.9%; 87 | --alternative: 21, 21, 23; 88 | --foreground: 0 0% 98%; 89 | --card: 240 10% 3.9%; 90 | --card-foreground: 0 0% 98%; 91 | --popover: 240 10% 3.9%; 92 | --popover-foreground: 0 0% 98%; 93 | --primary: 0 0% 98%; 94 | --primary-foreground: 240 5.9% 10%; 95 | --secondary: 240 3.7% 15.9%; 96 | --secondary-foreground: 0 0% 98%; 97 | --muted: 240 3.7% 15.9%; 98 | --muted-foreground: 240 5% 64.9%; 99 | --accent: 240 3.7% 15.9%; 100 | --accent-foreground: 0 0% 98%; 101 | --destructive: 0 62.8% 30.6%; 102 | --destructive-foreground: 0 0% 98%; 103 | --border: 240 3.7% 15.9%; 104 | --input: 240 3.7% 15.9%; 105 | --ring: 240 4.9% 83.9%; 106 | --chart-1: 220 70% 50%; 107 | --chart-2: 160 60% 45%; 108 | --chart-3: 30 80% 55%; 109 | --chart-4: 280 65% 60%; 110 | --chart-5: 340 75% 55%; 111 | } 112 | } 113 | 114 | @layer base { 115 | * { 116 | @apply border-border; 117 | } 118 | 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | 123 | .without-ring { 124 | @apply focus:ring-0 focus:ring-offset-0 focus-within:ring-0 focus-within:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ring-0 ring-offset-0 focus:outline-0 focus-within:outline-0 focus-visible:outline-0 outline-0 focus:border-0 focus-within:border-0 focus-visible:border-0 border-0; 125 | } 126 | } 127 | 128 | .hover-time { 129 | background-color: hsl(var(--background)); 130 | } 131 | 132 | .ui-video-seek-slider .hover-time .preview-screen { 133 | width: 240px !important; 134 | height: 135px !important; 135 | background-color: transparent !important; 136 | } 137 | 138 | .ui-video-seek-slider .track .main .connect { 139 | background-color: hsl(var(--plex-accent)); 140 | } 141 | 142 | .ui-video-seek-slider .thumb .handler { 143 | background-color: hsl(var(--plex-accent)); 144 | } 145 | 146 | .ui-video-seek-slider > .hover-time.active[data-testid="hover-time"] { 147 | background-color: transparent !important; 148 | padding: 0 !important; 149 | } 150 | 151 | .ui-video-seek-slider > .hover-time.active[data-testid="hover-time"] .preview-screen { 152 | background-color: transparent !important; 153 | padding: 0 !important; 154 | } 155 | 156 | .logo-shift { 157 | transition: transform 0.8s ease; 158 | transform: translateY(var(--h)); 159 | } 160 | 161 | .fade-down { 162 | animation: fadeDown 0.8s ease forwards; 163 | } 164 | 165 | @keyframes fadeDown { 166 | 0% { 167 | opacity: 1; 168 | transform: translateY(0); 169 | } 170 | 100% { 171 | opacity: 0; 172 | transform: translateY(100%); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { Appbar } from "@/components/appbar"; 4 | import { ReactNode, Suspense } from "react"; 5 | import Providers from "@/components/providers"; 6 | import { Analytics } from "@vercel/analytics/react"; 7 | import { SpeedInsights } from "@vercel/speed-insights/next"; 8 | 9 | export const metadata: Metadata = { 10 | title: "Plexy", 11 | description: "Alternative plex ui", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

You will be redirected soon...

; 3 | } 4 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ServerApi } from "@/api"; 4 | import { useEffect, useState } from "react"; 5 | import { Hero } from "@/components/hero"; 6 | import { HubSlider } from "@/components/hub-slider"; 7 | import { useHubs } from "@/hooks/use-hubs"; 8 | import { useServer } from "@/components/server-provider"; 9 | 10 | export default function Home() { 11 | const [item, setItem] = useState(null); 12 | const [promoted, setPromoted] = useState(null); 13 | const { hubs, reload, append } = useHubs(promoted); 14 | const [isLoading, setIsLoading] = useState(false); 15 | const { libraries, disabledLibraries } = useServer(); 16 | 17 | const handleUpdate = ( 18 | updatedItem: Plex.HubMetadata, 19 | _: number, 20 | hubIndex: number, 21 | ) => { 22 | const hubsIndex = [hubIndex]; 23 | const keys = [updatedItem.ratingKey]; 24 | if (updatedItem.parentRatingKey) keys.push(updatedItem.parentRatingKey); 25 | if (updatedItem.grandparentRatingKey) 26 | keys.push(updatedItem.grandparentRatingKey); 27 | hubs?.forEach((hub, index) => { 28 | if (hubIndex === index) return; 29 | const included = hub.Metadata?.some((item) => { 30 | return keys.includes(item.ratingKey); 31 | }); 32 | if ( 33 | included || 34 | hub.context.includes("recentlyviewed") || 35 | hub.context.includes("inprogress") 36 | ) { 37 | hubsIndex.push(index); 38 | } 39 | }); 40 | hubsIndex.forEach((index) => { 41 | reload(index); 42 | }); 43 | }; 44 | 45 | useEffect(() => { 46 | setIsLoading(true); 47 | const dirs = libraries 48 | .filter((a) => !disabledLibraries[a.title]) 49 | .map((a) => a.key); 50 | (async () => { 51 | if (dirs.length > 0) { 52 | const item = await ServerApi.random({ dir: dirs }); 53 | setItem(item); 54 | } 55 | const promo: Plex.Hub[] = []; 56 | ServerApi.continue({ dirs }).then(async (res) => { 57 | if (!res) return; 58 | if (res.length === 0) return; 59 | promo.push(res[0]); 60 | for (const dir of dirs) { 61 | const res = await ServerApi.promoted({ dir, dirs }); 62 | if (!res) continue; 63 | if (res.length === 0) continue; 64 | res.forEach((hub) => { 65 | promo.push(hub); 66 | }); 67 | } 68 | setPromoted(promo); 69 | }); 70 | setIsLoading(false); 71 | })(); 72 | 73 | const updateHubs = (event: PopStateEvent) => { 74 | const storage = localStorage.getItem("from-meta-screen"); 75 | if (storage) { 76 | const { ratingKey, parentRatingKey, grandparentRatingKey } = JSON.parse( 77 | storage, 78 | ) as { 79 | ratingKey: string; 80 | parentRatingKey: string | null; 81 | grandparentRatingKey: string | null; 82 | }; 83 | const hubsIndex: number[] = []; 84 | const keys = [ratingKey]; 85 | if (parentRatingKey) keys.push(parentRatingKey); 86 | if (grandparentRatingKey) keys.push(grandparentRatingKey); 87 | hubs?.forEach((hub, index) => { 88 | const included = hub.Metadata?.some((item) => 89 | keys.includes(item.ratingKey), 90 | ); 91 | if ( 92 | included || 93 | hub.context.includes("recentlyviewed") || 94 | hub.context.includes("inprogress") 95 | ) { 96 | hubsIndex.push(index); 97 | } 98 | }); 99 | hubsIndex.forEach((index) => { 100 | reload(index); 101 | }); 102 | } 103 | localStorage.removeItem("from-meta-screen"); 104 | }; 105 | 106 | window.addEventListener("popstate", updateHubs); 107 | return () => { 108 | window.removeEventListener("popstate", updateHubs); 109 | }; 110 | }, []); 111 | 112 | if (isLoading) { 113 | return null; 114 | } 115 | 116 | return ( 117 |
118 | {item && } 119 | {item && ( 120 |
121 | {hubs && 122 | hubs.map((item, i) => ( 123 | 125 | handleUpdate(updatedItem, itemIndex, i) 126 | } 127 | onAppend={(updatedItems) => append(i, updatedItems)} 128 | key={`${item.key}-${i}`} 129 | hub={item} 130 | /> 131 | ))} 132 |
133 | )} 134 |
135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { APPBAR_HEIGHT } from "@/components/appbar"; 4 | import { useServer } from "@/components/server-provider"; 5 | import { Checkbox } from "@/components/ui/checkbox"; 6 | import { Label } from "@/components/ui/label"; 7 | import { useSettings } from "@/components/settings-provider"; 8 | 9 | export default function Page() { 10 | const { libraries, disabledLibraries, toggleDisableLibrary } = useServer(); 11 | const { updateDisableClearLogo, disableClearLogo } = useSettings(); 12 | 13 | return ( 14 |
18 |

19 | Settings 20 |

21 |
22 |

Libraries

23 | {libraries.map((section) => ( 24 | 34 | ))} 35 |
36 |
37 |

Misc

38 | 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/appbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useSession } from "@/hooks/use-session"; 5 | import { Button } from "@/components/ui/button"; 6 | import { usePathname, useRouter } from "next/navigation"; 7 | import { 8 | ChevronsLeftRightEllipsis, 9 | Film, 10 | House, 11 | LogOut, 12 | Menu, 13 | Server, 14 | SettingsIcon, 15 | TvMinimal, 16 | X, 17 | } from "lucide-react"; 18 | import { Search } from "@/components/search"; 19 | import { useIsAtTop } from "@/hooks/use-is-at-top"; 20 | import { cn } from "@/lib/utils"; 21 | import { useServer } from "@/components/server-provider"; 22 | import { ChangeServerDialog } from "@/components/change-server-dialog"; 23 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 24 | import { 25 | Sheet, 26 | SheetClose, 27 | SheetContent, 28 | SheetHeader, 29 | SheetTitle, 30 | SheetTrigger, 31 | } from "@/components/ui/sheet"; 32 | import { 33 | NavigationMenu, 34 | NavigationMenuContent, 35 | NavigationMenuItem, 36 | NavigationMenuLink, 37 | NavigationMenuList, 38 | NavigationMenuTrigger, 39 | navigationMenuTriggerStyle, 40 | } from "@/components/ui/navigation-menu"; 41 | import qs from "qs"; 42 | import { 43 | Popover, 44 | PopoverContent, 45 | PopoverTrigger, 46 | } from "@/components/ui/popover"; 47 | import he from "he"; 48 | 49 | export const APPBAR_HEIGHT = "4.5rem"; 50 | 51 | export const Appbar = () => { 52 | const path = usePathname(); 53 | const router = useRouter(); 54 | const { user } = useSession(); 55 | const { libraries, disabledLibraries } = useServer(); 56 | 57 | const isAtTop = useIsAtTop(); 58 | 59 | const handleLogout = () => { 60 | localStorage.removeItem("token"); 61 | localStorage.removeItem("auth-token"); 62 | localStorage.removeItem("uuid"); 63 | localStorage.removeItem("pin"); 64 | localStorage.removeItem("user-uuid"); 65 | window.location.href = "/"; 66 | }; 67 | 68 | return ( 69 | <> 70 |
78 |
79 | 80 | 81 | 82 | 83 | 90 | Home 91 | 92 | 93 | 94 | {libraries.map((section) => 95 | !disabledLibraries[section.title] ? ( 96 | 97 | 98 | 107 | {section.title} 108 | 109 | 110 | 111 | 125 | 128 | 131 | 132 | 133 | ) : null, 134 | )} 135 | 136 | 137 |
138 | 139 | 140 | 141 | 142 | 143 | 144 | Plexy 145 | 146 | 149 | 150 | 151 |
152 | 165 | {libraries.map((section) => 166 | !disabledLibraries[section.title] ? ( 167 | 186 | ) : null, 187 | )} 188 |
189 |
190 | 191 |
192 | {user && ( 193 | 194 | 195 | 196 | 197 | 198 | {user.username.slice(0, 2).toUpperCase()} 199 | 200 | 201 | 202 | 203 |
204 |
205 |
206 | 207 | 208 | 209 | {user.title.slice(0, 2).toUpperCase()} 210 | 211 | 212 |
213 |
214 |

215 | {he.decode(user.title)} 216 |

217 | {user.email && ( 218 |

219 | {user.email} 220 |

221 | )} 222 |
223 |
224 | 225 | 235 | {user && ( 236 | <> 237 | 244 | Change Server 245 | 246 | } 247 | /> 248 | 259 | 267 | 268 | )} 269 |
270 |
271 |
272 | )} 273 |
274 | 275 | ); 276 | }; 277 | -------------------------------------------------------------------------------- /components/auth-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode, useEffect, useState } from "react"; 4 | import { uuidv4 } from "@/lib/utils"; 5 | import { Api } from "@/api"; 6 | import { PLEX } from "@/constants"; 7 | import { ServerProvider } from "@/components/server-provider"; 8 | 9 | function redirectPlexAuth({ 10 | pin, 11 | pinID, 12 | uuid, 13 | }: { 14 | pin: string; 15 | pinID: number; 16 | uuid: string; 17 | }) { 18 | window.location.href = `https://app.plex.tv/auth/#!?clientID=${ 19 | uuid 20 | }&context[device][product]=${ 21 | PLEX.application 22 | }&context[device][version]=4.118.0&context[device][platform]=Firefox&context[device][platformVersion]=122.0&context[device][device]=Linux&context[device][model]=bundled&context[device][screenResolution]=1920x945,1920x1080&context[device][layout]=desktop&context[device][protocol]=${window.location.protocol.replace( 23 | ":", 24 | "", 25 | )}&forwardUrl=${window.location.protocol}//${ 26 | window.location.host 27 | }/login?pinID=${pinID}&code=${pin}&language=en`; 28 | } 29 | 30 | export function AuthProvider({ children }: { children: ReactNode }) { 31 | const [mounted, setMounted] = useState(false); 32 | const [signed, setSigned] = useState(false); 33 | 34 | useEffect(() => { 35 | setMounted(true); 36 | }, []); 37 | 38 | useEffect(() => { 39 | if (!mounted) return; 40 | 41 | let pin = localStorage.getItem("pin"); 42 | const stored = localStorage.getItem("token"); 43 | const pinId = new URL(location.href).searchParams.get("pinID"); 44 | let uuid = localStorage.getItem("uuid"); 45 | 46 | if (!uuid) { 47 | uuid = uuidv4(); 48 | localStorage.setItem("uuid", uuid); 49 | } 50 | 51 | // if token is not set in the local storage 52 | if (!stored) { 53 | // if the pinID is not set redirect to plex auth for login 54 | if (!pinId) { 55 | Api.pin({ uuid }) 56 | .then((res) => { 57 | pin = res.data.code; 58 | localStorage.setItem("pin", pin); 59 | redirectPlexAuth({ pin: pin, pinID: res.data.id, uuid }); 60 | }) 61 | .catch((err) => { 62 | console.error(err); 63 | // TODO: handle error 64 | }); 65 | // else (when the pinID is set, fetch a new auth token 66 | } else { 67 | Api.token({ uuid, pin: pinId }) 68 | .then(async (res) => { 69 | // should have the token here 70 | if (!res.data.authToken) { 71 | // TODO: handle error 72 | return; 73 | } 74 | 75 | // update/set the plex token then redirect to home screen 76 | localStorage.setItem("token", res.data.authToken); 77 | localStorage.setItem("auth-token", res.data.authToken); 78 | window.location.href = "/"; 79 | setSigned(true); 80 | }) 81 | .catch((err) => { 82 | console.error(err); 83 | // TODO: handle error 84 | }); 85 | } 86 | } else { 87 | setSigned(true); 88 | } 89 | }, [mounted]); 90 | 91 | if (!mounted || !signed) return null; 92 | 93 | return {children}; 94 | } 95 | -------------------------------------------------------------------------------- /components/cards/collection-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC, forwardRef } from "react"; 2 | import { usePathname, useRouter } from "next/navigation"; 3 | import { getPosterImage } from "@/hooks/use-hub-item"; 4 | import qs from "qs"; 5 | 6 | export const CollectionPreviewItem = forwardRef< 7 | HTMLButtonElement, 8 | { item: Plex.Metadata } 9 | >(({ item }, ref) => { 10 | const router = useRouter(); 11 | const pathname = usePathname(); 12 | 13 | return ( 14 | 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /components/cards/element-image-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Progress } from "@/components/ui/progress"; 3 | import { HubItemInfo } from "@/hooks/use-hub-item"; 4 | import { ClassNameValue } from "tailwind-merge"; 5 | import { cn } from "@/lib/utils"; 6 | import { useSettings } from "@/components/settings-provider"; 7 | 8 | export const ElementImagePreviewItem: FC<{ 9 | item: Plex.HubMetadata | Plex.Metadata; 10 | info: HubItemInfo; 11 | isOnDeck?: boolean; 12 | image: string; 13 | action?: "play" | "open" | null; 14 | disabled?: boolean; 15 | indicator?: boolean; 16 | className?: ClassNameValue; 17 | progress?: boolean; 18 | quality?: boolean; 19 | clearLogo?: string | null; 20 | }> = ({ 21 | item, 22 | info, 23 | isOnDeck = false, 24 | image, 25 | disabled = false, 26 | indicator = false, 27 | className = "", 28 | action = null, 29 | progress = true, 30 | quality = false, 31 | clearLogo, 32 | }) => { 33 | const { isEpisode, isMovie, isSeason, play, open } = info; 34 | const { disableClearLogo } = useSettings(); 35 | 36 | return ( 37 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /components/cards/episode-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { Progress } from "@/components/ui/progress"; 4 | import { Play } from "lucide-react"; 5 | import { getCoverImage, useHubItem } from "@/hooks/use-hub-item"; 6 | 7 | export const EpisodePreviewItem: FC<{ 8 | selected?: boolean; 9 | item: Plex.Metadata; 10 | count: number; 11 | }> = ({ selected = false, item, count }) => { 12 | const { play, progress, duration } = useHubItem(item); 13 | return ( 14 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /components/cards/metadata-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { getCoverImage, useHubItem } from "@/hooks/use-hub-item"; 3 | 4 | const MetadataPreviewItem = forwardRef< 5 | HTMLDivElement, 6 | { item: Plex.Metadata | Plex.HubMetadata } 7 | >(({ item }, ref) => { 8 | const { isSeason, isShow, isMovie, isEpisode, quality, open } = 9 | useHubItem(item); 10 | 11 | return ( 12 |
16 | 69 |
70 | ); 71 | }); 72 | 73 | export { MetadataPreviewItem }; 74 | -------------------------------------------------------------------------------- /components/cards/on-deck-image-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo } from "react"; 2 | import { extractClearLogo, getCoverImage } from "@/hooks/use-hub-item"; 3 | import { ElementImagePreviewItem } from "@/components/cards/element-image-preview-item"; 4 | import * as React from "react"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export const OnDeckImagePreviewItem: FC< 8 | Omit< 9 | React.ComponentPropsWithoutRef, 10 | "image" 11 | > & { higherResolution?: boolean } 12 | > = ({ item, className, higherResolution, ...rest }) => { 13 | const clearLogo = extractClearLogo(item); 14 | const image = useMemo(() => { 15 | if (item.type === "movie") 16 | return getCoverImage(item.art, false, higherResolution); 17 | if (item.type === "episode") 18 | return getCoverImage( 19 | (clearLogo && item.thumb) ?? item.thumb ?? item.art, 20 | false, 21 | higherResolution, 22 | ); 23 | return getCoverImage( 24 | item.grandparentArt ?? item.art, 25 | false, 26 | higherResolution, 27 | ); 28 | }, [item]); 29 | 30 | return ( 31 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /components/cards/other-image-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo } from "react"; 2 | import { getPosterImage } from "@/hooks/use-hub-item"; 3 | import { cn } from "@/lib/utils"; 4 | import * as React from "react"; 5 | import { ElementImagePreviewItem } from "@/components/cards/element-image-preview-item"; 6 | 7 | export const OtherImagePreviewItem: FC< 8 | Omit< 9 | React.ComponentPropsWithoutRef, 10 | "image" 11 | > & { higherResolution?: boolean } 12 | > = ({ item, higherResolution, className, ...rest }) => { 13 | const image = useMemo(() => { 14 | if (item.type === "episode") 15 | return getPosterImage( 16 | item.parentThumb ?? item.grandparentThumb ?? item.thumb, 17 | false, 18 | higherResolution, 19 | ); 20 | if (item.type === "season") 21 | return getPosterImage( 22 | item.thumb ?? item.parentThumb, 23 | false, 24 | higherResolution, 25 | ); 26 | return getPosterImage(item.thumb, false, higherResolution); 27 | }, [item]); 28 | 29 | return ( 30 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /components/cards/season-preview-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { usePathname, useRouter } from "next/navigation"; 3 | import { getPosterImage } from "@/hooks/use-hub-item"; 4 | 5 | export const SeasonPreviewItem: FC<{ season: Plex.Metadata }> = ({ 6 | season, 7 | }) => { 8 | const router = useRouter(); 9 | const pathname = usePathname(); 10 | 11 | return ( 12 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /components/carousel/carousel-item-hover.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, useContext, useEffect, useRef, useState } from "react"; 2 | import { lerp } from "@/lib/utils"; 3 | import { createPortal } from "react-dom"; 4 | import { CarouselContext } from "@/components/carousel"; 5 | 6 | const CarouselItemHover: FC<{ 7 | open: boolean; 8 | top: number; 9 | left: number; 10 | width: number; 11 | height: number; 12 | children: ReactNode; 13 | onLeave: () => void; 14 | duration: number; 15 | isFirst: boolean; 16 | isLast: boolean; 17 | refKey: string; 18 | }> = ({ 19 | open, 20 | children, 21 | onLeave, 22 | duration, 23 | width, 24 | height, 25 | top, 26 | left, 27 | isFirst, 28 | isLast, 29 | refKey, 30 | }) => { 31 | const { scale } = useContext(CarouselContext); 32 | const hoverRef = useRef(null); 33 | const [current, setCurrent] = useState({ 34 | width, 35 | top: top, 36 | left, 37 | scale: 1, 38 | opacity: 0, 39 | visible: open, 40 | }); 41 | const [windowScrollTopOnOpen, setWindowScrollTopOnOpen] = useState( 42 | window.scrollY, 43 | ); 44 | 45 | useEffect(() => { 46 | if (!hoverRef.current) return; 47 | 48 | hoverRef.current.addEventListener("mouseleave", onLeave); 49 | return () => { 50 | hoverRef.current?.removeEventListener("mouseleave", onLeave); 51 | }; 52 | }, [hoverRef.current]); 53 | 54 | useEffect(() => { 55 | const getLeft = () => { 56 | if (isFirst && isLast) { 57 | return left; 58 | } 59 | 60 | if (isFirst) { 61 | return left + width * (1 + (scale - 1) / 2) - width - 2; 62 | } 63 | 64 | if (isLast) { 65 | return left - (width * (1 + (scale - 1) / 2) - width) + 2; 66 | } 67 | 68 | return left; 69 | }; 70 | 71 | if (open) { 72 | setWindowScrollTopOnOpen(window.scrollY); 73 | } 74 | 75 | const target = open 76 | ? { 77 | width: width, 78 | scale: scale, 79 | top: top, 80 | left: getLeft(), 81 | opacity: 1, 82 | visible: true, 83 | } 84 | : { 85 | width, 86 | scale: 1, 87 | top: top, 88 | left, 89 | opacity: 0, 90 | visible: false, 91 | }; 92 | 93 | let frameId: number; 94 | const d = duration; // animation duration in ms 95 | const startTime = performance.now(); 96 | 97 | const animate = () => { 98 | const now = performance.now(); 99 | const t = Math.min((now - startTime) / (target.visible ? d : d / 3), 1); // normalize time to range [0, 1] 100 | 101 | setCurrent((prev) => ({ 102 | width: lerp(prev.width, target.width, t), 103 | scale: lerp(prev.scale, target.scale, t), 104 | top: lerp(prev.top, target.top, t), 105 | left: lerp(prev.left, target.left, t), 106 | opacity: lerp(prev.opacity, target.opacity, t), 107 | visible: open 108 | ? target.visible 109 | : t > 0.96 110 | ? target.visible 111 | : prev.visible, 112 | })); 113 | 114 | if (t < 1) { 115 | frameId = requestAnimationFrame(animate); 116 | } 117 | }; 118 | 119 | frameId = requestAnimationFrame(animate); 120 | return () => { 121 | cancelAnimationFrame(frameId); 122 | }; 123 | }, [open, width, top, left]); 124 | 125 | if (!current.visible) return null; 126 | 127 | return createPortal( 128 |
139 | {children} 140 |
, 141 | document.body, 142 | refKey, 143 | ); 144 | }; 145 | 146 | export default CarouselItemHover; 147 | -------------------------------------------------------------------------------- /components/carousel/carousel-item.tsx: -------------------------------------------------------------------------------- 1 | import { FC, forwardRef, ReactNode, useEffect, useRef, useState } from "react"; 2 | import { CarouselItemHover, useCarouselItem } from "@/components/carousel"; 3 | 4 | const CarouselItem = forwardRef< 5 | HTMLDivElement, 6 | { 7 | children: ReactNode; 8 | hoverview: ReactNode; 9 | index: number; 10 | refKey: string; 11 | } 12 | >(({ children, hoverview, index, refKey }, ref) => { 13 | const { size, isFirst, isLast, open, close, isOpen } = useCarouselItem( 14 | index, 15 | refKey, 16 | ); 17 | const itemRef = useRef(null); 18 | const [origin, setOrigin] = useState({ 19 | top: 0, 20 | left: 0, 21 | height: 0, 22 | }); 23 | const d = 350; 24 | 25 | useEffect(() => { 26 | if (!itemRef.current) return; 27 | 28 | let timer: NodeJS.Timeout; 29 | 30 | const enter = () => { 31 | if (itemRef.current) { 32 | const height = itemRef.current.offsetHeight; 33 | const top = itemRef.current.getBoundingClientRect().top; 34 | const left = itemRef.current.getBoundingClientRect().left; 35 | setOrigin({ top, left, height }); 36 | } 37 | 38 | timer = setTimeout(open, d); 39 | }; 40 | const leave = () => { 41 | clearTimeout(timer); 42 | }; 43 | 44 | itemRef.current.addEventListener("pointerenter", enter); 45 | itemRef.current.addEventListener("pointerleave", leave); 46 | 47 | return () => { 48 | itemRef.current?.removeEventListener("pointerenter", enter); 49 | itemRef.current?.removeEventListener("pointerleave", leave); 50 | }; 51 | }, [itemRef.current]); 52 | 53 | return ( 54 |
63 |
64 | {hoverview && itemRef.current && ( 65 | 77 | {hoverview} 78 | 79 | )} 80 | {children} 81 |
82 | ); 83 | }); 84 | 85 | export default CarouselItem; 86 | -------------------------------------------------------------------------------- /components/carousel/carousel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | Dispatch, 4 | FC, 5 | ReactNode, 6 | SetStateAction, 7 | TouchEvent, 8 | useContext, 9 | useEffect, 10 | useMemo, 11 | useRef, 12 | useState, 13 | } from "react"; 14 | import { 15 | DESKTOP_BREAKPOINT, 16 | GIANT_BREAKPOINT, 17 | MOBILE_BREAKPOINT, 18 | TABLET_BREAKPOINT, 19 | TINY_BREAKPOINT, 20 | } from "@/hooks/use-is-size"; 21 | import { cn } from "@/lib/utils"; 22 | import { ChevronLeft, ChevronRight } from "lucide-react"; 23 | 24 | const CarouselWrapperContext = createContext( 25 | {} as { 26 | openIndex: string | null; 27 | setOpenIndex: Dispatch>; 28 | }, 29 | ); 30 | 31 | export const CarouselWrapper: FC<{ children: ReactNode }> = ({ children }) => { 32 | const [openIndex, setOpenIndex] = useState(null); 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | }; 40 | 41 | export const CarouselContext = createContext( 42 | {} as { 43 | size: number; 44 | firstIndex: number; 45 | lastIndex: number; 46 | spacing: number; 47 | scale: number; 48 | openIndex: string | null; 49 | open: (refKey: string) => void; 50 | close: () => void; 51 | }, 52 | ); 53 | 54 | const CarouselProvider: FC<{ 55 | children: ReactNode; 56 | size: number; 57 | firstIndex: number; 58 | lastIndex: number; 59 | spacing: number; 60 | scale: number; 61 | }> = ({ children, size, firstIndex, lastIndex, spacing, scale }) => { 62 | const { openIndex, setOpenIndex } = useContext(CarouselWrapperContext); 63 | 64 | const close = () => { 65 | setOpenIndex(null); 66 | }; 67 | 68 | const open = (refKey: string) => { 69 | setOpenIndex(refKey); 70 | }; 71 | 72 | return ( 73 | 85 | {children} 86 | 87 | ); 88 | }; 89 | 90 | export const useCarouselItem = (index: number, refKey: string) => { 91 | const { size, firstIndex, lastIndex, spacing, openIndex, open, close } = 92 | useContext(CarouselContext); 93 | 94 | const isOpen = useMemo(() => openIndex === refKey, [openIndex, refKey]); 95 | 96 | return { 97 | size: size - spacing, 98 | firstIndex, 99 | lastIndex, 100 | isFirst: index === firstIndex, 101 | isLast: index === lastIndex, 102 | spacing, 103 | isOpen, 104 | open: () => open(refKey), 105 | close, 106 | }; 107 | }; 108 | 109 | const Carousel: FC<{ 110 | children: ReactNode; 111 | edges?: number; 112 | spacing?: number; 113 | scale?: number; 114 | minimumVisibleItem?: number; 115 | }> = ({ 116 | children, 117 | edges = 40, 118 | spacing = 10, 119 | scale = 1.2, 120 | minimumVisibleItem = 1, 121 | }) => { 122 | const { close } = useContext(CarouselContext); 123 | const containerRef = useRef(null); 124 | const [currentIndex, setCurrentIndex] = useState(0); 125 | const [isAtEndOfScroll, setIsAtEndOfScroll] = useState(false); 126 | const [isAtStartOfScroll, setIsAtStartOfScroll] = useState(true); 127 | const [isContainerScrollable, setIsContainerScrollable] = useState(false); 128 | const [numberOfItemsVisible, setNumberOfItemsVisible] = useState(0); 129 | const [size, setSize] = useState(0); 130 | const [startMove, setStartMove] = useState(null); 131 | const [moved, setMoved] = useState(false); 132 | 133 | // MUST NOT TOUCH THIS USE EFFECT 134 | useEffect(() => { 135 | if (!containerRef.current) return; 136 | const onscroll = (baseSize = size) => { 137 | const container = containerRef.current; 138 | if (!container) { 139 | return; 140 | } 141 | 142 | setIsContainerScrollable(container.scrollWidth > container.offsetWidth); 143 | setIsAtStartOfScroll(container.scrollLeft === 0); 144 | setNumberOfItemsVisible( 145 | Math.floor((container.offsetWidth - edges * 2 + spacing) / baseSize), 146 | ); 147 | 148 | const isAtStart = container.scrollLeft === 0; 149 | setIsAtStartOfScroll(() => isAtStart); 150 | 151 | const scrollDelta = 152 | container.scrollLeft - (container.scrollWidth - container.offsetWidth); 153 | const isAtEnd = Math.min(Math.ceil(scrollDelta), 0) === 0; 154 | setIsAtEndOfScroll(() => isAtEnd); 155 | 156 | const maxScrollLeft = Math.max(container.scrollLeft - edges, 0); 157 | 158 | const itemWidthWithMargin = 159 | baseSize - spacing + (maxScrollLeft < spacing ? 0 : spacing); 160 | 161 | let index = Math.floor(maxScrollLeft / itemWidthWithMargin); 162 | 163 | if (maxScrollLeft % baseSize !== 0) { 164 | index++; 165 | } 166 | 167 | setCurrentIndex(() => index); 168 | }; 169 | 170 | onscroll(); 171 | 172 | const scrollbase = () => onscroll(); 173 | 174 | containerRef.current.addEventListener("scroll", scrollbase); 175 | return () => { 176 | containerRef.current?.removeEventListener("scroll", scrollbase); 177 | }; 178 | }, [size]); 179 | 180 | useEffect(() => { 181 | const wheelscroll = (event: WheelEvent) => { 182 | if (Math.abs(event.deltaX) !== 0) { 183 | event.preventDefault(); 184 | event.stopPropagation(); 185 | } 186 | }; 187 | 188 | containerRef.current?.addEventListener("wheel", wheelscroll, { 189 | passive: false, 190 | }); 191 | return () => { 192 | containerRef.current?.removeEventListener("wheel", wheelscroll); 193 | }; 194 | }, []); 195 | 196 | useEffect(() => { 197 | const calcSize = (divider: number) => { 198 | if (!containerRef.current) return 0; 199 | return Math.floor( 200 | (containerRef.current.offsetWidth - edges * 2) / divider, 201 | ); 202 | }; 203 | 204 | const sizechange = () => { 205 | let updatedSize: number; 206 | if (window.innerWidth < 520) { 207 | updatedSize = calcSize(minimumVisibleItem); 208 | } else if (window.innerWidth < TINY_BREAKPOINT) { 209 | updatedSize = calcSize(minimumVisibleItem + 1); 210 | } else if (window.innerWidth < MOBILE_BREAKPOINT) { 211 | updatedSize = calcSize(minimumVisibleItem + 2); 212 | } else if (window.innerWidth < TABLET_BREAKPOINT) { 213 | updatedSize = calcSize(minimumVisibleItem + 3); 214 | } else if (window.innerWidth < DESKTOP_BREAKPOINT) { 215 | updatedSize = calcSize(minimumVisibleItem + 4); 216 | } else if (window.innerWidth < GIANT_BREAKPOINT) { 217 | updatedSize = calcSize(minimumVisibleItem + 5); 218 | } else { 219 | updatedSize = calcSize(minimumVisibleItem + 6); 220 | } 221 | 222 | setSize(updatedSize); 223 | return updatedSize; 224 | }; 225 | 226 | sizechange(); 227 | 228 | const onresize = () => { 229 | const updatedSize = sizechange(); 230 | if (containerRef.current) { 231 | const updatedNumberOfItemsVisible = Math.floor( 232 | (containerRef.current.offsetWidth - edges * 2 + spacing) / 233 | updatedSize, 234 | ); 235 | setNumberOfItemsVisible(updatedNumberOfItemsVisible); 236 | containerRef.current.scroll(currentIndex * updatedSize, 0); 237 | } 238 | }; 239 | 240 | window.addEventListener("resize", onresize, { passive: true }); 241 | 242 | return () => { 243 | window.removeEventListener("resize", onresize); 244 | }; 245 | }, [currentIndex, minimumVisibleItem, edges]); 246 | 247 | const handlePrevious = () => { 248 | if (!containerRef.current) return; 249 | containerRef.current.scroll( 250 | currentIndex <= numberOfItemsVisible 251 | ? 0 252 | : Math.max((currentIndex - numberOfItemsVisible) * size, 0), 253 | 0, 254 | ); 255 | }; 256 | 257 | const handleNext = () => { 258 | if (!containerRef.current) return; 259 | containerRef.current.scroll( 260 | (currentIndex + numberOfItemsVisible) * size, 261 | 0, 262 | ); 263 | }; 264 | 265 | // TODO: fix on click/touch events and vertical scroll 266 | useEffect(() => { 267 | const onmove = (e: TouchEvent) => { 268 | e.preventDefault(); 269 | if (!moved) { 270 | if (startMove === null) { 271 | setStartMove(e.touches[0].clientX); 272 | } else { 273 | const diff = startMove - e.touches[0].clientX; 274 | const diffAbs = Math.abs(diff); 275 | if (diffAbs > 50) { 276 | if (diff < 0) { 277 | handlePrevious(); 278 | } else { 279 | handleNext(); 280 | } 281 | setMoved(true); 282 | } 283 | } 284 | } 285 | }; 286 | 287 | const touchend = () => { 288 | setMoved(false); 289 | setStartMove(null); 290 | }; 291 | 292 | const container = containerRef.current; 293 | if (container) { 294 | // @ts-ignore 295 | container.addEventListener("touchmove", onmove, { passive: false }); 296 | container.addEventListener("touchend", touchend); 297 | } 298 | 299 | return () => { 300 | if (container) { 301 | // @ts-ignore 302 | container.removeEventListener("touchmove", onmove); 303 | container.removeEventListener("touchend", touchend); 304 | } 305 | }; 306 | }, [startMove, moved]); 307 | 308 | return ( 309 |
310 | 331 | 350 |
359 | 366 | {size === 0 ? null : children} 367 | 368 |
369 |
370 | ); 371 | }; 372 | 373 | export default Carousel; 374 | -------------------------------------------------------------------------------- /components/carousel/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | default as Carousel, 3 | useCarouselItem, 4 | CarouselContext, 5 | } from "@/components/carousel/carousel"; 6 | export { default as CarouselItem } from "@/components/carousel/carousel-item"; 7 | export { default as CarouselItemHover } from "@/components/carousel/carousel-item-hover"; 8 | -------------------------------------------------------------------------------- /components/change-server-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "@/components/ui/dialog"; 10 | import { Button } from "@/components/ui/button"; 11 | import { Server } from "lucide-react"; 12 | import { DialogBody } from "next/dist/client/components/react-dev-overlay/internal/components/Dialog"; 13 | import { PlexConnection, PlexServer } from "@/type"; 14 | import { LibraryAndServer, useServer } from "@/components/server-provider"; 15 | import { ReactNode, useEffect, useState } from "react"; 16 | import { fetchConnectionLibrary } from "@/lib/server"; 17 | import { cn } from "@/lib/utils"; 18 | 19 | // helper function to format the connection URL for display 20 | const formatConnectionUrl = (connection: PlexConnection) => { 21 | try { 22 | const url = new URL(connection.uri); 23 | return `${url.hostname}`; 24 | } catch (e) { 25 | return connection.uri; 26 | } 27 | }; 28 | 29 | // helper function to sort and filter connections 30 | const getPreferredConnections = ( 31 | connections: PlexConnection[], 32 | userIp = undefined, 33 | ) => { 34 | // First, separate connections by type 35 | const local = connections.filter((c) => c.local && !c.relay); 36 | const remote = connections.filter((c) => !c.local && !c.relay); 37 | const relay = connections.filter((c) => c.relay); 38 | 39 | // Filter local connections to only include those that match the user's IP 40 | // const userLocal = local.filter((c) => userIp && c.address === userIp); 41 | 42 | // For each type, prioritize HTTPS over HTTP 43 | const sortByProtocol = (conns: PlexConnection[]) => { 44 | return conns.sort((a, b) => { 45 | if (a.protocol === "https" && b.protocol !== "https") return -1; 46 | if (a.protocol !== "https" && b.protocol === "https") return 1; 47 | return 0; 48 | }); 49 | }; 50 | 51 | // Combine in priority order: userLocal -> remote -> relay 52 | // Only include relay if no other options are available 53 | const preferred = [...sortByProtocol(local), ...sortByProtocol(remote)]; 54 | return preferred.length > 0 ? preferred : sortByProtocol(relay); 55 | }; 56 | 57 | function ConnectionButton({ 58 | server, 59 | connection, 60 | selected, 61 | onSelect, 62 | }: { 63 | server: PlexServer; 64 | connection: PlexConnection; 65 | selected: LibraryAndServer; 66 | onSelect: (info: LibraryAndServer) => void; 67 | }) { 68 | const [status, setStatus] = useState<"loading" | "success" | "fail">( 69 | "loading", 70 | ); 71 | const [libraries, setLibraries] = useState([]); 72 | 73 | useEffect(() => { 74 | fetchConnectionLibrary({ connection, server }).then((info) => { 75 | if (info) { 76 | setStatus("success"); 77 | setLibraries(info.libraries); 78 | } else { 79 | setStatus("fail"); 80 | } 81 | }); 82 | }, []); 83 | 84 | return ( 85 | 100 | ); 101 | } 102 | 103 | export function ChangeServerDialog({ trigger }: { trigger: ReactNode }) { 104 | const { servers, server, handleServerSelection } = useServer(); 105 | const [selected, setSelected] = useState(server); 106 | return ( 107 | 108 | {trigger} 109 | 110 | 111 | Select Server 112 | 113 | Choose a plex server to connect to. 114 | 115 | 116 | 117 | {servers.map((info) => { 118 | const connections = getPreferredConnections(info.connections); 119 | 120 | if (connections.length === 0) return null; 121 | return ( 122 |
123 |
124 | {info.name} 125 |
126 | {connections.map((connection) => ( 127 | 134 | ))} 135 |
136 | ); 137 | })} 138 |
139 | 140 | 148 | 149 |
150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /components/hero.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type CSSProperties, FC, useEffect, useRef, useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Info, Play } from "lucide-react"; 6 | import { ServerApi } from "@/api"; 7 | import { useQuery } from "@tanstack/react-query"; 8 | import { useHubItem } from "@/hooks/use-hub-item"; 9 | import { APPBAR_HEIGHT } from "@/components/appbar"; 10 | import { useSettings } from "@/components/settings-provider"; 11 | 12 | export const Hero: FC<{ item: Plex.Metadata }> = ({ item }) => { 13 | const { disableClearLogo } = useSettings(); 14 | const metadata = useQuery({ 15 | queryKey: ["metadata", item.ratingKey], 16 | queryFn: async () => { 17 | return ServerApi.metadata({ id: item.ratingKey }).then((res) => res); 18 | }, 19 | }); 20 | 21 | const { play, coverImage, clearLogo, playable, open } = useHubItem( 22 | metadata.data ?? item, 23 | { 24 | fullSize: true, 25 | }, 26 | ); 27 | 28 | const summaryRef = useRef(null); 29 | const [hideSummary, setHideSummary] = useState(false); 30 | const [summaryHeight, setSummaryHeight] = useState(0); 31 | 32 | useEffect(() => { 33 | if (summaryRef.current && summaryHeight === 0) { 34 | setSummaryHeight(summaryRef.current.clientHeight); 35 | } 36 | 37 | const timer = setTimeout(() => { 38 | setHideSummary(true); 39 | }, 5000); 40 | 41 | return () => clearTimeout(timer); 42 | }, []); 43 | 44 | return ( 45 |
46 |
47 | preview image 48 |
55 |
63 |
64 |
65 | 88 |

94 | {item.summary} 95 |

96 |
97 | {metadata.data && ( 98 | 109 | )} 110 | 118 |
119 |
120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode, Suspense, useEffect } from "react"; 4 | import ThemeProvider from "@/components/theme-provier"; 5 | import { AuthProvider } from "@/components/auth-provider"; 6 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 7 | import { SessionProvider } from "@/hooks/use-session"; 8 | import { uuid, uuidv4 } from "@/lib/utils"; 9 | import { MetaScreen } from "@/components/screens/meta-screen"; 10 | import { useSearchParams } from "next/navigation"; 11 | import { WatchScreen } from "@/components/screens/watch-screen"; 12 | import { LibraryScreen } from "@/components/screens/library-screen"; 13 | import { CarouselWrapper } from "@/components/carousel/carousel"; 14 | import { SearchProvider } from "@/components/search-provider"; 15 | import { SettingsProvider } from "@/components/settings-provider"; 16 | 17 | const client = new QueryClient(); 18 | 19 | export default function Providers({ children }: { children: ReactNode }) { 20 | const searchParams = useSearchParams(); 21 | const watch = searchParams.get("watch"); 22 | const key = searchParams.get("key"); 23 | const libtitle = searchParams.get("libtitle"); 24 | const contentDirectoryID = searchParams.get("contentDirectoryID"); 25 | 26 | useEffect(() => { 27 | if (!localStorage.getItem("clientId")) { 28 | localStorage.setItem("clientId", uuidv4()); 29 | } 30 | 31 | if (!sessionStorage.getItem("sessionId")) { 32 | sessionStorage.setItem("sessionId", uuid()); 33 | } 34 | }, []); 35 | 36 | return ( 37 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | {children} 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/screens/library-screen.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; 3 | import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import { ScrollArea } from "@/components/ui/scroll-area"; 6 | import { Button } from "@/components/ui/button"; 7 | import { ArrowLeft, X } from "lucide-react"; 8 | import { Skeleton } from "@/components/ui/skeleton"; 9 | import { MetadataPreviewItem } from "@/components/cards/metadata-preview-item"; 10 | import { useItemKeyMetadata } from "@/hooks/use-item-key-metadata"; 11 | import { CollectionPreviewItem } from "@/components/cards/collection-preview-item"; 12 | 13 | export const LibraryScreen: FC<{ 14 | keypath: string | undefined; 15 | title: string | undefined; 16 | contentDirectoryID: string | undefined; 17 | }> = ({ keypath: key, title, contentDirectoryID }) => { 18 | const router = useRouter(); 19 | const pathname = usePathname(); 20 | const { loading, metadata, lastRef } = useItemKeyMetadata( 21 | key, 22 | contentDirectoryID, 23 | ); 24 | 25 | return ( 26 | { 29 | if (!open) router.back(); 30 | }} 31 | > 32 | 33 | 34 | Item metadata dialog 35 | 36 | 37 |
38 |
39 | 47 |
48 |
49 | 57 |
58 |
59 | {title &&

{title}

} 60 |
61 | {loading && 62 | Array.from({ length: 5 }).map((_, i) => ( 63 | 64 | ))} 65 | {metadata.map((item, i) => 66 | item.type === "collection" ? ( 67 | void) 73 | : undefined 74 | } 75 | /> 76 | ) : ( 77 | void) 83 | : undefined 84 | } 85 | /> 86 | ), 87 | )} 88 |
89 |
90 |
91 |
92 |
93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /components/search-provider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | ReactNode, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import { 9 | Dialog, 10 | DialogClose, 11 | DialogContent, 12 | DialogTitle, 13 | } from "@/components/ui/dialog"; 14 | import { Button } from "@/components/ui/button"; 15 | import { SearchIcon, X } from "lucide-react"; 16 | import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; 17 | import { 18 | Command, 19 | CommandEmpty, 20 | CommandItem, 21 | CommandList, 22 | } from "@/components/ui/command"; 23 | import { ScrollArea } from "@/components/ui/scroll-area"; 24 | import qs from "qs"; 25 | import { usePathname, useRouter } from "next/navigation"; 26 | import { ServerApi } from "@/api"; 27 | 28 | const Context = createContext( 29 | {} as { 30 | open: boolean; 31 | onOpen: (isOpen: boolean | ((prev: boolean) => boolean)) => void; 32 | }, 33 | ); 34 | 35 | export function SearchProvider({ children }: { children: ReactNode }) { 36 | const [open, setOpen] = useState(false); 37 | const [query, setQuery] = useState(""); 38 | const [results, setResults] = useState([]); 39 | const [domLoaded, setDomLoaded] = useState(false); 40 | const router = useRouter(); 41 | const pathname = usePathname(); 42 | 43 | const handleReset = () => { 44 | setResults([]); 45 | setQuery(""); 46 | }; 47 | 48 | const handleOpen = (value: boolean | ((prev: boolean) => boolean)) => { 49 | if (typeof value === "function") { 50 | setOpen((prev) => { 51 | const state = value(prev); 52 | if (!state) handleReset(); 53 | return state; 54 | }); 55 | } else { 56 | setOpen(value); 57 | if (!value) handleReset(); 58 | } 59 | }; 60 | 61 | useEffect(() => { 62 | if (typeof window !== "undefined") { 63 | setDomLoaded(true); 64 | } 65 | }, []); 66 | 67 | useEffect(() => { 68 | if (!domLoaded) return; 69 | 70 | const down = (e: KeyboardEvent) => { 71 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 72 | e.preventDefault(); 73 | handleOpen((open) => !open); 74 | } 75 | }; 76 | 77 | document.body.addEventListener("keydown", down); 78 | return () => document.removeEventListener("keydown", down); 79 | }, [domLoaded]); 80 | 81 | const handleSearch = (value: string) => { 82 | setQuery(value); 83 | 84 | ServerApi.search({ query: value }).then((res) => { 85 | if (!res) { 86 | setResults([]); 87 | return; 88 | } 89 | 90 | const valid = res.filter( 91 | (item) => 92 | item.Metadata && 93 | (item.Metadata.type === "movie" || 94 | item.Metadata.type === "show" || 95 | item.Metadata.type === "episode"), 96 | ); 97 | const ordered = valid.toSorted((a, b) => b.score - a.score); 98 | const mapped = ordered.map((item) => item.Metadata); 99 | const present = mapped.filter((elem) => elem !== undefined); 100 | setResults(present); 101 | }); 102 | }; 103 | 104 | const token = localStorage.getItem("token"); 105 | 106 | return ( 107 | 108 | {children} 109 | 110 | 111 | Search dialog 112 | 113 | 114 | 115 |
116 | 117 | handleSearch(value)} 121 | value={query} 122 | /> 123 |
124 | 125 | 126 | 127 |
128 | {results.length > 0 ? ( 129 | results.map((item, i) => ( 130 | 208 | )) 209 | ) : ( 210 | 211 | No results found. 212 | 213 | )} 214 |
215 |
216 |
217 |
218 | 219 | 220 | 226 | 227 |
228 |
229 |
230 | ); 231 | } 232 | 233 | export function useSearch() { 234 | return useContext(Context); 235 | } 236 | -------------------------------------------------------------------------------- /components/search.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { SearchIcon } from "lucide-react"; 3 | import { useSearch } from "@/components/search-provider"; 4 | 5 | export const Search = () => { 6 | const { onOpen } = useSearch(); 7 | 8 | return ( 9 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/server-provider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | ReactNode, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import { PlexConnection, PlexServer } from "@/type"; 9 | import { fetchAvailableServers, fetchExistingServer } from "@/lib/server"; 10 | import { Api } from "@/api"; 11 | import { XMLParser } from "fast-xml-parser"; 12 | import qs from "qs"; 13 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 14 | import { cn } from "@/lib/utils"; 15 | import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; 16 | import { 17 | InputOTP, 18 | InputOTPGroup, 19 | InputOTPSlot, 20 | } from "@/components/ui/input-otp"; 21 | 22 | export type LibraryAndServer = { 23 | libraries: Plex.LibrarySection[]; 24 | server: PlexServer; 25 | connection: PlexConnection; 26 | }; 27 | 28 | const Context = createContext( 29 | {} as { 30 | servers: PlexServer[]; 31 | server: LibraryAndServer; 32 | libraries: Plex.LibrarySection[]; 33 | disabledLibraries: { [key in string]: boolean }; 34 | toggleDisableLibrary: (title: string) => void; 35 | handleServerSelection: (server: LibraryAndServer) => void; 36 | }, 37 | ); 38 | 39 | export function ServerProvider({ children }: { children: ReactNode }) { 40 | const [servers, setServers] = useState([]); 41 | const [server, setServer] = useState(null); 42 | const [loading, setLoading] = useState(true); 43 | const [user, setUser] = useState(false); 44 | const [disabledLibraries, setDisabledLibraries] = useState<{ 45 | [key in string]: boolean; 46 | }>(JSON.parse(localStorage.getItem("disabledLibraries") ?? "{}")); 47 | 48 | useEffect(() => { 49 | setUser(!!localStorage.getItem("user-uuid")); 50 | const currentConnectionUri = localStorage.getItem("server") ?? ""; 51 | let controllers: AbortController[] = []; 52 | setLoading(true); 53 | fetchExistingServer(currentConnectionUri).then((currentInfo) => { 54 | if (currentInfo) { 55 | localStorage.setItem("server", currentInfo.connection.uri); 56 | localStorage.setItem("token", currentInfo.server.accessToken); 57 | setServer(currentInfo); 58 | } 59 | fetchAvailableServers() 60 | .then(({ list, info, controllers: aborts }) => { 61 | if (aborts) controllers = aborts; 62 | if (list.length === 0) { 63 | // TODO: error 64 | return; 65 | } 66 | if (!currentInfo && info) { 67 | localStorage.setItem("token", info.server.accessToken); 68 | localStorage.setItem("server", info.connection.uri); 69 | setServer(info); 70 | } 71 | setServers(list); 72 | }) 73 | .finally(() => { 74 | setLoading(false); 75 | }); 76 | }); 77 | 78 | return () => { 79 | controllers.forEach((controller) => controller.abort()); 80 | }; 81 | }, []); 82 | 83 | const toggleDisableLibrary = (title: string) => { 84 | const updated = { ...disabledLibraries }; 85 | updated[title] = !updated[title]; 86 | setDisabledLibraries(updated); 87 | localStorage.setItem("disabledLibraries", JSON.stringify(updated)); 88 | }; 89 | 90 | const handleServerSelection = (server: LibraryAndServer) => { 91 | setServer(server); 92 | localStorage.setItem("server", server.connection.uri); 93 | localStorage.setItem("token", server.server.accessToken); 94 | window.location.reload(); 95 | }; 96 | 97 | if (loading || !server) return null; 98 | 99 | if (!user) { 100 | return ; 101 | } 102 | 103 | return ( 104 | 114 | {children} 115 | 116 | ); 117 | } 118 | 119 | function mapUser( 120 | rec: Record, 121 | ): Pick { 122 | const token = localStorage.getItem("token"); 123 | const server = localStorage.getItem("server"); 124 | return { 125 | uuid: rec["@_uuid"] as string, 126 | hasPassword: rec["@_protected"] == 1, 127 | thumb: `${server}/photo/:/transcode?${qs.stringify({ 128 | width: 128, 129 | height: 128, 130 | url: rec["@_thumb"], 131 | minSize: 1, 132 | "X-Plex-Token": token, 133 | })}`, 134 | title: rec["@_title"] as string, 135 | }; 136 | } 137 | 138 | function UserSelect() { 139 | const [users, setUsers] = useState< 140 | Pick[] 141 | >([]); 142 | const [viewPassword, setViewPassword] = useState(null); 143 | const [loading, setLoading] = useState(false); 144 | 145 | const handleSubmit = ({ 146 | uuid, 147 | pin = undefined, 148 | }: { 149 | uuid: string; 150 | pin?: string; 151 | }) => { 152 | setLoading(true); 153 | Api.switch({ uuid, pin }) 154 | .then((res) => { 155 | localStorage.setItem("user-uuid", uuid); 156 | localStorage.setItem("uuid", uuid); 157 | localStorage.setItem("token", res.data.authToken); 158 | localStorage.setItem("auth-token", res.data.authToken); 159 | window.location.reload(); 160 | }) 161 | .catch((err) => { 162 | console.error(err); 163 | }) 164 | .finally(() => { 165 | setLoading(false); 166 | }); 167 | }; 168 | 169 | useEffect(() => { 170 | Api.users().then((res) => { 171 | // console.log(res); 172 | const parser = new XMLParser({ 173 | parseAttributeValue: true, 174 | ignoreAttributes: false, 175 | }); 176 | const obj = parser.parse(res.data); 177 | const mappedUsers: Pick< 178 | Plex.UserData, 179 | "uuid" | "title" | "thumb" | "hasPassword" 180 | >[] = Array.isArray(obj.MediaContainer.User) 181 | ? (obj.MediaContainer.User as Record[]).map(mapUser) 182 | : [mapUser(obj.MediaContainer.User as Record)]; 183 | setUsers(() => mappedUsers); 184 | }); 185 | }, []); 186 | 187 | return ( 188 |
189 |

Select a User

190 | {users.map((user, index) => ( 191 |
195 | 216 | {viewPassword === index && ( 217 |
{ 220 | e.preventDefault(); 221 | const data = Object.fromEntries( 222 | new FormData(e.target as HTMLFormElement), 223 | ) as unknown as { 224 | userPin: string; 225 | }; 226 | handleSubmit({ uuid: user.uuid, pin: data.userPin }); 227 | }} 228 | className={cn( 229 | "p-2 overflow-hidden transition-[height] flex gap-2 items-end", 230 | )} 231 | > 232 | { 240 | if (value.length === 4) { 241 | handleSubmit({ uuid: user.uuid, pin: value }); 242 | } 243 | }} 244 | disabled={loading} 245 | > 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
254 | )} 255 |
256 | ))} 257 |
258 | ); 259 | } 260 | 261 | export function useServer() { 262 | return useContext(Context); 263 | } 264 | -------------------------------------------------------------------------------- /components/settings-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useContext, useState } from "react"; 2 | 3 | const Context = createContext( 4 | {} as { 5 | updateDisableClearLogo: (value: boolean) => void; 6 | disableClearLogo: boolean; 7 | }, 8 | ); 9 | 10 | export function SettingsProvider({ children }: { children: ReactNode }) { 11 | const [disableClearLogo, setDisableClearLogo] = useState( 12 | localStorage.getItem("settings.disableClearLogo") === "true", 13 | ); 14 | 15 | const updateDisableClearLogo = (value: boolean) => { 16 | setDisableClearLogo(value); 17 | localStorage.setItem("settings.disableClearLogo", value.toString()); 18 | }; 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | } 26 | 27 | export function useSettings() { 28 | const context = useContext(Context); 29 | if (context === undefined) { 30 | throw new Error("useSettings must be used within a SettingsProvider"); 31 | } 32 | return context; 33 | } 34 | -------------------------------------------------------------------------------- /components/theme-provier.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | ThemeProvider as NextThemesProvider, 4 | ThemeProviderProps, 5 | } from "next-themes"; 6 | import { FC } from "react"; 7 | 8 | const ThemeProvider: FC = ({ children, ...props }) => { 9 | return {children}; 10 | }; 11 | 12 | export default ThemeProvider; 13 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground hover:bg-primary/90 font-semibold", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent/40 hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | search: 23 | "group backdrop-blur supports-[backdrop-filter]:bg-muted/40 text-muted-foreground hover:text-primary hover:border-primary/80 border font-semibold tracking-wide", 24 | plex: "group backdrop-blur supports-[backdrop-filter]:bg-plex text-primary border font-semibold tracking-wide", 25 | }, 26 | size: { 27 | default: "h-9 px-4 py-2", 28 | sm: "h-8 rounded-md px-3 text-xs", 29 | lg: "h-10 rounded-md px-8", 30 | icon: "h-9 w-9", 31 | "icon-sm": "h-6 w-6", 32 | none: "px-4 py-2", 33 | }, 34 | }, 35 | defaultVariants: { 36 | variant: "search", 37 | size: "default", 38 | }, 39 | }, 40 | ); 41 | 42 | export interface ButtonProps 43 | extends React.ButtonHTMLAttributes, 44 | VariantProps { 45 | asChild?: boolean; 46 | } 47 | 48 | const Button = React.forwardRef( 49 | ({ className, variant, size, asChild = false, ...props }, ref) => { 50 | const Comp = asChild ? Slot : "button"; 51 | return ( 52 | 57 | ); 58 | }, 59 | ); 60 | Button.displayName = "Button"; 61 | 62 | export { Button, buttonVariants }; 63 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; 5 | import { Check, ChevronRight, Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const ContextMenu = ContextMenuPrimitive.Root; 10 | 11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger; 12 | 13 | const ContextMenuGroup = ContextMenuPrimitive.Group; 14 | 15 | const ContextMenuPortal = ContextMenuPrimitive.Portal; 16 | 17 | const ContextMenuSub = ContextMenuPrimitive.Sub; 18 | 19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 20 | 21 | const ContextMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean; 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )); 40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; 41 | 42 | const ContextMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )); 55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; 56 | 57 | const ContextMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 62 | 70 | 71 | )); 72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; 73 | 74 | const ContextMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean; 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )); 90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; 91 | 92 | const ContextMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )); 113 | ContextMenuCheckboxItem.displayName = 114 | ContextMenuPrimitive.CheckboxItem.displayName; 115 | 116 | const ContextMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )); 136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; 137 | 138 | const ContextMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean; 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 153 | )); 154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; 155 | 156 | const ContextMenuSeparator = React.forwardRef< 157 | React.ElementRef, 158 | React.ComponentPropsWithoutRef 159 | >(({ className, ...props }, ref) => ( 160 | 165 | )); 166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; 167 | 168 | const ContextMenuShortcut = ({ 169 | className, 170 | ...props 171 | }: React.HTMLAttributes) => { 172 | return ( 173 | 180 | ); 181 | }; 182 | ContextMenuShortcut.displayName = "ContextMenuShortcut"; 183 | 184 | export { 185 | ContextMenu, 186 | ContextMenuTrigger, 187 | ContextMenuContent, 188 | ContextMenuItem, 189 | ContextMenuCheckboxItem, 190 | ContextMenuRadioItem, 191 | ContextMenuLabel, 192 | ContextMenuSeparator, 193 | ContextMenuShortcut, 194 | ContextMenuGroup, 195 | ContextMenuPortal, 196 | ContextMenuSub, 197 | ContextMenuSubContent, 198 | ContextMenuSubTrigger, 199 | ContextMenuRadioGroup, 200 | }; 201 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Dialog = DialogPrimitive.Root; 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger; 11 | 12 | const DialogPortal = DialogPrimitive.Portal; 13 | 14 | const DialogClose = DialogPrimitive.Close; 15 | 16 | const DialogOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )); 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 36 | 37 | 45 | {children} 46 | 47 | 48 | )); 49 | DialogContent.displayName = DialogPrimitive.Content.displayName; 50 | 51 | const DialogHeader = ({ 52 | className, 53 | ...props 54 | }: React.HTMLAttributes) => ( 55 |
62 | ); 63 | DialogHeader.displayName = "DialogHeader"; 64 | 65 | const DialogFooter = ({ 66 | className, 67 | ...props 68 | }: React.HTMLAttributes) => ( 69 |
76 | ); 77 | DialogFooter.displayName = "DialogFooter"; 78 | 79 | const DialogTitle = React.forwardRef< 80 | React.ElementRef, 81 | React.ComponentPropsWithoutRef 82 | >(({ className, ...props }, ref) => ( 83 | 91 | )); 92 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 93 | 94 | const DialogDescription = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 103 | )); 104 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 105 | 106 | export { 107 | Dialog, 108 | DialogPortal, 109 | DialogOverlay, 110 | DialogTrigger, 111 | DialogClose, 112 | DialogContent, 113 | DialogHeader, 114 | DialogFooter, 115 | DialogTitle, 116 | DialogDescription, 117 | }; 118 | -------------------------------------------------------------------------------- /components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ) 17 | Drawer.displayName = "Drawer" 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal 22 | 23 | const DrawerClose = DrawerPrimitive.Close 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )) 56 | DrawerContent.displayName = "DrawerContent" 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ) 67 | DrawerHeader.displayName = "DrawerHeader" 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ) 78 | DrawerFooter.displayName = "DrawerFooter" 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )) 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | } 119 | -------------------------------------------------------------------------------- /components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { OTPInput, OTPInputContext } from "input-otp"; 5 | import { Minus } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const InputOTP = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, containerClassName, ...props }, ref) => ( 13 | 22 | )); 23 | InputOTP.displayName = "InputOTP"; 24 | 25 | const InputOTPGroup = React.forwardRef< 26 | React.ElementRef<"div">, 27 | React.ComponentPropsWithoutRef<"div"> 28 | >(({ className, ...props }, ref) => ( 29 |
30 | )); 31 | InputOTPGroup.displayName = "InputOTPGroup"; 32 | 33 | const InputOTPSlot = React.forwardRef< 34 | React.ElementRef<"div">, 35 | React.ComponentPropsWithoutRef<"div"> & { index: number } 36 | >(({ index, className, ...props }, ref) => { 37 | const inputOTPContext = React.useContext(OTPInputContext); 38 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; 39 | 40 | return ( 41 |
50 | {char} 51 | {hasFakeCaret && ( 52 |
53 |
54 |
55 | )} 56 |
57 | ); 58 | }); 59 | InputOTPSlot.displayName = "InputOTPSlot"; 60 | 61 | const InputOTPSeparator = React.forwardRef< 62 | React.ElementRef<"div">, 63 | React.ComponentPropsWithoutRef<"div"> 64 | >(({ ...props }, ref) => ( 65 |
66 | 67 |
68 | )); 69 | InputOTPSeparator.displayName = "InputOTPSeparator"; 70 | 71 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; 72 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | }, 19 | ); 20 | Input.displayName = "Input"; 21 | 22 | export { Input }; 23 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /components/ui/navigation-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; 3 | import { cva } from "class-variance-authority"; 4 | import { ChevronDown } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { createPortal } from "react-dom"; 8 | import { APPBAR_HEIGHT } from "@/components/appbar"; 9 | 10 | const NavigationMenu = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, children, ...props }, ref) => ( 14 | 22 | {children} 23 | 24 | 25 | )); 26 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; 27 | 28 | const NavigationMenuList = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 40 | )); 41 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; 42 | 43 | const NavigationMenuItem = NavigationMenuPrimitive.Item; 44 | 45 | const navigationMenuTriggerStyle = cva( 46 | "group text-muted-foreground font-bold inline-flex h-9 w-max items-center justify-center px-4 py-2 text-sm transition-colors hover:text-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50", 47 | ); 48 | 49 | const NavigationMenuTrigger = React.forwardRef< 50 | React.ElementRef, 51 | React.ComponentPropsWithoutRef 52 | >(({ className, children, ...props }, ref) => ( 53 | 58 | {children}{" "} 59 | 64 | )); 65 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; 66 | 67 | const NavigationMenuContent = React.forwardRef< 68 | React.ElementRef, 69 | React.ComponentPropsWithoutRef 70 | >(({ className, children, ...props }, ref) => ( 71 | 79 | {children} 80 | 81 | )); 82 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; 83 | 84 | const NavigationMenuLink = NavigationMenuPrimitive.Link; 85 | 86 | const NavigationMenuViewport = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => 90 | createPortal( 91 | , 97 | document.body, 98 | ), 99 | ); 100 | NavigationMenuViewport.displayName = 101 | NavigationMenuPrimitive.Viewport.displayName; 102 | 103 | const NavigationMenuIndicator = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 115 |
116 | 117 | )); 118 | NavigationMenuIndicator.displayName = 119 | NavigationMenuPrimitive.Indicator.displayName; 120 | 121 | export { 122 | navigationMenuTriggerStyle, 123 | NavigationMenu, 124 | NavigationMenuList, 125 | NavigationMenuItem, 126 | NavigationMenuContent, 127 | NavigationMenuTrigger, 128 | NavigationMenuLink, 129 | NavigationMenuIndicator, 130 | NavigationMenuViewport, 131 | }; 132 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor; 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )); 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 34 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef< 11 | typeof ProgressPrimitive.Root & { color?: string } 12 | > 13 | >(({ className, value, color, ...props }, ref) => ( 14 | 22 | 26 | 27 | )); 28 | Progress.displayName = ProgressPrimitive.Root.displayName; 29 | 30 | export { Progress }; 31 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SelectPrimitive from "@radix-ui/react-select"; 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Select = SelectPrimitive.Root; 10 | 11 | const SelectGroup = SelectPrimitive.Group; 12 | 13 | const SelectValue = SelectPrimitive.Value; 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className, 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )); 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )); 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )); 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName; 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )); 100 | SelectContent.displayName = SelectPrimitive.Content.displayName; 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )); 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )); 134 | SelectItem.displayName = SelectPrimitive.Item.displayName; 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )); 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | }; 160 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SheetPrimitive from "@radix-ui/react-dialog"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Sheet = SheetPrimitive.Root; 10 | 11 | const SheetTrigger = SheetPrimitive.Trigger; 12 | 13 | const SheetClose = SheetPrimitive.Close; 14 | 15 | const SheetPortal = SheetPrimitive.Portal; 16 | 17 | const SheetOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; 31 | 32 | const sheetVariants = cva( 33 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 34 | { 35 | variants: { 36 | side: { 37 | top: "inset-x-0 top-0 data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 38 | bottom: 39 | "inset-x-0 bottom-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 40 | left: "inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 41 | right: 42 | "inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 43 | }, 44 | }, 45 | defaultVariants: { 46 | side: "right", 47 | }, 48 | }, 49 | ); 50 | 51 | interface SheetContentProps 52 | extends React.ComponentPropsWithoutRef, 53 | VariantProps {} 54 | 55 | const SheetContent = React.forwardRef< 56 | React.ElementRef, 57 | SheetContentProps 58 | >(({ side = "right", className, children, ...props }, ref) => ( 59 | 60 | 61 | 66 | {children} 67 | 68 | 69 | )); 70 | SheetContent.displayName = SheetPrimitive.Content.displayName; 71 | 72 | const SheetHeader = ({ 73 | className, 74 | ...props 75 | }: React.HTMLAttributes) => ( 76 |
83 | ); 84 | SheetHeader.displayName = "SheetHeader"; 85 | 86 | const SheetFooter = ({ 87 | className, 88 | ...props 89 | }: React.HTMLAttributes) => ( 90 |
97 | ); 98 | SheetFooter.displayName = "SheetFooter"; 99 | 100 | const SheetTitle = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )); 110 | SheetTitle.displayName = SheetPrimitive.Title.displayName; 111 | 112 | const SheetDescription = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, ...props }, ref) => ( 116 | 121 | )); 122 | SheetDescription.displayName = SheetPrimitive.Description.displayName; 123 | 124 | export { 125 | Sheet, 126 | SheetPortal, 127 | SheetOverlay, 128 | SheetTrigger, 129 | SheetClose, 130 | SheetContent, 131 | SheetHeader, 132 | SheetFooter, 133 | SheetTitle, 134 | SheetDescription, 135 | }; 136 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SliderPrimitive from "@radix-ui/react-slider"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Slider = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, orientation, ...props }, ref) => ( 12 | 21 | 24 | 25 | 26 | 27 | 28 | )); 29 | Slider.displayName = SliderPrimitive.Root.displayName; 30 | 31 | export { Slider }; 32 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Separator } from "@/components/ui/separator"; 8 | 9 | const Tabs = TabsPrimitive.Root; 10 | 11 | const TabsList = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, children, ...props }, ref) => ( 15 | <> 16 | 24 | {children} 25 | 26 | 27 | 28 | )); 29 | TabsList.displayName = TabsPrimitive.List.displayName; 30 | 31 | const TabsTrigger = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, ...props }, ref) => ( 35 | 43 | )); 44 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 45 | 46 | const TabsContent = React.forwardRef< 47 | React.ElementRef, 48 | React.ComponentPropsWithoutRef 49 | >(({ className, ...props }, ref) => ( 50 | 58 | )); 59 | TabsContent.displayName = TabsPrimitive.Content.displayName; 60 | 61 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 62 | -------------------------------------------------------------------------------- /components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" 5 | import { type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { toggleVariants } from "@/components/ui/toggle" 9 | 10 | const ToggleGroupContext = React.createContext< 11 | VariantProps 12 | >({ 13 | size: "default", 14 | variant: "default", 15 | }) 16 | 17 | const ToggleGroup = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef & 20 | VariantProps 21 | >(({ className, variant, size, children, ...props }, ref) => ( 22 | 27 | 28 | {children} 29 | 30 | 31 | )) 32 | 33 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName 34 | 35 | const ToggleGroupItem = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef & 38 | VariantProps 39 | >(({ className, children, variant, size, ...props }, ref) => { 40 | const context = React.useContext(ToggleGroupContext) 41 | 42 | return ( 43 | 54 | {children} 55 | 56 | ) 57 | }) 58 | 59 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName 60 | 61 | export { ToggleGroup, ToggleGroupItem } 62 | -------------------------------------------------------------------------------- /components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TogglePrimitive from "@radix-ui/react-toggle"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center gap-2 rounded text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "backdrop-blur supports-[backdrop-filter]:bg-muted/40 text-primary/50 data-[state=off]:hover:text-primary/80 data-[state=on]:backdrop-blur data-[state=on]:supports-[backdrop-filter]:bg-muted/40 data-[state=on]:text-primary data-[state=on]:border-primary/60 data-[state=on]:border font-semibold", 17 | }, 18 | size: { 19 | default: "h-9 px-2 min-w-9", 20 | sm: "px-2 py-1", 21 | lg: "h-10 px-2.5 min-w-10", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | }, 29 | ); 30 | 31 | const Toggle = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef & 34 | VariantProps 35 | >(({ className, variant, size, ...props }, ref) => ( 36 | 41 | )); 42 | 43 | Toggle.displayName = TogglePrimitive.Root.displayName; 44 | 45 | export { Toggle, toggleVariants }; 46 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )); 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 33 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | environment: 6 | NODE_ENV: production 7 | ports: 8 | - 3000:3000 -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | export const PLEX = { 2 | application: "plexy", 3 | }; 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/demo.gif -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/demo.png -------------------------------------------------------------------------------- /fonts/NetflixSans-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/fonts/NetflixSans-Bold.otf -------------------------------------------------------------------------------- /fonts/NetflixSans-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/fonts/NetflixSans-Light.otf -------------------------------------------------------------------------------- /fonts/NetflixSans-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/fonts/NetflixSans-Medium.otf -------------------------------------------------------------------------------- /fonts/NetflixSans-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/fonts/NetflixSans-Regular.otf -------------------------------------------------------------------------------- /hooks/use-hub-item.ts: -------------------------------------------------------------------------------- 1 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 2 | import qs from "qs"; 3 | import { useState } from "react"; 4 | import { ServerApi } from "@/api"; 5 | 6 | export const getCoverImage = ( 7 | url: string, 8 | fullSize: boolean = false, 9 | higherResolution: boolean = false, 10 | ) => { 11 | const token = localStorage.getItem("token"); 12 | const server = localStorage.getItem("server"); 13 | 14 | if (fullSize) return `${server}${url}?X-Plex-Token=${token}`; 15 | 16 | const width = 300, 17 | height = 168; 18 | 19 | return `${server}/photo/:/transcode?${qs.stringify({ 20 | width: higherResolution ? width * 1.5 : width, 21 | height: higherResolution ? height * 1.5 : height, 22 | url: `${url}?X-Plex-Token=${token}`, 23 | minSize: 1, 24 | upscale: 1, 25 | "X-Plex-Token": token, 26 | })}`; 27 | }; 28 | 29 | export const getPosterImage = ( 30 | url: string, 31 | fullSize: boolean = false, 32 | higherResolution: boolean = false, 33 | ) => { 34 | const token = localStorage.getItem("token"); 35 | const server = localStorage.getItem("server"); 36 | 37 | if (fullSize) return `${server}${url}?X-Plex-Token=${token}`; 38 | 39 | const width = 300, 40 | height = 450; 41 | 42 | return `${server}/photo/:/transcode?${qs.stringify({ 43 | width: higherResolution ? width * 1.5 : width, 44 | height: higherResolution ? height * 1.5 : height, 45 | url: `${url}?X-Plex-Token=${token}`, 46 | minSize: 1, 47 | upscale: 1, 48 | "X-Plex-Token": token, 49 | })}`; 50 | }; 51 | 52 | type Item = Plex.Metadata | Plex.HubMetadata; 53 | 54 | type IsType = { 55 | episode: boolean; 56 | season: boolean; 57 | show: boolean; 58 | movie: boolean; 59 | }; 60 | 61 | const extractIsType = (type: Plex.LibraryType): IsType => { 62 | const episode = type === "episode"; 63 | const season = type === "season"; 64 | const show = type === "show"; 65 | const movie = type === "movie"; 66 | return { episode, season, show, movie }; 67 | }; 68 | 69 | const extractGuidNumber = (inputString: string | undefined) => { 70 | if (!inputString) return null; 71 | const match = inputString.match(/plex:\/\/\w+\/([a-zA-Z0-9]+)/); 72 | return match ? match[1] : null; 73 | }; 74 | 75 | export const extractClearLogo = (item: Plex.Metadata | Plex.HubMetadata) => { 76 | const logoUrl = item?.Image?.find((i) => i.type === "clearLogo")?.url; 77 | if (logoUrl) { 78 | const token = localStorage.getItem("token"); 79 | const server = localStorage.getItem("server"); 80 | 81 | return `${server}${logoUrl}?X-Plex-Token=${token}`; 82 | } 83 | return null; 84 | }; 85 | 86 | const extractProgress = (isType: IsType, item: Item): number => { 87 | if (isType.movie || isType.episode) { 88 | if (item.viewOffset) 89 | return Math.floor((item.viewOffset / item.duration) * 100); 90 | if ((item.viewCount ?? 0) >= 1) return 100; 91 | } 92 | return 0; 93 | }; 94 | 95 | const extractWatched = ( 96 | isType: IsType, 97 | item: Item, 98 | progress: number, 99 | ): boolean => { 100 | if (isType.show || isType.season) { 101 | return item.leafCount === item.viewedLeafCount; 102 | } 103 | if (isType.movie || isType.episode) { 104 | return progress === 100; 105 | } 106 | return false; 107 | }; 108 | 109 | const extractChildCount = (item: Item): number | null => { 110 | return item.childCount ?? null; 111 | }; 112 | 113 | const extractLeafCount = (item: Item): number | null => { 114 | return item.leafCount ?? null; 115 | }; 116 | 117 | type ItemDuration = { 118 | total: number; // total minute 119 | minutes: number; // minutes without hours 120 | hours: number; // hours 121 | }; 122 | 123 | const extractDuration = (isType: IsType, item: Item): ItemDuration | null => { 124 | if (isType.movie || isType.episode) { 125 | const total = Math.floor(item.duration / 1000 / 60); 126 | const hours = Math.floor(total / 60); 127 | return { total, hours, minutes: total - hours * 60 }; 128 | } 129 | return null; 130 | }; 131 | 132 | export const extractCoverImage = ( 133 | isType: IsType, 134 | item: Item, 135 | fullSize: boolean = false, 136 | higherResolution: boolean = false, 137 | ): string => { 138 | if (isType.movie || isType.episode) { 139 | return getCoverImage(item.art, fullSize, higherResolution); 140 | } 141 | return getCoverImage( 142 | item.grandparentArt ?? item.art, 143 | fullSize, 144 | higherResolution, 145 | ); 146 | }; 147 | 148 | export const extractPosterImage = ( 149 | isType: IsType, 150 | item: Item, 151 | fullSize: boolean = false, 152 | higherResolution: boolean = false, 153 | ): string => { 154 | if (isType.episode) { 155 | return getPosterImage( 156 | item.parentThumb ?? item.grandparentThumb ?? item.thumb, 157 | fullSize, 158 | higherResolution, 159 | ); 160 | } 161 | if (isType.season) { 162 | return getPosterImage( 163 | item.thumb ?? item.parentThumb, 164 | fullSize, 165 | higherResolution, 166 | ); 167 | } 168 | return getPosterImage(item.thumb, fullSize, higherResolution); 169 | }; 170 | 171 | type Playable = { 172 | season: number | null; 173 | episode: number | null; 174 | viewOffset: number | null; 175 | ratingKey: number | string | null; 176 | }; 177 | 178 | const extractPlayable = (isType: IsType, item: Item): Playable => { 179 | let viewOffset = item.viewOffset ?? null; 180 | let ratingKey = item.ratingKey; 181 | let episode = null; 182 | let season = null; 183 | if ((isType.show || isType.season) && item.OnDeck?.Metadata) { 184 | if (item.Children?.size) { 185 | season = item.OnDeck.Metadata.parentIndex ?? null; 186 | } 187 | episode = item.OnDeck.Metadata.index ?? null; 188 | viewOffset = item.OnDeck.Metadata.viewOffset ?? null; 189 | ratingKey = item.OnDeck.Metadata.ratingKey ?? null; 190 | } 191 | return { viewOffset, ratingKey, episode, season }; 192 | }; 193 | 194 | const extractQuality = (isType: IsType, item: Item): string | null => { 195 | if ((isType.movie || isType.episode) && item.Media && item.Media.length > 0) { 196 | return item.Media[0].videoResolution; 197 | } 198 | return null; 199 | }; 200 | 201 | export type HubItemInfo = 202 | | { 203 | isEpisode: boolean; 204 | isSeason: boolean; 205 | isShow: boolean; 206 | isMovie: boolean; 207 | guid: null; 208 | watched: null; 209 | progress: null; 210 | childCount: null; 211 | leafCount: null; 212 | duration: null; 213 | quality: null; 214 | playable: null; 215 | coverImage: string; 216 | posterImage: string; 217 | clearLogo: null; 218 | play: () => null; 219 | open: () => null; 220 | } 221 | | { 222 | isEpisode: boolean; 223 | isSeason: boolean; 224 | isShow: boolean; 225 | isMovie: boolean; 226 | guid: null | string; 227 | watched: boolean; 228 | progress: number; 229 | childCount: number | null; 230 | leafCount: number | null; 231 | duration: ItemDuration | null; 232 | quality: string | null; 233 | playable: Playable; 234 | coverImage: string; 235 | posterImage: string; 236 | clearLogo: string | null; 237 | play: () => void; 238 | open: (mid?: string) => void; 239 | }; 240 | 241 | export const useHubItem = ( 242 | item?: Item | null | undefined, 243 | options: { fullSize?: boolean; higherResolution?: boolean } = {}, 244 | ): HubItemInfo => { 245 | const router = useRouter(); 246 | const pathname = usePathname(); 247 | const searchParams = useSearchParams(); 248 | 249 | if (!item) { 250 | return { 251 | isEpisode: false, 252 | isSeason: false, 253 | isShow: false, 254 | isMovie: false, 255 | guid: null, 256 | watched: null, 257 | progress: null, 258 | childCount: null, 259 | leafCount: null, 260 | duration: null, 261 | quality: null, 262 | playable: null, 263 | coverImage: "", 264 | posterImage: "", 265 | clearLogo: null, 266 | play: () => null, 267 | open: () => null, 268 | }; 269 | } 270 | 271 | const isType = extractIsType(item.type); 272 | const coverImage = extractCoverImage( 273 | isType, 274 | item, 275 | options.fullSize ?? false, 276 | options.higherResolution ?? false, 277 | ); 278 | const posterImage = extractPosterImage( 279 | isType, 280 | item, 281 | options.fullSize ?? false, 282 | options.higherResolution ?? false, 283 | ); 284 | const guid = extractGuidNumber(item.guid); 285 | const progress = extractProgress(isType, item); 286 | const watched = extractWatched(isType, item, progress); 287 | const childCount = extractChildCount(item); 288 | const leafCount = extractLeafCount(item); 289 | const duration = extractDuration(isType, item); 290 | const playable = extractPlayable(isType, item); 291 | const quality = extractQuality(isType, item); 292 | const clearLogo = extractClearLogo(item); 293 | 294 | const open = (mid: string = item.ratingKey) => { 295 | if (searchParams.get("mid") !== mid) { 296 | router.push(`${pathname}?mid=${mid}`, { 297 | scroll: false, 298 | }); 299 | } 300 | }; 301 | 302 | const play = () => { 303 | if (isType.movie) { 304 | router.push( 305 | `${pathname}?watch=${item.ratingKey}${item.viewOffset ? `&t=${item.viewOffset}` : ""}`, 306 | { scroll: false }, 307 | ); 308 | return; 309 | } 310 | if (isType.episode) { 311 | router.push( 312 | `${pathname}?watch=${item.ratingKey.toString()}${item.viewOffset ? `&t=${item.viewOffset}` : ""}`, 313 | { scroll: false }, 314 | ); 315 | return; 316 | } 317 | if (isType.show || isType.season) { 318 | if (item.OnDeck && item.OnDeck.Metadata) { 319 | router.push( 320 | `${pathname}?watch=${item.OnDeck.Metadata.ratingKey}${ 321 | item.OnDeck.Metadata.viewOffset 322 | ? `&t=${item.OnDeck.Metadata.viewOffset}` 323 | : "" 324 | }`, 325 | { scroll: false }, 326 | ); 327 | return; 328 | } 329 | const season = isType.season 330 | ? item 331 | : item.Children?.Metadata.find((s) => s.title !== "Specials"); 332 | if (!season) return; 333 | 334 | ServerApi.children({ 335 | id: season.ratingKey as string, 336 | }).then((eps) => { 337 | if (!eps) return; 338 | 339 | router.push(`${pathname}?watch=${eps[0].ratingKey}`, { 340 | scroll: false, 341 | }); 342 | return; 343 | }); 344 | } 345 | }; 346 | 347 | return { 348 | isEpisode: isType.episode, 349 | isSeason: isType.season, 350 | isShow: isType.show, 351 | isMovie: isType.movie, 352 | guid, 353 | watched, 354 | progress, 355 | childCount, 356 | leafCount, 357 | duration, 358 | quality, 359 | playable, 360 | coverImage, 361 | posterImage, 362 | clearLogo, 363 | play, 364 | open, 365 | }; 366 | }; 367 | 368 | export const extractLanguages = ( 369 | streams: Plex.Stream[], 370 | ): [Set, Set] => { 371 | const dub = new Set(); 372 | const sub = new Set(); 373 | streams.forEach((curr) => { 374 | if (curr.streamType === 2) { 375 | dub.add(curr.language ?? curr.displayTitle ?? curr.extendedDisplayTitle); 376 | } else if (curr.streamType === 3) { 377 | sub.add(curr.language ?? curr.displayTitle ?? curr.extendedDisplayTitle); 378 | } 379 | }); 380 | return [dub, sub]; 381 | }; 382 | 383 | export const useItemLanguages = () => { 384 | const [languages, setLanguages] = useState([]); 385 | const [subtitles, setSubtitles] = useState([]); 386 | 387 | const process = (streams: Plex.Stream[]) => { 388 | const [dub, sub] = extractLanguages(streams); 389 | setLanguages(Array.from(dub)); 390 | setSubtitles(Array.from(sub)); 391 | }; 392 | 393 | return { languages, subtitles, process }; 394 | }; 395 | -------------------------------------------------------------------------------- /hooks/use-hubs.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import axios from "axios"; 3 | import qs from "qs"; 4 | import { xprops } from "@/api"; 5 | import { uuid } from "@/lib/utils"; 6 | 7 | export const useHubs = (initialHubs: Plex.Hub[] | null) => { 8 | const [hubs, setHubs] = useState(null); 9 | const token = localStorage.getItem("token"); 10 | const server = localStorage.getItem("server"); 11 | 12 | useEffect(() => { 13 | if (!initialHubs) return; 14 | setHubs(initialHubs); 15 | }, [initialHubs]); 16 | 17 | const append = (hubIndex: number, updatedMetadata: Plex.HubMetadata[]) => { 18 | if (!hubs) return; 19 | if (hubIndex < 0 || hubIndex >= hubs.length) return; 20 | setHubs((prev) => { 21 | if (!prev) return null; 22 | const temp = [...prev]; 23 | temp[hubIndex] = { 24 | ...temp[hubIndex], 25 | Metadata: temp[hubIndex]?.Metadata 26 | ? temp[hubIndex].Metadata.reduce( 27 | (acc, item) => 28 | acc.concat([ 29 | updatedMetadata.find((i) => i.ratingKey === item.ratingKey) ?? 30 | item, 31 | ]), 32 | [] as Plex.HubMetadata[], 33 | ).concat( 34 | updatedMetadata.filter( 35 | (item) => 36 | !temp[hubIndex].Metadata!.some( 37 | (i) => i.ratingKey === item.ratingKey, 38 | ), 39 | ), 40 | ) 41 | : updatedMetadata, 42 | }; 43 | return temp; 44 | }); 45 | }; 46 | 47 | const reload = (hubIndex: number) => { 48 | if (!hubs) return; 49 | if (hubIndex < 0 || hubIndex >= hubs.length) return; 50 | const hub = hubs[hubIndex]; 51 | const decodedKey = decodeURIComponent(hub.key); 52 | axios 53 | .get<{ MediaContainer: { Metadata: Plex.HubMetadata[] } }>( 54 | `${server}${decodedKey}${decodedKey.includes("?") ? "&" : "?"}${qs.stringify( 55 | { 56 | ...xprops(), 57 | excludeFields: "summary", 58 | "X-Plex-Container-Start": 0, 59 | "X-Plex-Container-Size": hub.Metadata?.length ?? 50, 60 | uuid: uuid(), 61 | }, 62 | )}`, 63 | { headers: { "X-Plex-Token": token, accept: "application/json" } }, 64 | ) 65 | .then((res) => { 66 | setHubs((prev) => { 67 | if (!prev) return null; 68 | const temp = [...prev]; 69 | temp[hubIndex] = { 70 | ...temp[hubIndex], 71 | Metadata: res.data.MediaContainer.Metadata, 72 | }; 73 | return temp; 74 | }); 75 | }) 76 | .catch(console.error); 77 | }; 78 | 79 | return { reload, hubs, append }; 80 | }; 81 | -------------------------------------------------------------------------------- /hooks/use-is-at-top.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useIsAtTop = () => { 4 | const [isAtTop, setIsAtTop] = useState(true); 5 | 6 | useEffect(() => { 7 | const onscroll = () => { 8 | setIsAtTop(window.scrollY === 0); 9 | }; 10 | 11 | window.addEventListener("scroll", onscroll); 12 | return () => { 13 | window.removeEventListener("scroll", onscroll); 14 | }; 15 | }, []); 16 | 17 | return isAtTop; 18 | }; 19 | 20 | export { useIsAtTop }; 21 | -------------------------------------------------------------------------------- /hooks/use-is-scroll-at-top.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsScrollAtTop = () => { 4 | const [isScrollAtTop, setIsScrollAtTop] = useState(true); 5 | 6 | useEffect(() => { 7 | const scroll = () => { 8 | setIsScrollAtTop(window.scrollY === 0); 9 | }; 10 | 11 | window.addEventListener("scroll", scroll); 12 | 13 | return () => { 14 | window.removeEventListener("scroll", scroll); 15 | }; 16 | }, []); 17 | 18 | return isScrollAtTop; 19 | }; 20 | -------------------------------------------------------------------------------- /hooks/use-is-size.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const GIANT_BREAKPOINT = 2000; 4 | export const DESKTOP_BREAKPOINT = 1800; 5 | export const TABLET_BREAKPOINT = 1400; 6 | export const MOBILE_BREAKPOINT = 1000; 7 | export const TINY_BREAKPOINT = 768; 8 | 9 | const useIsSize = () => { 10 | const [isGiant, setIsGiant] = useState(undefined); 11 | const [isDesktop, setIsDesktop] = useState(undefined); 12 | const [isTablet, setIsTablet] = useState(undefined); 13 | const [isMobile, setIsMobile] = useState(undefined); 14 | const [isTiny, setIsTiny] = useState(undefined); 15 | 16 | useEffect(() => { 17 | const onChange = () => { 18 | setIsGiant(window.innerWidth < GIANT_BREAKPOINT); 19 | setIsDesktop(window.innerWidth < DESKTOP_BREAKPOINT); 20 | setIsTablet(window.innerWidth < TABLET_BREAKPOINT); 21 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 22 | setIsTiny(window.innerWidth < TINY_BREAKPOINT); 23 | }; 24 | 25 | // Set initial state 26 | onChange(); 27 | 28 | // Add event listener 29 | window.addEventListener("resize", onChange); 30 | 31 | // Cleanup event listener on unmount 32 | return () => window.removeEventListener("resize", onChange); 33 | }, []); 34 | 35 | return { 36 | isGiant: !!isGiant, 37 | isDesktop: !!isDesktop, 38 | isTablet: !!isTablet, 39 | isMobile: !!isMobile, 40 | isTiny: !!isTiny, 41 | }; 42 | }; 43 | 44 | export { useIsSize }; 45 | -------------------------------------------------------------------------------- /hooks/use-item-key-metadata.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import axios, { Canceler } from "axios"; 3 | import qs from "qs"; 4 | import { xprops } from "@/api"; 5 | 6 | const useItemKeyMetadata = ( 7 | key: string | null | undefined, 8 | contentDirectoryID: string | null | undefined, 9 | ) => { 10 | const [metadata, setMetadata] = useState([]); 11 | const [loading, setLoading] = useState(false); 12 | const [hasMore, setHasMore] = useState(true); 13 | const [page, setPage] = useState(0); 14 | const observer = useRef(); 15 | const lastRef = useCallback( 16 | (node: HTMLDivElement | HTMLButtonElement) => { 17 | if (loading || !hasMore) return; 18 | if (observer.current) observer.current.disconnect(); 19 | observer.current = new IntersectionObserver((entries) => { 20 | if (entries[0].isIntersecting) { 21 | setPage((p) => p + 1); 22 | } 23 | }); 24 | if (node) observer.current.observe(node); 25 | }, 26 | [loading, hasMore], 27 | ); 28 | 29 | useEffect(() => { 30 | setMetadata([]); 31 | setHasMore(true); 32 | setPage(0); 33 | setLoading(false); 34 | }, [key]); 35 | 36 | useEffect(() => { 37 | if (!key) { 38 | setMetadata([]); 39 | setHasMore(true); 40 | setPage(0); 41 | setLoading(false); 42 | return; 43 | } 44 | 45 | if (!hasMore) { 46 | return; 47 | } 48 | setLoading(true); 49 | 50 | let cancel: Canceler; 51 | axios 52 | .get<{ 53 | MediaContainer: { Metadata: Plex.Metadata[]; totalSize: number }; 54 | }>( 55 | `${localStorage.getItem("server")}${decodeURIComponent(key)}${decodeURIComponent(key).includes("?") ? "&" : "?"}${qs.stringify( 56 | { 57 | ...xprops(), 58 | ...(contentDirectoryID ? { contentDirectoryID } : {}), 59 | includeCollections: 1, 60 | includeExternalMedia: 1, 61 | includeAdvanced: 1, 62 | includeMeta: 1, 63 | "X-Plex-Container-Start": metadata.length, 64 | "X-Plex-Container-Size": 50, 65 | }, 66 | )}`, 67 | { 68 | headers: { 69 | "X-Plex-Token": localStorage.getItem("token") as string, 70 | accept: "application/json", 71 | }, 72 | cancelToken: new axios.CancelToken((c) => { 73 | cancel = c; 74 | }), 75 | }, 76 | ) 77 | .then((res) => { 78 | if (res.data?.MediaContainer?.Metadata) { 79 | if ( 80 | res.data.MediaContainer.Metadata.length + metadata.length >= 81 | res.data.MediaContainer.totalSize 82 | ) { 83 | setHasMore(false); 84 | } 85 | setMetadata((prev) => [...prev, ...res.data.MediaContainer.Metadata]); 86 | } 87 | setLoading(false); 88 | }) 89 | .catch((err) => { 90 | console.error(err); 91 | setHasMore(true); 92 | setPage(0); 93 | setMetadata([]); 94 | setLoading(false); 95 | }) 96 | .finally(() => { 97 | setLoading(false); 98 | }); 99 | 100 | return () => { 101 | if (cancel) { 102 | cancel(); 103 | setLoading(false); 104 | } 105 | }; 106 | }, [page, key]); 107 | 108 | return { metadata, loading, lastRef }; 109 | }; 110 | 111 | export { useItemKeyMetadata }; 112 | -------------------------------------------------------------------------------- /hooks/use-item-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ServerApi } from "@/api"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | const useItemMetadata = (mid: string | null | undefined) => { 5 | const metadata = useQuery({ 6 | queryKey: ["metadata", mid], 7 | queryFn: async () => { 8 | if (!mid) return null; 9 | return ServerApi.metadata({ id: mid }); 10 | }, 11 | }); 12 | 13 | return { metadata: metadata.data, loading: metadata.isLoading }; 14 | }; 15 | 16 | const useItemChildren = ( 17 | item?: Plex.Metadata | Plex.HubMetadata | Plex.Child | null | undefined, 18 | ) => { 19 | const metadata = useQuery({ 20 | queryKey: ["metadata", "children", item?.ratingKey], 21 | queryFn: async () => { 22 | if (!item) return null; 23 | if (!item.ratingKey) return null; 24 | if (item.type === "season" || item.type === "show") { 25 | return ServerApi.children({ id: item.ratingKey }); 26 | } 27 | if (item.type === "episode" && item.parentRatingKey) { 28 | return ServerApi.children({ id: item.parentRatingKey }); 29 | } 30 | return null; 31 | }, 32 | }); 33 | 34 | return { children: metadata.data, loading: metadata.isLoading }; 35 | }; 36 | 37 | const useRelated = ( 38 | item?: Plex.Metadata | Plex.HubMetadata | null | undefined, 39 | ) => { 40 | const metadata = useQuery({ 41 | queryKey: ["related", item], 42 | queryFn: async () => { 43 | if (!item) return []; 44 | let id = item.ratingKey; 45 | if (item.type === "season" && item.parentRatingKey) { 46 | id = item.parentRatingKey; 47 | } else if (item.type === "episode" && item.grandparentRatingKey) { 48 | id = item.grandparentRatingKey; 49 | } 50 | return await ServerApi.related({ id }); 51 | }, 52 | }); 53 | 54 | return { related: metadata.data, loading: metadata.isLoading }; 55 | }; 56 | 57 | export { useItemMetadata, useItemChildren, useRelated }; 58 | -------------------------------------------------------------------------------- /hooks/use-preview-muted.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const PREVIEW_MUTED_KEY = "is-preview-muted"; 4 | 5 | export const usePreviewMuted = () => { 6 | const defaultMuted = localStorage.getItem(PREVIEW_MUTED_KEY); 7 | const [muted, setMuted] = useState( 8 | defaultMuted ? defaultMuted === "true" : true, 9 | ); 10 | 11 | const toggleMuted = () => { 12 | setMuted(!muted); 13 | localStorage.setItem(PREVIEW_MUTED_KEY, muted ? "false" : "true"); 14 | }; 15 | 16 | return { muted, toggleMuted }; 17 | }; 18 | -------------------------------------------------------------------------------- /hooks/use-session.tsx: -------------------------------------------------------------------------------- 1 | import { Api } from "@/api"; 2 | import { 3 | createContext, 4 | ReactNode, 5 | useContext, 6 | useEffect, 7 | useState, 8 | } from "react"; 9 | import { uuidv4 } from "@/lib/utils"; 10 | 11 | interface Context { 12 | user: Plex.UserData | null; 13 | sessionId: string | null; 14 | plexSessionId: string | null; 15 | } 16 | 17 | const SessionContext = createContext({} as Context); 18 | 19 | declare global { 20 | interface Window { 21 | user?: Plex.UserData; 22 | sessionId?: string; 23 | plexSessionId?: string; 24 | } 25 | } 26 | 27 | export function SessionProvider({ children }: { children: ReactNode }) { 28 | const [user, setUser] = useState(null); 29 | const [sessionId, setSessionId] = useState(null); 30 | const [plexSessionId, setPlexSessionId] = useState(null); 31 | 32 | useEffect(() => { 33 | window.sessionId = window.sessionId ?? uuidv4(); 34 | window.plexSessionId = window.plexSessionId ?? uuidv4(); 35 | 36 | setSessionId(window.sessionId); 37 | setPlexSessionId(window.plexSessionId); 38 | 39 | const token = localStorage.getItem("auth-token") as string; 40 | const uuid = localStorage.getItem("user-uuid") as string; 41 | 42 | if (!user && token && uuid) { 43 | Api.user({ token, uuid }) 44 | .then((res) => { 45 | window.user = res.data; 46 | setUser(res.data); 47 | }) 48 | .catch((err) => { 49 | console.error(err); 50 | setUser(null); 51 | }); 52 | } else { 53 | setUser(null); 54 | } 55 | }, []); 56 | 57 | return ( 58 | 59 | {children} 60 | 61 | ); 62 | } 63 | 64 | export const useSession = () => { 65 | return useContext(SessionContext); 66 | }; 67 | -------------------------------------------------------------------------------- /lib/server.ts: -------------------------------------------------------------------------------- 1 | import { PlexConnection, PlexServer } from "@/type"; 2 | import axios from "axios"; 3 | import { Api } from "@/api"; 4 | import { LibraryAndServer } from "@/components/server-provider"; 5 | import { flatMap } from "lodash"; 6 | 7 | export async function fetchConnectionLibrary( 8 | { 9 | server, 10 | connection, 11 | }: { 12 | server: PlexServer; 13 | connection: PlexConnection; 14 | }, 15 | signal: AbortSignal | undefined = undefined, 16 | ) { 17 | return axios 18 | .get<{ MediaContainer: { Directory: Plex.LibrarySection[] } }>( 19 | `${connection.uri}/library/sections`, 20 | { 21 | headers: { 22 | "X-Plex-Token": server.accessToken as string, 23 | accept: "application/json", 24 | }, 25 | signal, 26 | timeout: 10_000, 27 | }, 28 | ) 29 | .then(({ data }) => { 30 | if (data) { 31 | return { 32 | libraries: data.MediaContainer.Directory, 33 | server, 34 | connection, 35 | } as LibraryAndServer; 36 | } else { 37 | return null; 38 | } 39 | }) 40 | .catch((err) => { 41 | console.error(err); 42 | return null; 43 | }); 44 | } 45 | 46 | export async function fetchLibraries( 47 | server: PlexServer, 48 | controllers: AbortController[], 49 | ) { 50 | const promises: Promise[] = server.connections.map( 51 | (connection, index) => 52 | new Promise((resolve2) => { 53 | fetchConnectionLibrary( 54 | { server, connection }, 55 | controllers[index].signal, 56 | ) 57 | .then((info) => { 58 | if (info) { 59 | resolve2(info); 60 | } else { 61 | setTimeout(() => resolve2(null), 60_000); 62 | } 63 | }) 64 | .catch((error) => { 65 | console.error(error); 66 | setTimeout(() => resolve2(null), 60_000); 67 | }); 68 | }), 69 | ); 70 | 71 | // Use Promise.race to stop as soon as we find a valid server 72 | const result: LibraryAndServer | null = await Promise.race(promises); 73 | 74 | // Abort remaining requests 75 | controllers.forEach((controller) => controller.abort()); 76 | 77 | return result; 78 | } 79 | 80 | export async function fetchAvailableServers() { 81 | return Api.servers().then(async (res) => { 82 | let list = res.data || []; 83 | 84 | // remove the server with no connections or no accessToken from the list 85 | for (let i = list.length - 1; i >= 0; i--) { 86 | if (!list[i].connections?.length || !list[i].accessToken) { 87 | list.splice(i, 1); 88 | } 89 | } 90 | 91 | // if no server is not available 92 | if (list.length === 0) { 93 | return { list: [], info: null }; 94 | } 95 | 96 | const promises = list.map((server) => { 97 | // create an array of promises for each connection 98 | const controllers = server.connections.map(() => new AbortController()); 99 | return [fetchLibraries(server, controllers), controllers] as [ 100 | Promise, 101 | AbortController[], 102 | ]; 103 | }); 104 | const info = await Promise.race(promises.map(([call]) => call)); 105 | promises.forEach(([_, controllers]) => 106 | controllers.forEach((controller) => controller.abort()), 107 | ); 108 | return { 109 | info, 110 | list, 111 | controllers: flatMap(promises, ([_, controllers]) => controllers), 112 | }; 113 | }); 114 | } 115 | 116 | export async function fetchExistingServer(currentConnectionUri: string) { 117 | return currentConnectionUri 118 | ? Api.servers() 119 | .then((res) => { 120 | let list = res.data || []; 121 | 122 | let connected: PlexServer | null = null; 123 | 124 | // remove the server with no connections or no accessToken from the list 125 | for (let i = list.length - 1; i >= 0; i--) { 126 | if (!list[i].connections?.length || !list[i].accessToken) { 127 | list.splice(i, 1); 128 | } 129 | } 130 | 131 | for (const server of list) { 132 | for (const connection of server.connections) { 133 | if (connection.uri === currentConnectionUri) { 134 | connected = server; 135 | break; 136 | } 137 | } 138 | } 139 | 140 | if (connected) { 141 | return fetchConnectionLibrary({ 142 | connection: connected.connections.find( 143 | ({ uri }) => uri === currentConnectionUri, 144 | )!, 145 | server: connected, 146 | }).then((info) => { 147 | return info; 148 | }); 149 | } 150 | return null; 151 | }) 152 | .catch((error) => { 153 | console.error(error); 154 | return null; 155 | }) 156 | : null; 157 | } 158 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function getUrlLocation(href: string) { 9 | const match = href.match( 10 | /^(https?\:)\/\/(([^:\/?#]*)(?:\:([0-9]+))?)([\/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/, 11 | ); 12 | return ( 13 | match && { 14 | href: href, 15 | protocol: match[1], 16 | host: match[2], 17 | hostname: match[3], 18 | port: match[4], 19 | pathname: match[5], 20 | search: match[6], 21 | hash: match[7], 22 | } 23 | ); 24 | } 25 | 26 | export function uuidv4() { 27 | const length = 24; 28 | let result = ""; 29 | const characters = 30 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 31 | const charactersLength = characters.length; 32 | let counter = 0; 33 | while (counter < length) { 34 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 35 | counter += 1; 36 | } 37 | return result; 38 | } 39 | 40 | export function getFormatedTime(time: number) { 41 | const hours = Math.floor(time / 3600); 42 | const minutes = Math.floor((time % 3600) / 60); 43 | const seconds = Math.floor(time % 60); 44 | 45 | // only show hours if there are any 46 | if (hours > 0) 47 | return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds 48 | .toString() 49 | .padStart(2, "0")}`; 50 | 51 | return `${minutes.toString().padStart(2, "0")}:${seconds 52 | .toString() 53 | .padStart(2, "0")}`; 54 | } 55 | 56 | export function durationToMin(duration: number) { 57 | return Math.floor(duration / 1000 / 60); 58 | } 59 | 60 | export function durationToText(duration: number): string { 61 | const hours = Math.floor(duration / 1000 / 60 / 60); 62 | const minutes = (duration / 1000 / 60 / 60 - hours) * 60; 63 | 64 | return ( 65 | (hours > 0 ? `${hours}h` : "") + 66 | (Math.floor(minutes) > 0 ? ` ${Math.floor(minutes)}m` : "") 67 | ); 68 | } 69 | 70 | export function uuid() { 71 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 72 | const r = (Math.random() * 16) | 0, 73 | v = c === "x" ? r : (r & 0x3) | 0x8; 74 | return v.toString(16); 75 | }); 76 | } 77 | 78 | export function lerp(start: number, end: number, t: number): number { 79 | return start + t * (end - start); 80 | } 81 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plexy-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@iconify/icons-mdi": "^1.2.48", 13 | "@iconify/react": "^5.1.0", 14 | "@radix-ui/react-avatar": "^1.1.2", 15 | "@radix-ui/react-checkbox": "^1.1.4", 16 | "@radix-ui/react-context-menu": "^2.2.4", 17 | "@radix-ui/react-dialog": "^1.1.4", 18 | "@radix-ui/react-label": "^2.1.1", 19 | "@radix-ui/react-navigation-menu": "^1.2.5", 20 | "@radix-ui/react-popover": "^1.1.4", 21 | "@radix-ui/react-portal": "^1.1.3", 22 | "@radix-ui/react-progress": "^1.1.1", 23 | "@radix-ui/react-scroll-area": "^1.2.2", 24 | "@radix-ui/react-select": "^2.1.4", 25 | "@radix-ui/react-separator": "^1.1.1", 26 | "@radix-ui/react-slider": "^1.2.2", 27 | "@radix-ui/react-slot": "^1.1.1", 28 | "@radix-ui/react-tabs": "^1.1.2", 29 | "@radix-ui/react-toggle": "^1.1.1", 30 | "@radix-ui/react-toggle-group": "^1.1.1", 31 | "@radix-ui/react-tooltip": "^1.1.6", 32 | "@radix-ui/react-visually-hidden": "^1.1.1", 33 | "@tanstack/react-query": "^5.62.8", 34 | "@vercel/analytics": "^1.4.1", 35 | "@vercel/speed-insights": "^1.1.0", 36 | "axios": "^1.7.9", 37 | "body-scroll-lock": "4.0.0-beta.0", 38 | "class-variance-authority": "^0.7.1", 39 | "clsx": "^2.1.1", 40 | "cmdk": "1.0.0", 41 | "embla-carousel-react": "^8.5.1", 42 | "eslint-config-prettier": "^9.1.0", 43 | "eslint-plugin-prettier": "^5.2.1", 44 | "fast-xml-parser": "^4.5.1", 45 | "he": "^1.2.0", 46 | "input-otp": "^1.4.2", 47 | "localforage": "^1.10.0", 48 | "lodash": "^4.17.21", 49 | "lucide-react": "^0.468.0", 50 | "next": "14.2.16", 51 | "next-themes": "^0.4.4", 52 | "prettier": "^3.4.2", 53 | "qs": "^6.13.1", 54 | "react": "^18", 55 | "react-dom": "^18", 56 | "react-player": "^2.16.0", 57 | "react-video-seek-slider": "6.0.9", 58 | "sass": "^1.83.0", 59 | "tailwind-merge": "^2.5.5", 60 | "tailwindcss-animate": "^1.0.7", 61 | "tailwindcss-named-groups": "^0.0.5", 62 | "vaul": "^1.1.2", 63 | "zustand": "^5.0.2" 64 | }, 65 | "devDependencies": { 66 | "@types/body-scroll-lock": "^3.1.2", 67 | "@types/he": "^1.2.3", 68 | "@types/lodash": "^4.17.13", 69 | "@types/node": "^20", 70 | "@types/qs": "^6.9.17", 71 | "@types/react": "^18", 72 | "@types/react-dom": "^18", 73 | "eslint": "^9.17.0", 74 | "eslint-config-next": "14.2.16", 75 | "postcss": "^8", 76 | "tailwindcss": "^3.4.1", 77 | "typescript": "^5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/public/favicon.ico -------------------------------------------------------------------------------- /public/plex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/public/plex.png -------------------------------------------------------------------------------- /public/plexIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/public/plexIcon.png -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | keyframes: { 13 | "caret-blink": { 14 | "0%,70%,100%": { opacity: "1" }, 15 | "20%,50%": { opacity: "0" }, 16 | }, 17 | }, 18 | animation: { 19 | "caret-blink": "caret-blink 1.25s ease-out infinite", 20 | "spin-fast": "spin 0.75s linear infinite", // faster than default (1s) 21 | }, 22 | screens: { 23 | "3xl": "1600px", 24 | "4xl": "2000px", 25 | }, 26 | colors: { 27 | plex: "hsl(var(--plex-accent))", 28 | background: "hsl(var(--background))", 29 | alternative: "rgb(var(--alternative))", 30 | foreground: "hsl(var(--foreground))", 31 | card: { 32 | DEFAULT: "hsl(var(--card))", 33 | foreground: "hsl(var(--card-foreground))", 34 | }, 35 | popover: { 36 | DEFAULT: "hsl(var(--popover))", 37 | foreground: "hsl(var(--popover-foreground))", 38 | }, 39 | primary: { 40 | DEFAULT: "hsl(var(--primary))", 41 | foreground: "hsl(var(--primary-foreground))", 42 | }, 43 | secondary: { 44 | DEFAULT: "hsl(var(--secondary))", 45 | foreground: "hsl(var(--secondary-foreground))", 46 | }, 47 | muted: { 48 | DEFAULT: "hsl(var(--muted))", 49 | foreground: "hsl(var(--muted-foreground))", 50 | }, 51 | accent: { 52 | DEFAULT: "hsl(var(--accent))", 53 | foreground: "hsl(var(--accent-foreground))", 54 | }, 55 | destructive: { 56 | DEFAULT: "hsl(var(--destructive))", 57 | foreground: "hsl(var(--destructive-foreground))", 58 | }, 59 | border: "hsl(var(--border))", 60 | input: "hsl(var(--input))", 61 | ring: "hsl(var(--ring))", 62 | chart: { 63 | "1": "hsl(var(--chart-1))", 64 | "2": "hsl(var(--chart-2))", 65 | "3": "hsl(var(--chart-3))", 66 | "4": "hsl(var(--chart-4))", 67 | "5": "hsl(var(--chart-5))", 68 | }, 69 | }, 70 | borderRadius: { 71 | lg: "var(--radius)", 72 | md: "calc(var(--radius) - 2px)", 73 | sm: "calc(var(--radius) - 4px)", 74 | }, 75 | }, 76 | namedGroups: ["other"], 77 | }, 78 | plugins: [ 79 | // eslint-disable-next-line @typescript-eslint/no-require-imports 80 | require("tailwindcss-animate"), 81 | // eslint-disable-next-line @typescript-eslint/no-require-imports 82 | require("tailwindcss-named-groups"), 83 | ], 84 | }; 85 | export default config; 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "baseUrl": ".", 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /type.ts: -------------------------------------------------------------------------------- 1 | export interface VideoItemInterface 2 | extends Pick< 3 | Plex.Metadata, 4 | | "title" 5 | | "type" 6 | | "grandparentTitle" 7 | | "year" 8 | | "leafCount" 9 | | "viewedLeafCount" 10 | | "viewCount" 11 | | "childCount" 12 | | "rating" 13 | | "contentRating" 14 | | "duration" 15 | | "grandparentRatingKey" 16 | | "ratingKey" 17 | | "summary" 18 | | "viewOffset" 19 | | "parentTitle" 20 | | "OnDeck" 21 | | "Children" 22 | | "index" 23 | | "parentIndex" 24 | > { 25 | image: string; 26 | } 27 | 28 | export interface PlexConnection { 29 | protocol: string; 30 | address: string; 31 | port: number; 32 | uri: string; 33 | local: boolean; 34 | relay: boolean; 35 | IPv6: boolean; 36 | } 37 | 38 | export interface PlexServer { 39 | name: string; 40 | product: string; 41 | productVersion: string; 42 | platform: string; 43 | platformVersion: string; 44 | device: string; 45 | clientIdentifier: string; 46 | createdAt: string; 47 | lastSeenAt: string; 48 | provides: string; 49 | ownerId: any; 50 | sourceTitle: any; 51 | publicAddress: string; 52 | accessToken: string; 53 | owned: boolean; 54 | home: boolean; 55 | synced: boolean; 56 | relay: boolean; 57 | presence: boolean; 58 | httpsRequired: boolean; 59 | publicAddressMatches: boolean; 60 | dnsRebindingProtection: boolean; 61 | natLoopbackSupported: boolean; 62 | connections: PlexConnection[]; 63 | } 64 | -------------------------------------------------------------------------------- /window.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricoloic/plexy/13d61f9a7e1ebd79498281bb596a0ea17d3682a6/window.d.ts --------------------------------------------------------------------------------