├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── docker-latest.yml │ └── docker-preview.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── screenshot1.png ├── screenshot2.png └── screenshot3.png ├── backend ├── .gitignore ├── package-lock.json ├── package.json ├── prisma │ └── schema.prisma ├── run.sh ├── src │ ├── common │ │ ├── plex.ts │ │ └── sync.ts │ ├── index.ts │ └── types.ts └── tsconfig.json └── frontend ├── package-lock.json ├── package.json ├── public ├── icon.png ├── index.html ├── logo.png └── logoBig.png ├── src ├── App.tsx ├── backendURL.ts ├── common │ ├── ArrayExtra.ts │ └── NumberExtra.ts ├── components │ ├── AppBar.tsx │ ├── BigReader.tsx │ ├── CenteredSpinner.tsx │ ├── ConfirmModal.tsx │ ├── HeroDisplay.tsx │ ├── LibraryScreen.tsx │ ├── LibrarySortDropDown.tsx │ ├── MetaScreen.tsx │ ├── MovieItem.tsx │ ├── MovieItemLegacy.tsx │ ├── MovieItemSlider.tsx │ ├── PerPlexedSync.tsx │ ├── PlaybackNextEPButton.tsx │ ├── ToastManager.tsx │ ├── WatchShowChildView.tsx │ └── settings │ │ └── CheckBoxOption.tsx ├── index.css ├── index.tsx ├── pages │ ├── Browse.tsx │ ├── Home.tsx │ ├── Library.tsx │ ├── Login.tsx │ ├── Search.tsx │ ├── Settings.tsx │ ├── Startup.tsx │ ├── Utility.tsx │ ├── WaitingRoom.tsx │ ├── Watch.tsx │ ├── browse │ │ ├── BrowseLibrary.tsx │ │ └── BrowseRecommendations.tsx │ └── settings │ │ ├── SettingsInfo.tsx │ │ ├── SettingsPlayback.tsx │ │ ├── SettingsRecommendations.tsx │ │ └── _template.tsx ├── plex │ ├── QuickFunctions.ts │ ├── index.ts │ ├── plex.d.ts │ ├── plexCommunity.ts │ └── plextv.ts ├── states │ ├── PreviewPlayerState.ts │ ├── SessionState.ts │ ├── SyncSessionState.ts │ ├── UserSession.ts │ ├── UserSettingsState.ts │ └── WatchListCache.ts └── types.d.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | /backend/data -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Ipmake] 4 | custom: ["https://g.ipmake.dev/perplexed"] 5 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | runs-on: ubuntu-latest 12 | permissions: 13 | packages: write 14 | contents: read 15 | attestations: write 16 | id-token: write 17 | steps: 18 | - name: Check out the repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '22' 25 | 26 | - name: Install dependencies 27 | run: npm install 28 | working-directory: frontend 29 | 30 | - name: Build frontend 31 | run: npm run build 32 | working-directory: frontend 33 | 34 | - name: Log in to Docker Hub 35 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_PASSWORD }} 39 | 40 | - name: Create builder instance 41 | run: docker buildx create --name nevuBuilder --use 42 | 43 | - name: Build and push the amd64 image 44 | run: docker buildx build --platform linux/amd64 -t ipmake/nevu:latest-amd64 . --push 45 | 46 | - name: Build and push the arm64 image 47 | run: docker buildx build --platform linux/arm64 -t ipmake/nevu:latest-arm64 . --push 48 | 49 | - name: Build and push multi-platform Docker image 50 | run: docker buildx imagetools create --tag ipmake/nevu:latest ipmake/nevu:latest-amd64 ipmake/nevu:latest-arm64 -------------------------------------------------------------------------------- /.github/workflows/docker-preview.yml: -------------------------------------------------------------------------------- 1 | name: Publish Preview Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | runs-on: ubuntu-latest 12 | permissions: 13 | packages: write 14 | contents: read 15 | attestations: write 16 | id-token: write 17 | steps: 18 | - name: Check out the repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '22' 25 | 26 | - name: Install dependencies 27 | run: npm install 28 | working-directory: frontend 29 | 30 | - name: Build frontend 31 | run: npm run build 32 | working-directory: frontend 33 | 34 | - name: Log in to Docker Hub 35 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_PASSWORD }} 39 | 40 | - name: Create builder instance 41 | run: docker buildx create --name nevuBuilder --use 42 | 43 | - name: Build and push the amd64 image 44 | run: docker buildx build --platform linux/amd64 -t ipmake/nevu:preview-amd64 . --push 45 | 46 | - name: Build and push the arm64 image 47 | run: docker buildx build --platform linux/arm64 -t ipmake/nevu:preview-arm64 . --push 48 | 49 | - name: Build and push multi-platform Docker image 50 | run: docker buildx imagetools create --tag ipmake/nevu:preview ipmake/nevu:preview-amd64 ipmake/nevu:preview-arm64 -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | coverage 10 | 11 | # production 12 | build 13 | dist 14 | 15 | # misc 16 | .DS_Store 17 | .env 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | 28 | # exclude everything in the www folder 29 | /backend/www/* 30 | 31 | build -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-bookworm-slim as runner 2 | 3 | WORKDIR /app 4 | 5 | COPY backend/* /app 6 | 7 | RUN apt-get update -y && apt-get install -y openssl 8 | 9 | RUN npm install 10 | RUN npx prisma db push 11 | RUN npx tsc 12 | RUN chmod +x /app/run.sh 13 | 14 | EXPOSE 3000 15 | VOLUME /app/data 16 | 17 | COPY frontend/build/ /app/www/ 18 | 19 | CMD ["npm", "start"] 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NEVU for Plex 2 | Fixing Plex's old and simple UI. 3 | 4 | **PerPlexed is now Nevu!** 5 | 6 | [**Docker Hub**](https://hub.docker.com/r/ipmake/nevu) 7 | 8 | *Click image for video* 9 | [![Nevu1](assets/screenshot1.png)](https://www.youtube.com/watch?v=PuTOw3Wg9oY) 10 | ![Nevu2](assets/screenshot2.png) 11 | [More Screenshots](https://github.com/Ipmake/Nevu/tree/main/assets) 12 | 13 | ## Description 14 | 15 | Nevu is a complete redesign of Plex's UI using the Plex media server's API. It comes with its own web server. As the keen eye may notice, the UI is heavily inspired by Netflix's UI. It is currently only developed for desktops and laptops. It is not optimized for mobile or TV use. 16 | 17 | Nevu currently supports Movie and TV Show libraries. You can also play media via the interface. 18 | 19 | Mind that this project is still in development and may be unstable. 20 | 21 | 22 | ## Features 23 | - Modern, Netflix-like UI 24 | - Seamless Plex integration 25 | - Play media 26 | - Browse libraries 27 | - Search for media 28 | - Watch Together (Nevu Sync) 29 | - Get Recommendations 30 | - Fully integrated Watchlist 31 | - Simple and easy to use 32 | - Pro-User features (like special shortcuts etc.) 33 | 34 | ## Installation 35 | 36 | ### Docker 37 | 38 | The easiest way to run Nevu is to use Docker. You can use the following command to run Nevu in a Docker container: 39 | 40 | ```bash 41 | docker volume create nevu_data 42 | docker run --name nevu -p 3000:3000 -v nevu_data:/data -e PLEX_SERVER=http://your-plex-server:32400 ipmake/nevu 43 | ``` 44 | 45 | ### Docker Compose 46 | 47 | Alternatively, you can use Docker Compose to run Nevu. Create a `docker-compose.yml` file with the following content: 48 | 49 | ```yaml 50 | services: 51 | nevu: 52 | image: ipmake/nevu 53 | container_name: nevu 54 | ports: 55 | - "3000:3000" 56 | volumes: 57 | - nevu_data:/data 58 | environment: 59 | - PLEX_SERVER=http://your-plex-server:32400 60 | 61 | volumes: 62 | nevu_data: 63 | ``` 64 | 65 | Then run: 66 | 67 | ```bash 68 | docker-compose up -d 69 | ``` 70 | 71 | ### Environment Variables 72 | | Name | Type | Required | Description | 73 | |--------------------------|------------|----------|-----------------------------------------------------------------------------| 74 | | `PLEX_SERVER` | string | Yes | The URL of the Plex server that the backend will proxy to (CAN BE LOCAL) | 75 | | `DISABLE_TLS_VERIFY` | true/false | No | If set to true, the proxy will not check any https ssl certificates | 76 | | `DISABLE_NEVU_SYNC` | true/false | No | If set to true, Nevu sync (watch together) will be disabled | 77 | | `DISABLE_REQUEST_LOGGING`| true/false | No | If set to true, the server will not log any requests | 78 | 79 | 80 | 81 | ## Contributing 82 | Pull requests are welcome for any feature or a bug fix. For major changes, please open an issue first to discuss what you would like to change. 83 | 84 | ## Development 85 | 86 | To develop you need 2 terminals for the front and the backend of Nevu 87 | 88 | ```bash 89 | # Terminal 1 90 | cd frontend 91 | npm start 92 | 93 | # Terminal 2 94 | cd backend 95 | PLEX_SERVER=http://plex-server:32400 npm start 96 | ``` 97 | -------------------------------------------------------------------------------- /assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/assets/screenshot1.png -------------------------------------------------------------------------------- /assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/assets/screenshot2.png -------------------------------------------------------------------------------- /assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/assets/screenshot3.png -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | /data -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "dependencies": { 6 | "@prisma/client": "^6.2.1", 7 | "axios": "^1.6.8", 8 | "bonjour-service": "^1.3.0", 9 | "express": "^4.18.2", 10 | "http-proxy": "^1.18.1", 11 | "socket.io": "^4.8.1", 12 | "undici-types": "^5.26.5" 13 | }, 14 | "devDependencies": { 15 | "@types/express": "^4.17.21", 16 | "@types/http-proxy": "^1.17.16", 17 | "@types/node": "^20.11.16", 18 | "prisma": "^6.2.1", 19 | "typescript": "^5.3.3" 20 | }, 21 | "scripts": { 22 | "start": "./run.sh", 23 | "dev": "npx tsc && node ." 24 | }, 25 | "author": "Ipmake", 26 | "license": "ISC", 27 | "description": "" 28 | } 29 | -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | engineType = "library" 7 | } 8 | 9 | datasource db { 10 | provider = "sqlite" 11 | url = "file:../data/perplexed.db" 12 | } 13 | 14 | model UserOption { 15 | userUid String 16 | key String 17 | value String 18 | 19 | @@id([userUid, key]) 20 | @@unique([userUid, key]) 21 | } -------------------------------------------------------------------------------- /backend/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npx prisma db push 4 | # Run the Node.js application 5 | node . -------------------------------------------------------------------------------- /backend/src/common/plex.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { PerPlexed } from "../types"; 3 | 4 | export async function CheckPlexUser(token: string): Promise { 5 | const data = await axios.get("https://plex.tv/api/v2/user", { 6 | headers: { 7 | "X-Plex-Token": token, 8 | }, 9 | }).then(res => res.data as PerPlexed.PlexTV.User).catch(() => null); 10 | 11 | if (!data) return null; 12 | 13 | return data; 14 | } -------------------------------------------------------------------------------- /backend/src/common/sync.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io"; 2 | import { io } from ".."; 3 | import { PerPlexed } from "../types"; 4 | import { CheckPlexUser } from "./plex"; 5 | import crypto from 'crypto'; 6 | 7 | console.log(`SYNC is ${io ? 'enabled' : 'disabled'}`); 8 | 9 | io?.of('/').adapter.on('create-room', (room) => { 10 | console.log(`SYNC room created: ${room}`); 11 | }); 12 | 13 | io?.of('/').adapter.on('delete-room', (room) => { 14 | console.log(`SYNC room deleted: ${room}`); 15 | }); 16 | 17 | io?.of('/').adapter.on('join-room', (room, id) => { 18 | console.log(`SYNC [${id}] joined room: ${room}`); 19 | }) 20 | 21 | io?.of('/').adapter.on('leave-room', (room, id) => { 22 | console.log(`SYNC [${id}] left room: ${room}`); 23 | }) 24 | 25 | io?.on('connection', async (socket) => { 26 | console.log(`SYNC [${socket.id}] connected`); 27 | 28 | if(!socket.handshake.query.room || typeof socket.handshake.query.room !== 'string') { 29 | console.log(`SYNC [${socket.id}] disconnected: no room provided`); 30 | socket.emit("conn-error", { 31 | type: 'invalid_room', 32 | message: 'No room provided' 33 | } satisfies PerPlexed.Sync.SocketError); 34 | return setTimeout(() => socket.disconnect(), 1000); 35 | } 36 | 37 | if(!socket.handshake.auth.token) { 38 | console.log(`SYNC [${socket.id}] disconnected: no token provided`); 39 | socket.emit("conn-error", { 40 | type: 'invalid_auth', 41 | message: 'No token provided' 42 | } satisfies PerPlexed.Sync.SocketError); 43 | return setTimeout(() => socket.disconnect(), 1000); 44 | } 45 | 46 | const user = await CheckPlexUser(socket.handshake.auth.token); 47 | if(!user) { 48 | console.log(`SYNC [${socket.id}] disconnected: invalid token`); 49 | socket.emit("conn-error", { 50 | type: 'invalid_auth', 51 | message: 'Invalid token' 52 | } satisfies PerPlexed.Sync.SocketError); 53 | return setTimeout(() => socket.disconnect(), 1000); 54 | } 55 | 56 | socket.data.user = user; 57 | 58 | console.log(`SYNC [${socket.id}] authenticated as ${user.friendlyName}`); 59 | 60 | const isHost = socket.handshake.query.room === 'new' 61 | if(isHost) { 62 | socket.handshake.query.room = GenerateRoomID(); 63 | console.log(`SYNC [${socket.id}] generated new room ID: ${socket.handshake.query.room}`); 64 | } else if(!io?.sockets.adapter.rooms.has(socket.handshake.query.room)) { 65 | console.log(`SYNC [${socket.id}] disconnected: invalid room`); 66 | socket.emit("conn-error", { 67 | type: 'invalid_room', 68 | message: 'Invalid room' 69 | } satisfies PerPlexed.Sync.SocketError); 70 | return setTimeout(() => socket.disconnect(), 1000); 71 | } 72 | 73 | const room = socket.handshake.query.room; 74 | 75 | socket.join(socket.handshake.query.room); 76 | 77 | socket.emit('ready', { 78 | room: socket.handshake.query.room, 79 | host: isHost 80 | } satisfies PerPlexed.Sync.Ready); 81 | 82 | io?.to(room).emit('EVNT_USER_JOIN', { 83 | uid: user.uuid, 84 | socket: socket.id, 85 | name: user.friendlyName, 86 | avatar: user.thumb 87 | } satisfies PerPlexed.Sync.Member); 88 | 89 | AddEvents(socket, isHost, room); 90 | 91 | socket.on('disconnect', () => { 92 | console.log(`SYNC [${socket.id}] disconnected`); 93 | 94 | // if host, delete room and disconnect all clients 95 | if(isHost) { 96 | io?.to(room).emit('conn-error', { 97 | type: 'host_disconnect', 98 | message: 'Host disconnected' 99 | } satisfies PerPlexed.Sync.SocketError); 100 | 101 | const clients = io?.sockets.adapter.rooms.get(room); 102 | if(clients) { 103 | clients.forEach(client => { 104 | io?.sockets.sockets.get(client)?.disconnect(); 105 | }); 106 | } 107 | } else { 108 | io?.to(room).emit('EVNT_USER_LEAVE', { 109 | uid: user.uuid, 110 | socket: socket.id, 111 | name: user.friendlyName, 112 | avatar: user.thumb 113 | } satisfies PerPlexed.Sync.Member); 114 | } 115 | }); 116 | }) 117 | 118 | function AddEvents(socket: Socket, isHost: boolean, room: string) { 119 | const user = socket.data.user as PerPlexed.PlexTV.User; 120 | 121 | socket.onAny((event, ...args) => { 122 | if(!event.startsWith('SYNC_')) return; 123 | 124 | console.log(`SYNC [${socket.id}] emitting HOST ${event} to ${room}`); 125 | io?.to(room).emit(`HOST_${event}`, ...args); 126 | }); 127 | 128 | socket.onAny((event, ...args) => { 129 | if(!isHost) return; 130 | if(!event.startsWith('RES_')) return; 131 | 132 | console.log(`SYNC [${socket.id}] emitting ${event} to ${room}`); 133 | io?.to(room).emit(`${event}`, { 134 | uid: user.uuid, 135 | socket: socket.id, 136 | name: user.friendlyName, 137 | avatar: user.thumb 138 | } satisfies PerPlexed.Sync.Member, ...args); 139 | }); 140 | 141 | socket.onAny((event, ...args) => { 142 | if(!event.startsWith("EVNT_")) return; 143 | 144 | console.log(`SYNC [${socket.id}] emitting EVENT ${event} to ${room}`); 145 | io?.to(room).emit(`${event}`, { 146 | uid: user.uuid, 147 | socket: socket.id, 148 | name: user.friendlyName, 149 | avatar: user.thumb 150 | } satisfies PerPlexed.Sync.Member, ...args); 151 | }) 152 | 153 | } 154 | 155 | function GenerateRoomID() { 156 | let id: string | null = null; 157 | let i = 0; 158 | 159 | do { 160 | id = crypto.randomBytes(3).toString('hex'); 161 | i++; 162 | } while(io?.sockets.adapter.rooms.has(id) || !id || i > 10); 163 | 164 | if(!id) throw new Error('Failed to generate room ID'); 165 | 166 | return id; 167 | } -------------------------------------------------------------------------------- /backend/src/types.ts: -------------------------------------------------------------------------------- 1 | export namespace PerPlexed { 2 | export interface Status { 3 | ready: boolean; 4 | error: boolean; 5 | message: string; 6 | } 7 | 8 | export namespace Sync { 9 | export interface SocketError { 10 | type: string; 11 | message: string; 12 | } 13 | 14 | export interface Ready { 15 | room: string; 16 | host: boolean; 17 | } 18 | 19 | export interface PlayBackState { 20 | key?: string; 21 | state: string; 22 | time?: number; 23 | } 24 | 25 | export interface Member { 26 | uid: string; 27 | socket: string; 28 | name: string; 29 | avatar: string; 30 | } 31 | } 32 | 33 | export namespace PlexTV { 34 | export interface User { 35 | id: number; 36 | uuid: string; 37 | username: string; 38 | title: string; 39 | email: string; 40 | friendlyName: string; 41 | locale: string | null; 42 | confirmed: boolean; 43 | joinedAt: number; 44 | emailOnlyAuth: boolean; 45 | hasPassword: boolean; 46 | protected: boolean; 47 | thumb: string; 48 | authToken: string; 49 | mailingListStatus: string | null; 50 | mailingListActive: boolean; 51 | scrobbleTypes: string; 52 | country: string; 53 | pin: string; 54 | subscription: { 55 | active: boolean; 56 | subscribedAt: number | null; 57 | status: string; 58 | paymentService: string | null; 59 | plan: string | null; 60 | features: string[]; 61 | }; 62 | subscriptionDescription: string | null; 63 | restricted: boolean; 64 | anonymous: boolean | null; 65 | restrictionProfile: string | null; 66 | mappedRestrictionProfile: string | null; 67 | customRestrictions: { 68 | all: string | null; 69 | movies: string | null; 70 | music: string | null; 71 | photos: string | null; 72 | television: string | null; 73 | }; 74 | home: boolean; 75 | guest: boolean; 76 | homeSize: number; 77 | homeAdmin: boolean; 78 | maxHomeSize: number; 79 | rememberExpiresAt: number | null; 80 | profile: { 81 | autoSelectAudio: boolean; 82 | defaultAudioAccessibility: number; 83 | defaultAudioLanguage: string | null; 84 | defaultAudioLanguages: string[] | null; 85 | defaultSubtitleLanguage: string | null; 86 | defaultSubtitleLanguages: string[] | null; 87 | autoSelectSubtitle: number; 88 | defaultSubtitleAccessibility: number; 89 | defaultSubtitleForced: number; 90 | watchedIndicator: number; 91 | mediaReviewsVisibility: number; 92 | mediaReviewsLanguages: string[] | null; 93 | }; 94 | entitlements: string[]; 95 | services: { 96 | identifier: string; 97 | endpoint: string; 98 | token: string | null; 99 | secret: string | null; 100 | status: string; 101 | }[]; 102 | adsConsent: string | null; 103 | adsConsentSetAt: number | null; 104 | adsConsentReminderAt: number | null; 105 | experimentalFeatures: boolean; 106 | twoFactorEnabled: boolean; 107 | backupCodesCreated: boolean; 108 | attributionPartner: string | null; 109 | } 110 | } 111 | } -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nevu", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.14.0", 7 | "@emotion/styled": "^11.14.0", 8 | "@fontsource-variable/inter": "^5.2.5", 9 | "@fontsource-variable/quicksand": "^5.2.6", 10 | "@fontsource-variable/rubik": "^5.2.5", 11 | "@fontsource/ibm-plex-sans": "^5.2.5", 12 | "@mui/icons-material": "^7.0.2", 13 | "@mui/material": "^7.0.2", 14 | "axios": "^1.8.4", 15 | "fast-xml-parser": "^5.2.0", 16 | "framer-motion": "^12.6.3", 17 | "moment": "^2.30.1", 18 | "react": "^19.1.0", 19 | "react-dom": "^19.1.0", 20 | "react-intersection-observer": "^9.16.0", 21 | "react-player": "^2.16.0", 22 | "react-router-dom": "^7.5.0", 23 | "react-scripts": "5.0.1", 24 | "react-video-seek-slider": "^7.0.0", 25 | "socket.io-client": "^4.8.1", 26 | "zustand": "^5.0.3" 27 | }, 28 | "scripts": { 29 | "start": "PORT=4000 react-scripts start", 30 | "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "devDependencies": { 50 | "@types/react": "^19.1.0", 51 | "@types/react-dom": "^19.1.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/frontend/public/icon.png -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | NEVU 18 | 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/logoBig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ipmake/Nevu/495962beae86bca2fe730b9e9a1ba15c8316f069/frontend/public/logoBig.png -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import AppBar from "./components/AppBar"; 3 | import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; 4 | 5 | import Browse from "./pages/Browse"; 6 | import { Box } from "@mui/material"; 7 | import Watch from "./pages/Watch"; 8 | import Login from "./pages/Login"; 9 | import Search from "./pages/Search"; 10 | import Home from "./pages/Home"; 11 | import Library from "./pages/Library"; 12 | import BigReader from "./components/BigReader"; 13 | import { useWatchListCache } from "./states/WatchListCache"; 14 | import Startup, { useStartupState } from "./pages/Startup"; 15 | import PerPlexedSync from "./components/PerPlexedSync"; 16 | import WaitingRoom from "./pages/WaitingRoom"; 17 | import ToastManager from "./components/ToastManager"; 18 | import LibraryScreen from "./components/LibraryScreen"; 19 | import { useSessionStore } from "./states/SessionState"; 20 | import Settings from "./pages/Settings"; 21 | import { useUserSettings } from "./states/UserSettingsState"; 22 | import MetaScreen from "./components/MetaScreen"; 23 | import ConfirmModal from "./components/ConfirmModal"; 24 | 25 | function AppManager() { 26 | const { loading } = useStartupState(); 27 | const [showApp, setShowApp] = React.useState(false); 28 | const [fadeOut, setFadeOut] = React.useState(false); 29 | 30 | useEffect(() => { 31 | if (loading) return; 32 | 33 | setTimeout(() => { 34 | setFadeOut(true); 35 | setTimeout(() => setShowApp(true), 500); 36 | }, 1000); 37 | }, [loading]); 38 | 39 | if (!showApp) { 40 | return ( 41 |
42 | 43 |
44 | ); 45 | } 46 | 47 | return ; 48 | } 49 | 50 | function AppTitleManager() { 51 | const { PlexServer } = useSessionStore(); 52 | 53 | useEffect(() => { 54 | console.log(PlexServer); 55 | if (!PlexServer?.friendlyName) return; 56 | 57 | const capitalizedFriendlyName = 58 | PlexServer.friendlyName.charAt(0).toUpperCase() + 59 | PlexServer.friendlyName.slice(1); 60 | document.title = `${capitalizedFriendlyName} - Nevu`; 61 | }, [PlexServer]); 62 | 63 | useEffect(() => { 64 | document.title = "Nevu"; 65 | }, []); 66 | 67 | return <>; 68 | } 69 | 70 | function App() { 71 | const location = useLocation(); 72 | const navigate = useNavigate(); 73 | 74 | useEffect(() => { 75 | if ( 76 | !localStorage.getItem("accessToken") && 77 | !location.pathname.startsWith("/login") 78 | ) 79 | navigate("/login"); 80 | }, [location.pathname, navigate]); 81 | 82 | useEffect(() => { 83 | useWatchListCache.getState().loadWatchListCache(); 84 | useSessionStore.getState().fetchPlexServer(); 85 | useUserSettings.getState().fetchSettings(); 86 | 87 | const interval = setInterval(() => { 88 | useWatchListCache.getState().loadWatchListCache(); 89 | }, 60000); 90 | 91 | return () => clearInterval(interval); 92 | }, []); 93 | 94 | return ( 95 | <> 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | } /> 105 | } /> 106 | } /> 107 | 108 | 114 | 115 | } /> 116 | } /> 117 | } /> 118 | } /> 119 | } /> 120 | } /> 121 | } 124 | /> 125 | } /> 126 | 127 | 128 | 129 | ); 130 | } 131 | 132 | export default AppManager; 133 | -------------------------------------------------------------------------------- /frontend/src/backendURL.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const isDev = !process.env.NODE_ENV || process.env.NODE_ENV === 'development' 4 | 5 | export function getBackendURL() { 6 | if (isDev) return "http://localhost:3000"; 7 | else return ""; 8 | } 9 | 10 | export function ProxiedRequest(url: string, method: "GET" | "POST" | "PUT", headers?: Record, data?: any) { 11 | return axios.post(`${getBackendURL()}/proxy`, { url, method, headers, data }).catch((err) => { 12 | console.log(err); 13 | return { status: err.response?.status || 500, data: err.response?.data || 'Internal server error' } 14 | }) 15 | } -------------------------------------------------------------------------------- /frontend/src/common/ArrayExtra.ts: -------------------------------------------------------------------------------- 1 | export const shuffleArray = (array: any[]) => { 2 | const oldArray = [...array]; 3 | const newArray = []; 4 | 5 | while (oldArray.length) { 6 | const index = Math.floor(Math.random() * oldArray.length); 7 | newArray.push(oldArray.splice(index, 1)[0]); 8 | } 9 | 10 | return newArray; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/common/NumberExtra.ts: -------------------------------------------------------------------------------- 1 | export function absoluteDifference(num1: number, num2: number): number { 2 | return Math.abs(num1 - num2); 3 | } -------------------------------------------------------------------------------- /frontend/src/components/BigReader.tsx: -------------------------------------------------------------------------------- 1 | import { Backdrop, Box, Button, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { create } from "zustand"; 4 | 5 | interface BigReaderState { 6 | bigReader: string | null; 7 | setBigReader: (bigReader: string) => void; 8 | closeBigReader: () => void; 9 | } 10 | 11 | export const useBigReader = create((set) => ({ 12 | bigReader: null, 13 | setBigReader: (bigReader) => set({ bigReader }), 14 | closeBigReader: () => set({ bigReader: null }), 15 | })); 16 | 17 | function BigReader() { 18 | const { bigReader, closeBigReader } = useBigReader(); 19 | if (!bigReader) return null; 20 | return ( 21 | 28 | e.stopPropagation()} 42 | > 43 | 52 | {bigReader} 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | export default BigReader; 62 | -------------------------------------------------------------------------------- /frontend/src/components/CenteredSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from "@mui/material"; 2 | import React from "react"; 3 | 4 | export default function CenteredSpinner() { 5 | return ( 6 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Modal, Typography, Paper, Fade } from "@mui/material"; 2 | import React from "react"; 3 | import { create } from "zustand"; 4 | 5 | // State management with zustand 6 | interface ConfirmModalState { 7 | open: boolean; 8 | title: string; 9 | message: string; 10 | onConfirm: () => void; 11 | onCancel: () => void; 12 | setModal: ({ 13 | title, 14 | message, 15 | onConfirm, 16 | onCancel, 17 | }: { 18 | title: string; 19 | message: string; 20 | onConfirm: () => void; 21 | onCancel: () => void; 22 | }) => void; 23 | } 24 | 25 | export const useConfirmModal = create((set) => ({ 26 | open: false, 27 | title: "", 28 | message: "", 29 | onConfirm: () => {}, 30 | onCancel: () => {}, 31 | setModal: ({ 32 | title, 33 | message, 34 | onConfirm, 35 | onCancel, 36 | }: { 37 | title: string; 38 | message: string; 39 | onConfirm: () => void; 40 | onCancel: () => void; 41 | }) => 42 | set({ 43 | open: true, 44 | title, 45 | message, 46 | onConfirm, 47 | onCancel, 48 | }), 49 | })); 50 | 51 | function ConfirmModal() { 52 | const { open, title, message, onConfirm, onCancel } = useConfirmModal(); 53 | 54 | const handleConfirm = () => { 55 | onConfirm(); 56 | useConfirmModal.setState({ open: false }); 57 | }; 58 | 59 | const handleCancel = () => { 60 | onCancel(); 61 | useConfirmModal.setState({ open: false }); 62 | }; 63 | 64 | return ( 65 | 72 | 73 | 87 | 97 | {title} 98 | 99 | 100 | 108 | {message} 109 | 110 | 111 | 118 | 130 | 131 | 144 | 145 | 146 | 147 | 148 | ); 149 | } 150 | 151 | export default ConfirmModal; 152 | -------------------------------------------------------------------------------- /frontend/src/components/HeroDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PlayArrowRounded, 3 | InfoOutlined, 4 | VolumeOffRounded, 5 | VolumeUpRounded, 6 | PauseRounded, 7 | } from "@mui/icons-material"; 8 | import { Box, Typography, Button, IconButton } from "@mui/material"; 9 | import React, { useEffect, useState } from "react"; 10 | import { useSearchParams, useNavigate } from "react-router-dom"; 11 | import { usePreviewPlayer } from "../states/PreviewPlayerState"; 12 | import ReactPlayer from "react-player"; 13 | import { useBigReader } from "./BigReader"; 14 | import { WatchListButton } from "./MovieItem"; 15 | import { getBackendURL } from "../backendURL"; 16 | import { queryBuilder } from "../plex/QuickFunctions"; 17 | import { getTranscodeImageURL } from "../plex"; 18 | 19 | function HeroDisplay({ item }: { item: Plex.Metadata }) { 20 | const [searchParams, setSearchParams] = useSearchParams(); 21 | const navigate = useNavigate(); 22 | 23 | const { MetaScreenPlayerMuted, setMetaScreenPlayerMuted } = 24 | usePreviewPlayer(); 25 | 26 | const previewVidURL = item?.Extras?.Metadata?.[0]?.Media?.[0]?.Part?.[0]?.key 27 | ? `${getBackendURL()}/dynproxy${item?.Extras?.Metadata?.[0]?.Media?.[0]?.Part?.[0]?.key.split("?")[0]}?${ 28 | queryBuilder({ 29 | "X-Plex-Token": localStorage.getItem("accessToken"), 30 | ...Object.fromEntries(new URL("http://localhost:3000" + item?.Extras?.Metadata?.[0]?.Media?.[0]?.Part?.[0]?.key).searchParams.entries()), 31 | }) 32 | }` 33 | : null; 34 | 35 | const [previewVidPlaying, setPreviewVidPlaying] = useState(false); 36 | 37 | useEffect(() => { 38 | setPreviewVidPlaying(false); 39 | 40 | if (!previewVidURL) return; 41 | 42 | const timeout = setTimeout(() => { 43 | if (window.scrollY > 100) return; 44 | if (searchParams.has("mid")) return; 45 | if (document.location.href.includes("mid=")) return; 46 | setPreviewVidPlaying(true); 47 | }, 3000); 48 | 49 | const onScroll = () => { 50 | if (window.scrollY > 100) setPreviewVidPlaying(false); 51 | else setPreviewVidPlaying(true); 52 | }; 53 | 54 | window.addEventListener("scroll", onScroll); 55 | 56 | return () => { 57 | clearTimeout(timeout); 58 | window.removeEventListener("scroll", onScroll); 59 | }; 60 | }, []); 61 | 62 | return ( 63 | 73 | 91 | { 96 | setPreviewVidPlaying(!previewVidPlaying); 97 | }} 98 | > 99 | {previewVidPlaying ? : } 100 | 101 | 102 | { 107 | setMetaScreenPlayerMuted(!MetaScreenPlayerMuted); 108 | }} 109 | > 110 | {MetaScreenPlayerMuted ? : } 111 | 112 | 113 | 114 | 134 | 153 | { 165 | setPreviewVidPlaying(false); 166 | }} 167 | pip={false} 168 | config={{ 169 | file: { 170 | attributes: { 171 | controlsList: "nodownload", 172 | disablePictureInPicture: true, 173 | disableRemotePlayback: true, 174 | }, 175 | }, 176 | }} 177 | /> 178 | 179 | 180 | 187 | 196 | {/* */} 205 | theme.palette.primary.main, 211 | textTransform: "uppercase", 212 | }} 213 | > 214 | {item.type} 215 | 216 | 217 | 223 | {item.title} 224 | 225 | { 242 | useBigReader.getState().setBigReader(item.summary); 243 | }} 244 | > 245 | {item.summary} 246 | 247 | 248 | 260 | 281 | 282 | 326 | 327 | 328 | 329 | 330 | 331 | 347 | 348 | ); 349 | } 350 | 351 | export default HeroDisplay; 352 | -------------------------------------------------------------------------------- /frontend/src/components/LibraryScreen.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Backdrop, 4 | Box, 5 | CircularProgress, 6 | Grid, 7 | Typography, 8 | } from "@mui/material"; 9 | import React, { useEffect, useState } from "react"; 10 | import { useSearchParams } from "react-router-dom"; 11 | import { getLibraryDir } from "../plex"; 12 | import MovieItem from "./MovieItem"; 13 | import { useInView } from "react-intersection-observer"; 14 | import LibrarySortDropDown, { 15 | LibrarySort, 16 | sortMetadata, 17 | } from "./LibrarySortDropDown"; 18 | import { useWatchListCache } from "../states/WatchListCache"; 19 | 20 | function LibraryScreen() { 21 | const [searchParams, setSearchParams] = useSearchParams(); 22 | 23 | const [loading, setLoading] = useState(false); 24 | const [error, setError] = useState(null); 25 | const [library, setLibrary] = useState(null); 26 | 27 | const [sortBy, setSortBy] = useState( 28 | (localStorage.getItem("sortBy") as LibrarySort) || "title:asc" 29 | ); 30 | const [skipFilter, setSkipFilter] = useState(false); 31 | 32 | const bkey = searchParams.has("bkey") 33 | ? decodeURIComponent(searchParams.get("bkey") as string) 34 | : null; 35 | 36 | useEffect(() => { 37 | const handleKeyDown = (e: KeyboardEvent) => { 38 | if (e.key === "Escape") { 39 | searchParams.delete("bkey"); 40 | setSearchParams(searchParams); 41 | } 42 | }; 43 | 44 | window.addEventListener("keydown", handleKeyDown); 45 | 46 | return () => window.removeEventListener("keydown", handleKeyDown); 47 | }, []); 48 | 49 | useEffect(() => { 50 | if (!(searchParams.has("mid") && searchParams.has("bkey"))) return; 51 | 52 | const localbkey = searchParams.get("bkey"); 53 | searchParams.delete("mid"); 54 | if (localbkey) searchParams.set("bkey", localbkey); 55 | setSearchParams(searchParams); 56 | }, [searchParams, setSearchParams]); 57 | 58 | useEffect(() => { 59 | if (!bkey) return; 60 | 61 | setLoading(true); 62 | setError(null); 63 | setLibrary(null); 64 | setSortBy(localStorage.getItem("sortBy") as LibrarySort); 65 | 66 | switch (bkey) { 67 | case "/plextv/watchlist": 68 | { 69 | const watchlist = useWatchListCache.getState().watchListCache; 70 | setLibrary({ 71 | size: watchlist.length, 72 | title1: "Watchlist", 73 | librarySectionID: 0, 74 | mediaTagPrefix: "", 75 | mediaTagVersion: 0, 76 | viewGroup: "secondary", 77 | Metadata: watchlist, 78 | }); 79 | setLoading(false); 80 | setSkipFilter(true); 81 | } 82 | break; 83 | default: 84 | getLibraryDir(bkey) 85 | .then((data) => { 86 | setLibrary(data); 87 | setLoading(false); 88 | setSkipFilter(false); 89 | }) 90 | .catch((e) => { 91 | setError(e.message); 92 | setLoading(false); 93 | }); 94 | 95 | break; 96 | } 97 | }, [bkey, searchParams]); 98 | 99 | if (loading) 100 | return ( 101 | { 111 | searchParams.delete("bkey"); 112 | setSearchParams(searchParams); 113 | }} 114 | > 115 | 116 | 117 | ); 118 | 119 | if (bkey) 120 | return ( 121 | { 131 | searchParams.delete("bkey"); 132 | setSearchParams(searchParams); 133 | }} 134 | > 135 | 10 && { 147 | pb: "10vh", 148 | }), 149 | 150 | borderRadius: "10px", 151 | }} 152 | onClick={(e) => { 153 | e.stopPropagation(); 154 | }} 155 | > 156 | {error && ( 157 | 158 | {error} 159 | 160 | )} 161 | 162 | 175 | 182 | {library?.title1} {library?.title2 && ` - ${library?.title2}`} 183 | 184 | 185 | 195 | {!skipFilter && ( 196 | 197 | )} 198 | 199 | 200 | 201 | 202 | {library?.Metadata && 203 | (skipFilter 204 | ? library?.Metadata 205 | : sortMetadata(library?.Metadata, sortBy) 206 | ).map((item, index) => ( 207 | 211 | 216 | 217 | ))} 218 | 219 | 220 | 221 | ); 222 | 223 | return <>; 224 | } 225 | 226 | function Element({ item, plexTv }: { item: Plex.Metadata; plexTv?: boolean }) { 227 | const { inView, ref } = useInView(); 228 | 229 | return ( 230 |
231 | {inView && } 232 | {!inView && ( 233 | 234 | 235 | 236 | 237 | )} 238 |
239 | ); 240 | } 241 | 242 | export default LibraryScreen; 243 | -------------------------------------------------------------------------------- /frontend/src/components/LibrarySortDropDown.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import React, { SetStateAction, useEffect } from "react"; 3 | 4 | export type LibrarySort = 5 | | "title:asc" 6 | | "title:desc" 7 | | "addedAt:asc" 8 | | "addedAt:desc" 9 | | "year:asc" 10 | | "year:desc" 11 | | "updated:asc" 12 | | "updated:desc" 13 | | "random:desc"; 14 | 15 | function LibrarySortDropDown({ 16 | sortHook, 17 | }: { 18 | sortHook: [string, React.Dispatch>]; 19 | }) { 20 | const [option, setOption] = sortHook; 21 | 22 | return ( 23 | 40 | ); 41 | } 42 | 43 | export function sortMetadata(items: Plex.Metadata[], sort: LibrarySort) { 44 | switch (sort) { 45 | case "title:asc": 46 | return items.sort((a, b) => a.title.localeCompare(b.title)); 47 | case "title:desc": 48 | return items.sort((a, b) => b.title.localeCompare(a.title)); 49 | case "addedAt:asc": 50 | return items.sort((a, b) => 51 | a.addedAt.toString().localeCompare(b.addedAt.toString()) 52 | ); 53 | case "addedAt:desc": 54 | return items.sort((a, b) => 55 | b.addedAt.toString().localeCompare(a.addedAt.toString()) 56 | ); 57 | case "year:asc": 58 | return items.sort((a, b) => a.year - b.year); 59 | case "year:desc": 60 | return items.sort((a, b) => b.year - a.year); 61 | case "updated:asc": 62 | return items.sort((a, b) => 63 | a.updatedAt.toString().localeCompare(b.updatedAt.toString()) 64 | ); 65 | case "updated:desc": 66 | return items.sort((a, b) => 67 | b.updatedAt.toString().localeCompare(a.updatedAt.toString()) 68 | ); 69 | case "random:desc": 70 | return items.sort(() => Math.random() - 0.5); 71 | default: 72 | return items; 73 | } 74 | } 75 | 76 | export default LibrarySortDropDown; 77 | -------------------------------------------------------------------------------- /frontend/src/components/MovieItemSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { getLibraryDir } from "../plex"; 4 | import { ArrowForwardIosRounded } from "@mui/icons-material"; 5 | import { useSearchParams } from "react-router-dom"; 6 | import MovieItem from "./MovieItem"; 7 | 8 | function MovieItemSlider({ 9 | title, 10 | dir, 11 | props, 12 | filter, 13 | link, 14 | shuffle, 15 | data, 16 | plexTvSource, 17 | }: { 18 | title: string; 19 | dir?: string; 20 | props?: { [key: string]: any }; 21 | filter?: (item: Plex.Metadata) => boolean; 22 | link?: string; 23 | shuffle?: boolean; 24 | data?: Plex.Metadata[]; 25 | plexTvSource?: boolean; 26 | }) { 27 | const [, setSearchParams] = useSearchParams(); 28 | const [items, setItems] = React.useState( 29 | data ?? null 30 | ); 31 | 32 | const [currPage, setCurrPage] = React.useState(0); 33 | 34 | const calculateItemsPerPage = (width: number) => { 35 | if (width < 400) return 1; 36 | if (width < 600) return 1; 37 | if (width < 1200) return 2; 38 | if (width < 1500) return 4; 39 | if (width < 2000) return 5; 40 | if (width < 3000) return 6; 41 | if (width < 4000) return 7; 42 | if (width < 5000) return 8; 43 | return 6; 44 | }; 45 | 46 | const [itemsPerPage, setItemsPerPage] = React.useState( 47 | calculateItemsPerPage(window.innerWidth) 48 | ); 49 | 50 | React.useEffect(() => { 51 | const handleResize = () => { 52 | setItemsPerPage(calculateItemsPerPage(window.innerWidth)); 53 | }; 54 | window.addEventListener("resize", handleResize); 55 | return () => window.removeEventListener("resize", handleResize); 56 | }, []); 57 | 58 | const fetchData = async () => { 59 | if (!dir) return; 60 | 61 | getLibraryDir(dir, props).then((res) => { 62 | // cut the array down so its a multiple of itemsPerPage 63 | if (!res.Metadata) return; 64 | 65 | let media: Plex.Metadata[] = res.Metadata; 66 | if (filter) media = res.Metadata.filter(filter); 67 | 68 | if (!media) return; 69 | setItems(shuffle ? shuffleArray(media) : media); 70 | }); 71 | }; 72 | 73 | React.useEffect(() => { 74 | if (data) return setItems(data); 75 | 76 | fetchData(); 77 | // eslint-disable-next-line react-hooks/exhaustive-deps 78 | }, [data, dir, filter, props, shuffle]); 79 | 80 | if (!items) return <>; 81 | 82 | const itemCount = items.slice(0, itemsPerPage * 5).length; 83 | 84 | return ( 85 | 96 | 107 | :nth-child(2)": { 120 | opacity: 1, 121 | gap: "5px", 122 | }, 123 | transition: "all 0.5s ease", 124 | userSelect: "none", 125 | }} 126 | onClick={() => { 127 | if (link) 128 | setSearchParams( 129 | new URLSearchParams({ 130 | bkey: link, 131 | }) 132 | ); 133 | }} 134 | > 135 | 143 | {title} 144 | 145 | 146 | {link && ( 147 | 160 | Browse 161 | 162 | 163 | )} 164 | 165 | 166 | itemsPerPage ? "visible" : "hidden", 173 | }} 174 | > 175 | {Array(Math.ceil(itemCount / itemsPerPage)) 176 | .fill(0) 177 | .map((_, i) => { 178 | return ( 179 | { 189 | setCurrPage(i); 190 | }} 191 | /> 192 | ); 193 | })} 194 | 195 | 196 | 212 | itemsPerPage ? "visible" : "hidden", 225 | 226 | "&:hover": { 227 | backgroundColor: "#000000AA", 228 | }, 229 | 230 | transition: "all 0.5s ease", 231 | }} 232 | onClick={() => { 233 | setCurrPage((currPage) => 234 | currPage - 1 < 0 235 | ? Math.ceil(itemCount / itemsPerPage) - 1 236 | : currPage - 1 237 | ); 238 | }} 239 | > 240 | 246 | 247 | 259 | {items?.slice(0, itemsPerPage * 5).map((item, i) => { 260 | const start = currPage * itemsPerPage - itemsPerPage; 261 | const end = currPage * itemsPerPage + itemsPerPage * 2; 262 | 263 | if (i >= start && i < end) { 264 | return ( 265 | 275 | ); 276 | } else { 277 | return ( 278 | 285 | 288 | 289 | 290 | ); 291 | } 292 | })} 293 | 294 | itemsPerPage ? "visible" : "hidden", 307 | 308 | "&:hover": { 309 | backgroundColor: "#000000AA", 310 | }, 311 | 312 | transition: "all 0.5s ease", 313 | }} 314 | onClick={() => { 315 | setCurrPage( 316 | currPage + 1 > Math.ceil(itemCount / itemsPerPage) - 1 317 | ? 0 318 | : currPage + 1 319 | ); 320 | }} 321 | > 322 | 323 | 324 | 325 | 326 | ); 327 | } 328 | 329 | export default MovieItemSlider; 330 | 331 | export function durationToText(duration: number): string { 332 | const hours = Math.floor(duration / 1000 / 60 / 60); 333 | const minutes = (duration / 1000 / 60 / 60 - hours) * 60; 334 | 335 | return ( 336 | (hours > 0 ? `${hours}h` : "") + 337 | (Math.floor(minutes) > 0 ? ` ${Math.floor(minutes)}m` : "") 338 | ).trim(); 339 | } 340 | 341 | export const shuffleArray = (array: any[]) => { 342 | const oldArray = [...array]; 343 | const newArray = []; 344 | 345 | while (oldArray.length) { 346 | const index = Math.floor(Math.random() * oldArray.length); 347 | newArray.push(oldArray.splice(index, 1)[0]); 348 | } 349 | 350 | return newArray; 351 | }; 352 | -------------------------------------------------------------------------------- /frontend/src/components/PerPlexedSync.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Backdrop, 3 | Box, 4 | Button, 5 | CircularProgress, 6 | Collapse, 7 | Divider, 8 | Drawer, 9 | TextField, 10 | Typography, 11 | } from "@mui/material"; 12 | import React, { useEffect } from "react"; 13 | import { create } from "zustand"; 14 | import { useSyncSessionState } from "../states/SyncSessionState"; 15 | import { ContentCopyRounded } from "@mui/icons-material"; 16 | import { useNavigate } from "react-router-dom"; 17 | 18 | interface SyncInterfaceState { 19 | open: boolean; 20 | setOpen: (open: boolean) => void; 21 | } 22 | 23 | export const useSyncInterfaceState = create((set, get) => ({ 24 | open: false, 25 | setOpen: (open) => set({ open }), 26 | })); 27 | 28 | function PerPlexedSync() { 29 | const { open, setOpen } = useSyncInterfaceState(); 30 | const { room, isHost } = useSyncSessionState(); 31 | const navigate = useNavigate(); 32 | 33 | const [inputRoom, setInputRoom] = React.useState(""); 34 | const [error, setError] = React.useState(null); 35 | const [page, setPage] = React.useState("home"); 36 | 37 | useEffect(() => { 38 | setInputRoom(""); 39 | if (page !== "join") setError(null); 40 | }, [page]); 41 | 42 | useEffect(() => { 43 | if (!open && !room) setPage("home"); 44 | }, [room, open]); 45 | 46 | return ( 47 | 20000, 50 | }} 51 | open={open} 52 | onClick={() => setOpen(false)} 53 | > 54 | e.stopPropagation()} 69 | > 70 | 77 | Nevu Sync 78 | 79 | 80 | 86 | 87 | {page === "load" && ( 88 | 100 | 101 | 102 | )} 103 | 104 | {page === "connected" && ( 105 | 117 | 126 | 134 | Room ID: 123456 135 | 136 | 148 | 149 | 150 | 160 | {isHost && ( 161 | 170 | )} 171 | 172 | 182 | 183 | 184 | )} 185 | 186 | {page === "join" && ( 187 | 199 | 200 | 206 | {error} 207 | 208 | 209 | setInputRoom(e.target.value)} 214 | /> 215 | 216 | 226 | 235 | 236 | 263 | 264 | 265 | )} 266 | 267 | {page === "home" && ( 268 | 279 | 290 | 307 | 308 | 309 | 310 | 311 | 322 | 331 | 332 | 333 | )} 334 | 335 | 336 | ); 337 | } 338 | 339 | export default PerPlexedSync; 340 | -------------------------------------------------------------------------------- /frontend/src/components/PlaybackNextEPButton.tsx: -------------------------------------------------------------------------------- 1 | import { SkipNext } from "@mui/icons-material"; 2 | import { Box, Button, Typography, useTheme } from "@mui/material"; 3 | import React, { useState, useEffect } from "react"; 4 | import { queryBuilder } from "../plex/QuickFunctions"; 5 | import { useUserSettings } from "../states/UserSettingsState"; 6 | 7 | function PlaybackNextEPButton({ 8 | player, 9 | playing, 10 | playbackBarRef, 11 | metadata, 12 | playQueue, 13 | navigate, 14 | }: { 15 | player: React.MutableRefObject; 16 | playing: boolean; 17 | playbackBarRef: React.MutableRefObject; 18 | metadata: any; 19 | playQueue: any; 20 | navigate: (path: string) => void; 21 | }) { 22 | const theme = useTheme(); 23 | const [countdown, setCountdown] = useState(null); 24 | const [isHovering, setIsHovering] = useState(false); 25 | const countdownDuration = metadata.type === "movie" ? 15 : 4; 26 | 27 | const enableAutoNext = 28 | useUserSettings.getState().settings.AUTO_NEXT_EP === "true"; 29 | 30 | // Start countdown when a next episode is available 31 | useEffect(() => { 32 | if (metadata?.Marker && playQueue && playQueue[1]) { 33 | setCountdown(countdownDuration); 34 | } 35 | }, [metadata, playQueue]); 36 | 37 | // Handle countdown timer 38 | useEffect(() => { 39 | if (countdown === null || isHovering || !playing || !enableAutoNext) return; 40 | 41 | if (countdown <= 0) { 42 | // Auto-navigate when timer reaches 0 43 | handleNavigation(); 44 | return; 45 | } 46 | 47 | const timer = setTimeout(() => { 48 | setCountdown((prev) => (prev !== null ? prev - 0.05 : null)); 49 | }, 50); 50 | 51 | return () => clearTimeout(timer); 52 | }, [countdown, isHovering, playing]); 53 | 54 | const handleNavigation = () => { 55 | if (!player.current || !metadata?.Marker) return; 56 | 57 | if (metadata.type === "movie") 58 | return navigate( 59 | `/browse/${metadata.librarySectionID}?${queryBuilder({ 60 | mid: metadata.ratingKey, 61 | })}` 62 | ); 63 | 64 | if (!playQueue) return; 65 | const next = playQueue[1]; 66 | if (!next) 67 | return navigate( 68 | `/browse/${metadata.librarySectionID}?${queryBuilder({ 69 | mid: metadata.grandparentRatingKey, 70 | pid: metadata.parentRatingKey, 71 | iid: metadata.ratingKey, 72 | })}` 73 | ); 74 | 75 | navigate(`/watch/${next.ratingKey}?t=0`); 76 | }; 77 | 78 | // Calculate progress percentage 79 | const progressPercentage = 80 | countdown !== null 81 | ? ((countdownDuration - countdown) / countdownDuration) * 100 82 | : 0; 83 | 84 | return ( 85 | 136 | ); 137 | } 138 | 139 | export default PlaybackNextEPButton; 140 | -------------------------------------------------------------------------------- /frontend/src/components/ToastManager.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PersonAddRounded, 3 | PersonRemoveRounded, 4 | PlayArrowRounded, 5 | PauseRounded, 6 | ResetTvRounded, 7 | } from "@mui/icons-material"; 8 | import { Avatar, Box, Divider, Typography } from "@mui/material"; 9 | import { create } from "zustand"; 10 | import { useEffect, useState } from "react"; 11 | import React from "react"; 12 | 13 | export interface ToastState { 14 | toasts: ToastProps[]; 15 | addToast: ( 16 | user: PerPlexed.Sync.Member, 17 | icon: ToastIcons, 18 | message: string, 19 | duration: number 20 | ) => void; 21 | } 22 | 23 | export const useToast = create((set) => ({ 24 | toasts: [], 25 | addToast: ( 26 | user: PerPlexed.Sync.Member, 27 | icon: ToastIcons, 28 | message: string, 29 | duration: number = 5000 30 | ) => { 31 | const toastID = Math.random() * 1000; 32 | 33 | set((state) => ({ 34 | toasts: [ 35 | ...state.toasts, 36 | { 37 | id: toastID, 38 | user, 39 | icon, 40 | message, 41 | duration, 42 | toRemove: false, 43 | }, 44 | ], 45 | })); 46 | }, 47 | })); 48 | 49 | interface ToastProps { 50 | id: number; 51 | duration: number; 52 | message: string; 53 | user: PerPlexed.Sync.Member; 54 | icon: ToastIcons; 55 | toRemove?: boolean; 56 | } 57 | 58 | function ToastManager() { 59 | const { toasts } = useToast(); 60 | 61 | useEffect(() => { 62 | const interval = setInterval(() => { 63 | let toasts = useToast.getState().toasts; 64 | 65 | if (!toasts.length) return; 66 | 67 | // only filter if all toasts are to be removed 68 | if (toasts.every((toast) => toast.toRemove)) { 69 | toasts = toasts.filter((toast) => !toast.toRemove); 70 | 71 | useToast.setState({ 72 | toasts, 73 | }); 74 | } 75 | }, 500); 76 | return () => clearInterval(interval); 77 | }, []); 78 | 79 | return ( 80 | 95 | {toasts.map((toast, index) => ( 96 | 104 | ))} 105 | 106 | ); 107 | } 108 | 109 | export default ToastManager; 110 | 111 | export type ToastIcons = 112 | | "Play" 113 | | "Pause" 114 | | "UserAdd" 115 | | "UserRemove" 116 | | "PlaySet"; 117 | 118 | export function Toast({ 119 | id, 120 | user, 121 | icon, 122 | message, 123 | duration = 5000, 124 | }: { 125 | id: number; 126 | user: PerPlexed.Sync.Member; 127 | icon: ToastIcons; 128 | message: string; 129 | duration?: number; 130 | }) { 131 | /* 132 | * Animation States: 133 | * 0 - Slide in 134 | * 1 - Static 135 | * 2 - Slide out 136 | */ 137 | const [animationState, setAnimationState] = useState(0); 138 | 139 | useEffect(() => { 140 | setTimeout(() => { 141 | setAnimationState(1); 142 | }, 500); 143 | 144 | setTimeout(() => { 145 | setAnimationState(2); 146 | 147 | setTimeout(() => { 148 | console.log(id); 149 | 150 | let toasts = useToast.getState().toasts; 151 | 152 | toasts = toasts.map((toast) => { 153 | if (toast.id === id) { 154 | toast.toRemove = true; 155 | } 156 | 157 | return toast; 158 | }); 159 | 160 | useToast.setState({ 161 | toasts, 162 | }); 163 | }, 500); 164 | }, duration); 165 | }, []); 166 | 167 | return ( 168 | 188 | {icon === "Play" && ( 189 | 195 | )} 196 | 197 | {icon === "Pause" && ( 198 | 204 | )} 205 | 206 | {icon === "PlaySet" && ( 207 | 213 | )} 214 | 215 | {icon === "UserAdd" && ( 216 | 222 | )} 223 | 224 | {icon === "UserRemove" && ( 225 | 231 | )} 232 | 233 | 239 | 240 | 248 | 260 | 267 | 268 | 278 | {user.name} 279 | 280 | 281 | 282 | 295 | {message} 296 | 297 | 298 | 299 | ); 300 | } 301 | -------------------------------------------------------------------------------- /frontend/src/components/settings/CheckBoxOption.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Checkbox, Typography, Box } from "@mui/material"; 2 | import React from "react"; 3 | 4 | function CheckBoxOption({ 5 | title, 6 | subtitle, 7 | checked, 8 | onChange, 9 | }: { 10 | title: string; 11 | subtitle?: string; 12 | checked: boolean; 13 | onChange: (checked: boolean) => void; 14 | }) { 15 | return ( 16 | 17 | 18 | onChange(e.target.checked)} 21 | /> 22 | {title} 23 | 24 | {subtitle && ( 25 | 31 | {subtitle} 32 | 33 | )} 34 | 35 | ); 36 | } 37 | 38 | export default CheckBoxOption; 39 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body, #root { 2 | font-family: 'Roboto', sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | width: 100%; 6 | } 7 | 8 | .head-link::after { 9 | content: ''; 10 | display: block; 11 | width: 0; 12 | height: 2px; 13 | background: #fff; 14 | transition: width .3s; 15 | margin-left: auto; 16 | margin-right: auto; 17 | } 18 | 19 | .head-link:hover::after { 20 | width: 100%; 21 | transition: width .3s; 22 | } 23 | 24 | .head-link-active::after { 25 | width: 100%; 26 | transition: width .3s; 27 | } 28 | 29 | a { 30 | text-decoration: none; 31 | color: #fff; 32 | } 33 | 34 | .ui-video-seek-slider .hover-time .preview-screen { 35 | width: 240px !important; 36 | height: 135px !important; 37 | background-color: transparent !important; 38 | } -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import { ThemeProvider } from "@emotion/react"; 6 | import { CssBaseline, createTheme } from "@mui/material"; 7 | import { BrowserRouter } from "react-router-dom"; 8 | import { makeid, uuidv4 } from "./plex/QuickFunctions"; 9 | 10 | import "@fontsource-variable/quicksand"; 11 | import "@fontsource-variable/rubik"; 12 | import "@fontsource/ibm-plex-sans"; 13 | import "@fontsource-variable/inter"; 14 | 15 | if (!localStorage.getItem("clientID")) 16 | localStorage.setItem("clientID", makeid(24)); 17 | 18 | sessionStorage.setItem("sessionID", uuidv4()); 19 | 20 | let config: PerPlexed.ConfigOptions = { 21 | DISABLE_PROXY: false, // DEPRECATED 22 | DISABLE_NEVU_SYNC: false, 23 | }; 24 | 25 | (() => { 26 | if (!localStorage.getItem("config")) return; 27 | config = JSON.parse( 28 | localStorage.getItem("config") as string 29 | ) as PerPlexed.ConfigOptions; 30 | })(); 31 | 32 | if (!localStorage.getItem("quality")) localStorage.setItem("quality", "12000"); 33 | 34 | export { config }; 35 | 36 | const root = ReactDOM.createRoot( 37 | document.getElementById("root") as HTMLElement 38 | ); 39 | 40 | root.render( 41 | 109 | 110 | 111 | 112 | 113 | 114 | ); -------------------------------------------------------------------------------- /frontend/src/pages/Browse.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Button, ButtonGroup } from "@mui/material"; 3 | import { create } from "zustand"; 4 | import { AnimatePresence } from "framer-motion"; 5 | import BrowseRecommendations from "./browse/BrowseRecommendations"; 6 | import BrowseLibrary from "./browse/BrowseLibrary"; 7 | 8 | type BrowsePages = "recommendations" | "browse"; 9 | 10 | interface BrowsePageOptionsState { 11 | page: BrowsePages; 12 | setPage: (page: BrowsePages) => void; 13 | } 14 | 15 | const useBrowsePageOptions = create((set) => ({ 16 | page: 17 | (localStorage.getItem("browsePage") as BrowsePages) || "recommendations", 18 | setPage: (page: BrowsePages) => { 19 | localStorage.setItem("browsePage", page); 20 | set({ page }); 21 | }, 22 | })); 23 | 24 | function Library() { 25 | const { page, setPage } = useBrowsePageOptions(); 26 | 27 | return ( 28 | 37 | 56 | 76 | 96 | 97 | 98 | 99 | {page === "recommendations" && } 100 | {page === "browse" && } 101 | 102 | 103 | ); 104 | } 105 | 106 | export default Library; 107 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, CircularProgress, Grid, Typography } from "@mui/material"; 2 | import React, { useEffect } from "react"; 3 | import { 4 | getAllLibraries, 5 | getLibraryDir, 6 | getLibraryMeta, 7 | getLibrarySecondary, 8 | getTranscodeImageURL, 9 | } from "../plex"; 10 | import { useNavigate } from "react-router-dom"; 11 | import { shuffleArray } from "../common/ArrayExtra"; 12 | import MovieItemSlider from "../components/MovieItemSlider"; 13 | import HeroDisplay from "../components/HeroDisplay"; 14 | import { useWatchListCache } from "../states/WatchListCache"; 15 | 16 | export default function Home() { 17 | const [libraries, setLibraries] = React.useState([]); 18 | const [featured, setFeatured] = React.useState< 19 | PerPlexed.RecommendationShelf[] 20 | >([]); 21 | const [randomItem, setRandomItem] = React.useState( 22 | null 23 | ); 24 | const { watchListCache } = useWatchListCache(); 25 | 26 | const [loading, setLoading] = React.useState(true); 27 | 28 | useEffect(() => { 29 | async function fetchData() { 30 | setLoading(true); 31 | try { 32 | const librariesData = await getAllLibraries(); 33 | setLibraries(librariesData); 34 | 35 | const filteredLibraries = librariesData.filter((lib) => 36 | ["movie", "show"].includes(lib.type) 37 | ); 38 | 39 | const featuredData = await getRecommendations(filteredLibraries); 40 | setFeatured(featuredData); 41 | 42 | let randomItemData = await getRandomItem(filteredLibraries); 43 | let attempts = 0; 44 | while (!randomItemData && attempts < 15) { 45 | randomItemData = await getRandomItem(filteredLibraries); 46 | attempts++; 47 | } 48 | 49 | if (!randomItemData) return; 50 | 51 | const data = await getLibraryMeta(randomItemData?.ratingKey as string); 52 | setRandomItem(data); 53 | } catch (error) { 54 | console.error("Error fetching data", error); 55 | } finally { 56 | setLoading(false); 57 | } 58 | } 59 | 60 | fetchData(); 61 | }, []); 62 | const navigate = useNavigate(); 63 | 64 | if (loading) 65 | return ( 66 | 76 | 77 | 78 | ); 79 | 80 | return ( 81 | 92 | {randomItem && } 93 | 106 | 107 | {libraries 108 | ?.filter((e) => ["movie", "show"].includes(e.type || "")) 109 | .map((library) => ( 110 | 114 | theme.shadows[1], 127 | transition: "all 0.2s ease", 128 | 129 | "&:hover": { 130 | transform: "translateY(-4px) scale(1.02)", 131 | boxShadow: (theme) => theme.shadows[3], 132 | }, 133 | }} 134 | onClick={() => navigate(`/browse/${library.key}`)} 135 | > 136 | {/* Background image */} 137 | 154 | 155 | {/* Theme color overlay */} 156 | `linear-gradient(180deg, 165 | ${theme.palette.primary.dark}99, 166 | ${theme.palette.background.default}EE)`, 167 | opacity: 0.85, 168 | zIndex: -1, 169 | transition: "opacity 0.2s ease", 170 | }} 171 | /> 172 | 173 | 183 | theme.shadows[2], 190 | }} 191 | /> 192 | 200 | {library.title} 201 | 202 | 203 | 204 | 205 | ))} 206 | 207 | 208 | 218 | 223 | 224 | {watchListCache && watchListCache.length > 0 && ( 225 | 231 | )} 232 | 233 | {featured && 234 | featured.map((item, index) => ( 235 | 242 | ))} 243 | 244 | 245 | 246 | ); 247 | } 248 | 249 | async function getRecommendations(libraries: Plex.Directory[]) { 250 | const genreSelection: PerPlexed.RecommendationShelf[] = []; 251 | 252 | for (const library of libraries) { 253 | const genres = await getLibrarySecondary(library.key, "genre"); 254 | 255 | if (!genres || !genres.length) continue; 256 | 257 | const selectGenres: Plex.Directory[] = []; 258 | 259 | // Get 5 random genres 260 | while (selectGenres.length < Math.min(5, genres.length)) { 261 | const genre = genres[Math.floor(Math.random() * genres.length)]; 262 | if (selectGenres.includes(genre)) continue; 263 | selectGenres.push(genre); 264 | } 265 | 266 | for (const genre of selectGenres) { 267 | genreSelection.push({ 268 | title: `${library.title} - ${genre.title}`, 269 | libraryID: library.key, 270 | dir: `/library/sections/${library.key}/genre/${genre.key}`, 271 | link: `/library/sections/${library.key}/genre/${genre.key}`, 272 | }); 273 | } 274 | } 275 | 276 | return shuffleArray(genreSelection); 277 | } 278 | 279 | // get one completely random item from any library 280 | async function getRandomItem(libraries: Plex.Directory[]) { 281 | try { 282 | const library = libraries[Math.floor(Math.random() * libraries.length)]; 283 | 284 | const items = await getLibraryDir(`/library/sections/${library.key}/all`, { 285 | sort: "random:desc", 286 | limit: 1, 287 | }); 288 | 289 | return items.Metadata?.[0] || null; 290 | } catch (error) { 291 | console.log("Error fetching random item", error); 292 | return null; 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /frontend/src/pages/Library.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Grid, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { useParams, useSearchParams } from "react-router-dom"; 4 | import { getLibraryDir } from "../plex"; 5 | import MovieItem from "../components/MovieItem"; 6 | 7 | export default function Library() { 8 | const { dir } = useParams() as { 9 | dir: string; 10 | }; 11 | // get the query strings from react router 12 | const [searchParams] = useSearchParams(); 13 | 14 | const [results, setResults] = React.useState(null); 15 | const [isLoading, setIsLoading] = React.useState(true); 16 | const [isError, setIsError] = React.useState(false); 17 | 18 | React.useEffect(() => { 19 | const fetchData = async () => { 20 | try { 21 | const params = new URLSearchParams(searchParams); 22 | if (params.has("mid")) params.delete("mid"); 23 | 24 | const data = await getLibraryDir(dir, searchParams); 25 | setResults(data); 26 | } catch (error) { 27 | setIsError(true); 28 | } finally { 29 | setIsLoading(false); 30 | } 31 | }; 32 | 33 | fetchData(); 34 | }, [dir, searchParams]); 35 | 36 | return ( 37 | 52 | {isLoading && } 53 | {isError && Error} 54 | 55 | {results && ( 56 | <> 57 | 58 | {results.title1} - {results.title2} 59 | 60 | {!results && } 61 | 62 | {results && 63 | results.Metadata?.map((item) => ( 64 | 68 | 69 | 70 | ))} 71 | 72 | 73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Box, 4 | CircularProgress, 5 | Collapse, 6 | Typography, 7 | } from "@mui/material"; 8 | import React, { useEffect } from "react"; 9 | import { queryBuilder } from "../plex/QuickFunctions"; 10 | import { useSearchParams } from "react-router-dom"; 11 | import { getAccessToken, getPin } from "../plex"; 12 | import axios from "axios"; 13 | import { ProxiedRequest } from "../backendURL"; 14 | import { XMLParser } from "fast-xml-parser"; 15 | 16 | export default function Login() { 17 | const [query] = useSearchParams(); 18 | const [error, setError] = React.useState(null); 19 | useEffect(() => { 20 | if (!query.has("pinID")) { 21 | (async () => { 22 | const res = await getPin(); 23 | 24 | window.location.href = `https://app.plex.tv/auth/#!?clientID=${localStorage.getItem( 25 | "clientID" 26 | )}&context[device][product]=Plex%20Web&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( 27 | ":", 28 | "" 29 | )}&forwardUrl=${window.location.protocol}//${ 30 | window.location.host 31 | }/login?pinID=${res.id}&code=${res.code}&language=en`; 32 | })(); 33 | } 34 | 35 | if (query.has("pinID")) { 36 | (async () => { 37 | try { 38 | const res = await getAccessToken(query.get("pinID") as string); 39 | 40 | if (!res.authToken) 41 | return setError("Failed to log in. Please try again."); 42 | 43 | console.log("1", res); 44 | 45 | // check token validity against the server 46 | // const tokenCheck = await ProxiedRequest(`/?${queryBuilder({ "X-Plex-Token": res.authToken })}`, "GET", {}) 47 | 48 | // if (tokenCheck.status === 200) { 49 | // localStorage.setItem("accessToken", res.authToken); 50 | // localStorage.setItem("accAccessToken", res.authToken); 51 | // window.location.href = "/"; 52 | // } 53 | 54 | // console.log("2", tokenCheck); 55 | 56 | const serverIdentity = await ProxiedRequest("/identity", "GET", { 57 | "X-Plex-Token": res.authToken, 58 | }); 59 | 60 | console.log("3", serverIdentity); 61 | 62 | if (!serverIdentity || !serverIdentity.data.MediaContainer) 63 | return setError( 64 | `Failed to log in: ${ 65 | serverIdentity.data.errors[0].message || "Unknown error" 66 | }` 67 | ); 68 | 69 | const serverID = serverIdentity.data.MediaContainer.machineIdentifier; 70 | 71 | const parser = new XMLParser({ 72 | attributeNamePrefix: "", 73 | textNodeName: "value", 74 | ignoreAttributes: false, 75 | parseAttributeValue: true, 76 | }); 77 | 78 | // try getting a shared server 79 | const sharedServersXML = await axios.get( 80 | `https://plex.tv/api/resources?${queryBuilder({ 81 | "X-Plex-Token": res.authToken, 82 | })}` 83 | ); 84 | 85 | const sharedServers = parser.parse(sharedServersXML.data); 86 | console.log("4", sharedServers); 87 | 88 | let targetServer; 89 | 90 | if (sharedServers.MediaContainer.size === 1) 91 | targetServer = sharedServers.MediaContainer.Device; 92 | else 93 | targetServer = sharedServers.MediaContainer.Device.find( 94 | (server: any) => server.clientIdentifier === serverID 95 | ); 96 | 97 | if (!targetServer) 98 | return setError("You do not have access to this server."); 99 | 100 | localStorage.setItem("accessToken", targetServer.accessToken); 101 | localStorage.setItem("accAccessToken", res.authToken); 102 | 103 | window.location.href = "/"; 104 | } catch (e) { 105 | console.log(e); 106 | setError("Failed to log in. Please try again."); 107 | } 108 | })(); 109 | } 110 | }, [query]); 111 | 112 | return ( 113 | 122 | 123 | {error} 124 | 125 | {!error && ( 126 | <> 127 | 128 | 134 | Logging in... 135 | 136 | 137 | )} 138 | 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /frontend/src/pages/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Grid, Typography } from "@mui/material"; 2 | import React, { useEffect, useState } from "react"; 3 | import { useParams, useSearchParams } from "react-router-dom"; 4 | import { getSearch } from "../plex"; 5 | import MovieItem from "../components/MovieItem"; 6 | 7 | export default function Search() { 8 | const { query } = useParams(); 9 | const [, setSearchParams] = useSearchParams(); 10 | 11 | const [results, setResults] = useState(null); 12 | const [directories, setDirectories] = useState(null); 13 | 14 | useEffect(() => { 15 | setResults(null); 16 | setDirectories(null); 17 | 18 | if (!query) return; 19 | 20 | const delayDebounceFn = setTimeout(() => { 21 | getSearch(query).then((res) => { 22 | if (!res) return setResults([]); 23 | setResults( 24 | res 25 | .filter( 26 | (item) => 27 | item.Metadata && ["movie", "show"].includes(item.Metadata.type) 28 | ) 29 | .map((item) => item.Metadata) 30 | .filter( 31 | (metadata): metadata is Plex.Metadata => metadata !== undefined 32 | ) 33 | ); 34 | 35 | setDirectories( 36 | res 37 | .filter((item) => item.Directory) 38 | .map((item) => item.Directory) 39 | .filter( 40 | (directory): directory is Plex.Directory => 41 | directory !== undefined 42 | ) 43 | ); 44 | }); 45 | }, 500); // Adjust the delay as needed 46 | 47 | return () => clearTimeout(delayDebounceFn); 48 | }, [query]); 49 | 50 | return ( 51 | 67 | 68 | {query ? ( 69 | <> 70 | Results for {query} 71 | 72 | ) : ( 73 | "Use Search Bar to Search" 74 | )} 75 | 76 | 77 | {!results && } 78 | 79 | 80 | {directories && directories.length > 0 && ( 81 | <> 82 | 83 | Categories 84 | 85 | 86 | {directories.map((item) => ( 87 | 88 | { 91 | setSearchParams( 92 | new URLSearchParams({ 93 | bkey: `/library/sections/${item.librarySectionID}/genre/${item.id}`, 94 | }) 95 | ); 96 | }} 97 | /> 98 | 99 | ))} 100 | 101 | 102 | 103 | )} 104 | 105 | {results && 106 | results.map((item) => ( 107 | 111 | 112 | 113 | ))} 114 | 115 | 116 | ); 117 | } 118 | 119 | export function DirectoryItem({ 120 | item, 121 | onClick, 122 | }: { 123 | item: Plex.Directory; 124 | onClick: () => void; 125 | }) { 126 | return ( 127 | 148 | 149 | {item.librarySectionTitle} - {item.tag} 150 | 151 | 152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /frontend/src/pages/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { Link, Route, Routes, useLocation } from "react-router-dom"; 4 | import SettingsInfo from "./settings/SettingsInfo"; 5 | import SettingsPlayback from "./settings/SettingsPlayback"; 6 | import { useUserSettings } from "../states/UserSettingsState"; 7 | import SettingsRecommendations from "./settings/SettingsRecommendations"; 8 | 9 | function Settings() { 10 | const { loaded } = useUserSettings(); 11 | 12 | if (!loaded) 13 | return ( 14 | 23 | 24 | 25 | ); 26 | 27 | return ( 28 | 42 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 76 | 77 | } /> 78 | 79 | } /> 80 | } /> 81 | 82 | 83 | 84 | ); 85 | } 86 | 87 | export default Settings; 88 | 89 | function SettingsDivider({ title }: { title: string }) { 90 | return ( 91 | 102 | 109 | {title} 110 | 111 | 112 | ); 113 | } 114 | 115 | function SettingsItem({ title, link }: { title: string; link: string }) { 116 | const { pathname } = useLocation(); 117 | 118 | return ( 119 | 120 | 138 | pathname === link ? theme.palette.primary.main : theme.palette.text.primary, 141 | fontSize: "1rem", 142 | userSelect: "none", 143 | }} 144 | > 145 | {title} 146 | 147 | 148 | 149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /frontend/src/pages/Startup.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material"; 2 | import axios from "axios"; 3 | import React, { useEffect, useRef } from "react"; 4 | import { create } from "zustand"; 5 | import { getBackendURL } from "../backendURL"; 6 | import Utility from "./Utility"; 7 | 8 | interface StartupState { 9 | loading: boolean; 10 | setLoading: (loading: boolean) => void; 11 | 12 | showDiagnostic: boolean; 13 | setShowDiagnostic: (showDiagnostic: boolean) => void; 14 | 15 | lastStatus?: PerPlexed.Status; 16 | setLastStatus: (status: PerPlexed.Status) => void; 17 | 18 | frontEndStatus?: PerPlexed.Status; 19 | setFrontEndStatus: (frontEndStatus: PerPlexed.Status) => void; 20 | } 21 | 22 | export const useStartupState = create((set) => ({ 23 | loading: true, 24 | setLoading: (loading) => set({ loading }), 25 | 26 | showDiagnostic: false, 27 | setShowDiagnostic: (showDiagnostic) => set({ showDiagnostic }), 28 | 29 | lastStatus: undefined, 30 | setLastStatus: (lastStatus) => set({ lastStatus }), 31 | 32 | frontEndStatus: undefined, 33 | setFrontEndStatus: (frontEndStatus) => set({ frontEndStatus }), 34 | })); 35 | 36 | function Startup() { 37 | const { 38 | loading, 39 | showDiagnostic, 40 | setShowDiagnostic, 41 | lastStatus, 42 | setLastStatus, 43 | setLoading, 44 | frontEndStatus, 45 | setFrontEndStatus, 46 | } = useStartupState(); 47 | 48 | const skipUpdates = useRef(false); 49 | 50 | useEffect(() => { 51 | if (!loading || skipUpdates.current) return; 52 | 53 | const fetchStatus = async () => { 54 | const res = await axios 55 | .get(`${getBackendURL()}/status`, { 56 | timeout: 5000, 57 | }) 58 | .then((res) => res.data as PerPlexed.Status) 59 | .catch(() => null); 60 | if (!res) { 61 | setFrontEndStatus({ 62 | ready: false, 63 | error: true, 64 | message: 65 | "Frontend could not connect to the backend. Please check the backend logs for more information.", 66 | }); 67 | return; 68 | } 69 | 70 | setLastStatus(res); 71 | if (res.error) setShowDiagnostic(true); 72 | }; 73 | 74 | fetchStatus(); 75 | 76 | const interval = setInterval(fetchStatus, 2500); 77 | 78 | return () => { 79 | clearInterval(interval); 80 | }; 81 | }, [loading, setFrontEndStatus, setLastStatus, setShowDiagnostic]); 82 | 83 | useEffect(() => { 84 | if (!lastStatus || skipUpdates.current) return; 85 | 86 | if (lastStatus.ready) { 87 | (async () => { 88 | skipUpdates.current = true; 89 | let reload = false; 90 | 91 | const config = await axios 92 | .get(`${getBackendURL()}/config`) 93 | .then((res) => res.data as PerPlexed.Config) 94 | .catch(() => null); 95 | 96 | if (!config) { 97 | setFrontEndStatus({ 98 | ready: false, 99 | error: true, 100 | message: 101 | "Frontend could not retrieve the configuration from the backend. Please check the backend logs for more information.", 102 | }); 103 | skipUpdates.current = false; 104 | return; 105 | } 106 | 107 | if (JSON.stringify(config.CONFIG) !== localStorage.getItem("config")) { 108 | localStorage.setItem("config", JSON.stringify(config.CONFIG)); 109 | reload = true; 110 | } 111 | 112 | // const currServer = localStorage.getItem("server"); 113 | 114 | // if(config.PLEX_SERVER !== currServer) reload = true; 115 | // skipUpdates.current = false; 116 | 117 | // localStorage.setItem("server", config.PLEX_SERVER); 118 | localStorage.setItem("deploymentId", config.DEPLOYMENTID); 119 | 120 | 121 | if (reload) return window.location.reload(); 122 | else { 123 | setShowDiagnostic(false); 124 | setLoading(false); 125 | } 126 | })(); 127 | } 128 | }, [ 129 | lastStatus, 130 | setFrontEndStatus, 131 | setLastStatus, 132 | setLoading, 133 | setShowDiagnostic, 134 | ]); 135 | 136 | if (showDiagnostic || frontEndStatus) return ; 137 | 138 | return ( 139 | 149 | NEVU Logo 154 | 155 | ); 156 | } 157 | 158 | export default Startup; 159 | -------------------------------------------------------------------------------- /frontend/src/pages/Utility.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { useStartupState } from "./Startup"; 4 | 5 | function Utility() { 6 | const { lastStatus, frontEndStatus } = useStartupState(); 7 | 8 | return ( 9 | 19 | 32 | 41 | NEVU has encountered an error 42 | 43 | 44 | {lastStatus?.error && ( 45 | 55 | {lastStatus.message.split(" ").map((word, index) => ( 56 | 57 | {word.match(/https?:\/\/[^\s]+/) ? ( 58 | <> 59 |
60 |
61 | 67 | {word} 68 | 69 |
70 | 71 | ) : ( 72 | word + " " 73 | )} 74 |
75 | ))} 76 |
77 | )} 78 | 79 | {frontEndStatus?.error && ( 80 | 90 | {frontEndStatus.message.split(" ").map((word, index) => ( 91 | 92 | {word.match(/https?:\/\/[^\s]+/) ? ( 93 | <> 94 |
95 |
96 | 102 | {word} 103 | 104 |
105 | 106 | ) : ( 107 | word + " " 108 | )} 109 |
110 | ))} 111 |
112 | )} 113 |
114 |
115 | ); 116 | } 117 | 118 | export default Utility; 119 | -------------------------------------------------------------------------------- /frontend/src/pages/WaitingRoom.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, LinearProgress, Typography } from "@mui/material"; 2 | import React, { useEffect } from "react"; 3 | import { useSyncSessionState } from "../states/SyncSessionState"; 4 | import { useSyncInterfaceState } from "../components/PerPlexedSync"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | function WaitingRoom() { 8 | const [loading, setLoading] = React.useState(true); 9 | 10 | const { room, isHost, socket } = useSyncSessionState(); 11 | const { setOpen } = useSyncInterfaceState(); 12 | const navigate = useNavigate(); 13 | 14 | useEffect(() => { 15 | if (isHost || !room) navigate("/"); 16 | }, [room, isHost, navigate]); 17 | 18 | useEffect(() => { 19 | if(!socket) return; 20 | 21 | socket.once("RES_SYNC_RESYNC_PLAYBACK", (user, data: PerPlexed.Sync.PlayBackState) => { 22 | console.log("Playback resync received", data); 23 | navigate(`/watch/${data.key}?t=${data.time}`); 24 | }) 25 | }, [navigate, socket]); 26 | 27 | return ( 28 | 39 | NEVU 48 | 49 | 58 | Waiting for host to start playback... 59 | 60 | 61 | {loading && ( 62 | 68 | )} 69 | 70 | 73 | 74 | ); 75 | } 76 | 77 | export default WaitingRoom; 78 | -------------------------------------------------------------------------------- /frontend/src/pages/browse/BrowseLibrary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Divider, 4 | Grid, 5 | MenuItem, 6 | Select, 7 | Skeleton, 8 | } from "@mui/material"; 9 | import { AnimatePresence, motion } from "framer-motion"; 10 | import React, { useEffect } from "react"; 11 | import { useParams } from "react-router-dom"; 12 | import { getLibrary, getLibraryDir } from "../../plex"; 13 | import MovieItem from "../../components/MovieItem"; 14 | import { useInView } from "react-intersection-observer"; 15 | 16 | export const libTypeToNum = (type: string) => { 17 | switch (type) { 18 | case "movie": 19 | return 1; 20 | case "show": 21 | return 2; 22 | case "episode": 23 | return 4; 24 | default: 25 | return 0; 26 | } 27 | }; 28 | 29 | function BrowseLibrary() { 30 | const { libraryID } = useParams<{ libraryID: string }>(); 31 | const [library, setLibrary] = React.useState( 32 | null 33 | ); 34 | const [items, setItems] = React.useState(null); 35 | 36 | const [isLoading, setIsLoading] = React.useState(true); 37 | 38 | const [primaryFilter, setPrimaryFilter] = React.useState( 39 | localStorage.getItem("primaryFilter") || "all" 40 | ); 41 | 42 | const [typeFilter, setTypeFilter] = React.useState( 43 | localStorage.getItem("typeFilter") || "any" 44 | ); 45 | 46 | const [sortBy, setSortBy] = React.useState( 47 | localStorage.getItem("sortBy") || "title:asc" 48 | ); 49 | 50 | useEffect(() => { 51 | if (!libraryID) return; 52 | getLibrary(libraryID).then((data) => { 53 | setLibrary(data); 54 | }); 55 | }, [libraryID]); 56 | 57 | useEffect(() => { 58 | setItems(null); 59 | setIsLoading(true); 60 | 61 | let conEnd = "all"; 62 | let extraProps = {}; 63 | let sortString = sortBy; 64 | 65 | switch (primaryFilter) { 66 | case "watched": 67 | conEnd = "all"; 68 | extraProps = { 69 | "show.unwatchedLeaves!": 1, 70 | "unwatched!": 1, 71 | }; 72 | break; 73 | default: 74 | conEnd = primaryFilter; 75 | break; 76 | } 77 | 78 | switch (sortBy) { 79 | case "updated:asc": 80 | case "updated:desc": 81 | if (library?.Type?.[0].type === "show") sortString = "title:asc"; 82 | break; 83 | } 84 | 85 | if (!library) return; 86 | getLibraryDir( 87 | `/library/sections/${library.librarySectionID.toString()}/${conEnd}`, 88 | { 89 | ...extraProps, 90 | ...(primaryFilter === "all" && 91 | typeFilter !== "any" && { 92 | type: libTypeToNum(typeFilter), 93 | }), 94 | sort: sortString, 95 | } 96 | ).then(async (media) => { 97 | if (!media) return; 98 | 99 | switch (sortBy) { 100 | case "updated:asc": 101 | case "updated:desc": 102 | media.Metadata = media.Metadata?.sort((a, b) => { 103 | if (sortBy === "updated:asc") return a.updatedAt - b.updatedAt; 104 | else return b.updatedAt - a.updatedAt; 105 | }); 106 | break; 107 | } 108 | 109 | setItems(media); 110 | setIsLoading(false); 111 | }); 112 | }, [library, primaryFilter, sortBy, typeFilter]); 113 | 114 | return ( 115 | 132 | 145 | 161 | 162 | {primaryFilter === "all" && ( 163 | 186 | )} 187 | 188 | 208 | 209 | 210 | {/* {isLoading && ( 211 | 220 | 221 | 222 | )} */} 223 | 224 | 237 | 238 | 239 | {isLoading && 240 | "1" 241 | .repeat(50) 242 | .split("") 243 | .map((_, index) => ( 244 | 248 | 259 | 260 | ))} 261 | {items && 262 | !isLoading && 263 | items.Metadata?.map((item) => ( 264 | 268 | 269 | 270 | ))} 271 | 272 | 273 | 274 | 275 | ); 276 | } 277 | 278 | export default BrowseLibrary; 279 | 280 | function DisplayMovieItem({ item }: { item: Plex.Metadata }) { 281 | const { inView, ref } = useInView(); 282 | 283 | return ( 284 |
291 | {inView && } 292 | {!inView && ( 293 | 294 | 295 | 296 | 297 | )} 298 |
299 | ); 300 | } 301 | -------------------------------------------------------------------------------- /frontend/src/pages/browse/BrowseRecommendations.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, ButtonGroup, Button } from "@mui/material"; 2 | import React, { useEffect } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import HeroDisplay from "../../components/HeroDisplay"; 5 | import MovieItemSlider, { 6 | shuffleArray, 7 | } from "../../components/MovieItemSlider"; 8 | import { getLibrary, getLibraryDir, getLibraryMeta } from "../../plex"; 9 | import { getIncludeProps } from "../../plex/QuickFunctions"; 10 | import { motion } from "framer-motion"; 11 | 12 | interface Category { 13 | title: string; 14 | dir: string; 15 | props?: { [key: string]: any }; 16 | filter?: (item: Plex.Metadata) => boolean; 17 | link: string; 18 | shuffle?: boolean; 19 | } 20 | 21 | function BrowseRecommendations() { 22 | const { libraryID } = useParams<{ libraryID: string }>(); 23 | const [library, setLibrary] = React.useState( 24 | null 25 | ); 26 | 27 | const [featuredItem, setFeaturedItem] = React.useState( 28 | null 29 | ); 30 | 31 | const [categories, setCategories] = React.useState([]); 32 | 33 | useEffect(() => { 34 | if (!libraryID) return; 35 | getLibrary(libraryID).then((data) => { 36 | setLibrary(data); 37 | }); 38 | }, [libraryID]); 39 | 40 | useEffect(() => { 41 | setFeaturedItem(null); 42 | setCategories([]); 43 | 44 | if (!library) return; 45 | getLibraryDir( 46 | `/library/sections/${library.librarySectionID.toString()}/unwatched` 47 | ).then(async (media) => { 48 | const data = media.Metadata; 49 | if (!data) return; 50 | const item = data[Math.floor(Math.random() * data.length)]; 51 | 52 | const meta = await getLibraryMeta(item.ratingKey); 53 | setFeaturedItem(meta); 54 | }); 55 | 56 | (async () => { 57 | let categoryPool: Category[] = []; 58 | 59 | const getGenres = new Promise((resolve) => { 60 | getLibraryDir( 61 | `/library/sections/${library.librarySectionID.toString()}/genre` 62 | ).then(async (media) => { 63 | const genres = media.Directory; 64 | if (!genres || !genres.length) return; 65 | const genreSelection: Plex.Directory[] = []; 66 | 67 | // Get 5 random genres 68 | while (genreSelection.length < Math.min(8, genres.length)) { 69 | const genre = genres[Math.floor(Math.random() * genres.length)]; 70 | if (genreSelection.includes(genre)) continue; 71 | genreSelection.push(genre); 72 | } 73 | 74 | resolve( 75 | shuffleArray(genreSelection).map((genre) => ({ 76 | title: genre.title, 77 | dir: `/library/sections/${library.librarySectionID}/genre/${genre.key}`, 78 | link: `/library/sections/${library.librarySectionID}/genre/${genre.key}`, 79 | shuffle: true, 80 | })) 81 | ); 82 | }); 83 | }); 84 | 85 | const getLastViewed = new Promise((resolve) => { 86 | getLibraryDir( 87 | `/library/sections/${library.librarySectionID.toString()}/all`, 88 | { 89 | type: library.Type?.[0].type === "movie" ? "1" : "2", 90 | sort: "lastViewedAt:desc", 91 | limit: "20", 92 | unwatched: "0", 93 | } 94 | ).then(async (media) => { 95 | let data = media.Metadata; 96 | if (!data) return resolve([]); 97 | resolve(data.filter((item) => ["movie", "show"].includes(item.type))); 98 | }); 99 | }); 100 | 101 | const [genres, lastViewed] = await Promise.all([ 102 | getGenres, 103 | getLastViewed, 104 | ]); 105 | 106 | if (lastViewed[0]) { 107 | const lastViewItem = await getLibraryMeta(lastViewed[0].ratingKey); 108 | 109 | if (lastViewItem?.Related?.Hub?.[0]?.Metadata?.[0]) { 110 | let shortenedTitle = lastViewItem.title; 111 | if (shortenedTitle.length > 40) 112 | shortenedTitle = `${shortenedTitle.slice(0, 40)}...`; 113 | 114 | categoryPool.push({ 115 | title: `Because you watched ${shortenedTitle}`, 116 | dir: lastViewItem.Related.Hub[0].hubKey, 117 | link: lastViewItem.Related.Hub[0].key, 118 | shuffle: true, 119 | }); 120 | } 121 | } 122 | // if lastviewed has more than 3 items, get some random item that isnt the first one and add a category called "More Like This" 123 | if (lastViewed.length > 3) { 124 | const randomItem = 125 | lastViewed[Math.floor(Math.random() * lastViewed.length)]; 126 | const randomMeta = await getLibraryMeta(randomItem.ratingKey); 127 | 128 | let shortenedTitle = randomMeta.title; 129 | if (shortenedTitle.length > 40) 130 | shortenedTitle = `${shortenedTitle.slice(0, 40)}...`; 131 | 132 | if (randomMeta?.Related?.Hub?.[0]?.Metadata?.[0]) { 133 | categoryPool.push({ 134 | title: `More Like ${shortenedTitle}`, 135 | dir: randomMeta.Related.Hub[0].hubKey, 136 | link: randomMeta.Related.Hub[0].key, 137 | shuffle: true, 138 | }); 139 | } 140 | } 141 | 142 | if (library.Type?.[0].type === "show") { 143 | categoryPool.push({ 144 | title: "Recently Added", 145 | dir: `/hubs/home/recentlyAdded`, 146 | link: ``, 147 | props: { 148 | type: "2", 149 | limit: "30", 150 | sectionID: library.librarySectionID, 151 | contentSectionID: library.librarySectionID, 152 | ...getIncludeProps(), 153 | }, 154 | filter: (item) => item.type === "show", 155 | }); 156 | } 157 | 158 | categoryPool = shuffleArray([...genres, ...categoryPool]); 159 | 160 | if (library.Type?.[0].type === "movie") { 161 | categoryPool.unshift({ 162 | title: "Recently Added", 163 | dir: `/library/sections/${library.librarySectionID}/recentlyAdded`, 164 | link: `/library/sections/${library.librarySectionID}/recentlyAdded`, 165 | }); 166 | categoryPool.unshift({ 167 | title: "New Releases", 168 | dir: `/library/sections/${library.librarySectionID}/newest`, 169 | link: `/library/sections/${library.librarySectionID}/newest`, 170 | }); 171 | } 172 | 173 | categoryPool.unshift({ 174 | title: "Continue Watching", 175 | dir: `/library/sections/${library.librarySectionID}/onDeck`, 176 | link: `/library/sections/${library.librarySectionID}/onDeck`, 177 | shuffle: false, 178 | }); 179 | 180 | setCategories(categoryPool); 181 | })(); 182 | }, [library]); 183 | 184 | if (!featuredItem || !categories || !library) 185 | return ( 186 | 201 | 202 | 203 | ); 204 | 205 | return ( 206 | 222 | 223 | 234 | {categories && 235 | categories.map((category, index) => ( 236 | 245 | ))} 246 | 247 | 248 | ); 249 | } 250 | 251 | export default BrowseRecommendations; 252 | -------------------------------------------------------------------------------- /frontend/src/pages/settings/SettingsInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Paper, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import FavoriteIcon from "@mui/icons-material/Favorite"; 4 | import GitHubIcon from "@mui/icons-material/GitHub"; 5 | import BugReportIcon from "@mui/icons-material/BugReport"; 6 | import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; 7 | 8 | function SettingsInfo() { 9 | return ( 10 | <> 11 | 20 | PerPlexed Logo 29 | 30 | 31 | 40 | 41 | Welcome to the Nevu Family! 42 | 43 | 44 | 45 | Hey there! Thanks for joining us on this journey to elevate your Plex 46 | experience. Nevu is crafted with passion by{" "} 47 | 57 | Ipmake 58 | {" "} 59 | and a community of amazing open-source contributors just like you! 60 | 61 | 62 | 63 | We're on a mission to supercharge your media library with smart 64 | features, beautiful interfaces, and thoughtful enhancements that make 65 | managing and enjoying your content a breeze. 66 | 67 | 68 | 69 | 80 | 81 | 92 | 93 | 94 | 105 | 108 | 109 | 110 | Fuel the Future of Nevu 111 | 112 | 113 | 114 | Your support makes all the difference! Every contribution helps us 115 | build new features, improve performance, and keep this project 116 | thriving for the entire community. 117 | 118 | 119 | 131 | 132 | 133 | 134 | ); 135 | } 136 | 137 | export default SettingsInfo; -------------------------------------------------------------------------------- /frontend/src/pages/settings/SettingsPlayback.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Box } from "@mui/material"; 2 | import React from "react"; 3 | import CheckBoxOption from "../../components/settings/CheckBoxOption"; 4 | import { useUserSettings } from "../../states/UserSettingsState"; 5 | 6 | function SettingsPlayback() { 7 | const { settings, setSetting } = useUserSettings(); 8 | 9 | return ( 10 | <> 11 | Experience - Playback 12 | 13 | 22 | 23 | 24 | { 29 | setSetting( 30 | "DISABLE_WATCHSCREEN_DARKENING", 31 | settings["DISABLE_WATCHSCREEN_DARKENING"] === "true" 32 | ? "false" 33 | : "true" 34 | ); 35 | }} 36 | /> 37 | 38 | { 43 | setSetting( 44 | "AUTO_MATCH_TRACKS", 45 | settings["AUTO_MATCH_TRACKS"] === "true" ? "false" : "true" 46 | ); 47 | }} 48 | /> 49 | 50 | { 55 | setSetting( 56 | "AUTO_NEXT_EP", 57 | settings["AUTO_NEXT_EP"] === "true" ? "false" : "true" 58 | ); 59 | }} 60 | /> 61 | 62 | 63 | ); 64 | } 65 | 66 | export default SettingsPlayback; 67 | -------------------------------------------------------------------------------- /frontend/src/pages/settings/SettingsRecommendations.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Box } from "@mui/material"; 2 | import React from "react"; 3 | 4 | function SettingsRecommendations() { 5 | return ( 6 | <> 7 | Experience - Recommendations 8 | 9 | 18 | 19 | 20 | WIP 21 | 22 | 23 | ); 24 | } 25 | export default SettingsRecommendations; -------------------------------------------------------------------------------- /frontend/src/pages/settings/_template.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Box } from "@mui/material"; 2 | import React from "react"; 3 | import CheckBoxOption from "../../components/settings/CheckBoxOption"; 4 | import { useUserSettings } from "../../states/UserSettingsState"; 5 | 6 | function _template() { 7 | const { settings, setSetting } = useUserSettings(); 8 | 9 | return ( 10 | <> 11 | Template Title 12 | 13 | 22 | 23 | 24 | {}} 28 | /> 29 | 30 | 31 | ); 32 | } 33 | 34 | export default _template; 35 | -------------------------------------------------------------------------------- /frontend/src/plex/QuickFunctions.ts: -------------------------------------------------------------------------------- 1 | import { ProxiedRequest } from "../backendURL"; 2 | import { useSessionStore } from "../states/SessionState"; 3 | 4 | export async function authedGet(url: string) { 5 | const res = await ProxiedRequest(url, "GET", { 6 | 'X-Plex-Token': localStorage.getItem("accessToken") as string, 7 | 'accept': 'application/json' 8 | }).catch((err) => { 9 | console.log(err); 10 | return { status: err.response?.status || 500, data: err.response?.data || 'Internal server error' } 11 | }); 12 | 13 | if (res.status === 200) return res.data; 14 | else return null; 15 | } 16 | 17 | export async function authedPost(url: string, body?: any) { 18 | const res = await ProxiedRequest(url, "POST", { 19 | 'X-Plex-Token': localStorage.getItem("accessToken") as string, 20 | 'accept': 'application/json' 21 | }, body).catch((err) => { 22 | console.log(err); 23 | return { status: err.response?.status || 500, data: err.response?.data || 'Internal server error' } 24 | }); 25 | 26 | if (res.status === 200) return res.data; 27 | else return null; 28 | } 29 | 30 | export async function authedPut(url: string, body: any) { 31 | const res = await ProxiedRequest(url, "PUT", { 32 | 'X-Plex-Token': localStorage.getItem("accessToken") as string, 33 | 'accept': 'application/json' 34 | }, body).catch((err) => { 35 | console.log(err); 36 | return { status: err.response?.status || 500, data: err.response?.data || 'Internal server error' } 37 | }); 38 | 39 | if (res.status === 200) return res.data; 40 | else return null; 41 | } 42 | 43 | export function queryBuilder(query: any) { 44 | return Object.keys(query) 45 | .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(query[k])) 46 | .join('&'); 47 | } 48 | 49 | export function getXPlexProps() { 50 | return { 51 | "X-Incomplete-Segments": "1", 52 | "X-Plex-Product": "NEVU", 53 | "X-Plex-Version": "0.1.0", 54 | "X-Plex-Client-Identifier": localStorage.getItem("clientID"), 55 | "X-Plex-Platform": getBrowserName(), 56 | "X-Plex-Platform-Version": getBrowserVersion(), 57 | "X-Plex-Features": "external-media,indirect-media,hub-style-list", 58 | "X-Plex-Model": "bundled", 59 | "X-Plex-Device": getBrowserName(), 60 | "X-Plex-Device-Name": getBrowserName(), 61 | "X-Plex-Device-Screen-Resolution": getResString(), 62 | "X-Plex-Token": localStorage.getItem("accessToken"), 63 | "X-Plex-Language": "en", 64 | "X-Plex-Session-Id": sessionStorage.getItem("sessionID"), 65 | "X-Plex-Session-Identifier": useSessionStore.getState().XPlexSessionID, 66 | "session": useSessionStore.getState().sessionID, 67 | } 68 | } 69 | 70 | export function getResString() { 71 | // use the screen resolution to determine the quality 72 | return `${window.screen.width}x${window.screen.height}`; 73 | } 74 | 75 | export function getIncludeProps() { 76 | return { 77 | includeDetails: 1, 78 | includeMarkers: 1, 79 | includeOnDeck: 1, 80 | includeChapters: 1, 81 | includeChildren: 1, 82 | includeExternalMedia: 1, 83 | includeExtras: 1, 84 | includeConcerts: 1, 85 | includeReviews: 1, 86 | includePreferences: 1, 87 | includeStations: 1, 88 | includeRelated: 1, 89 | } 90 | } 91 | 92 | export function makeid(length: number) { 93 | let result = ''; 94 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 95 | const charactersLength = characters.length; 96 | let counter = 0; 97 | while (counter < length) { 98 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 99 | counter += 1; 100 | } 101 | return result; 102 | } 103 | 104 | export function getBrowserName() { 105 | let userAgent = navigator.userAgent; 106 | let browserName = "Unknown"; 107 | 108 | // Check for different browsers 109 | const browsers = [ 110 | { name: "Chrome", identifier: "Chrome" }, 111 | { name: "Safari", identifier: "Safari" }, 112 | { name: "Opera", identifier: "Opera" }, 113 | { name: "Firefox", identifier: "Firefox" }, 114 | { name: "Internet Explorer", identifier: ["MSIE", "Trident"] } 115 | ]; 116 | 117 | for (const browser of browsers) { 118 | if (Array.isArray(browser.identifier)) { 119 | if (browser.identifier.some(id => userAgent.includes(id))) { 120 | browserName = browser.name; 121 | break; 122 | } 123 | } else if (userAgent.includes(browser.identifier)) { 124 | browserName = browser.name; 125 | break; 126 | } 127 | } 128 | 129 | return browserName; 130 | } 131 | 132 | export function getBrowserVersion() { 133 | let userAgent = navigator.userAgent; 134 | let browserVersion = "Unknown"; 135 | 136 | // Check for different browsers 137 | const browsers = [ 138 | { name: "Chrome", identifier: "Chrome" }, 139 | { name: "Safari", identifier: "Version" }, 140 | { name: "Opera", identifier: "OPR" }, 141 | { name: "Firefox", identifier: "Firefox" }, 142 | { name: "Internet Explorer", identifier: ["MSIE", "Trident"] } 143 | ]; 144 | 145 | for (const browser of browsers) { 146 | if (Array.isArray(browser.identifier)) { 147 | if (browser.identifier.some(id => userAgent.includes(id))) { 148 | browserVersion = userAgent.split(browser.identifier[0])[1].split(" ")[0]; 149 | break; 150 | } 151 | } else if (userAgent.includes(browser.identifier)) { 152 | browserVersion = userAgent.split(browser.identifier)[1].split(" ")[0]; 153 | break; 154 | } 155 | } 156 | 157 | return browserVersion; 158 | } 159 | 160 | export function getOS() { 161 | let userAgent = navigator.userAgent; 162 | let os = "Unknown"; 163 | 164 | // Check for different operating systems 165 | const operatingSystems = [ 166 | { name: "Windows", identifier: "Windows" }, 167 | { name: "Mac OS", identifier: "Mac OS" }, 168 | { name: "Linux", identifier: "Linux" } 169 | ]; 170 | 171 | for (const operatingSystem of operatingSystems) { 172 | if (userAgent.includes(operatingSystem.identifier)) { 173 | os = operatingSystem.name; 174 | break; 175 | } 176 | } 177 | 178 | return os; 179 | } 180 | 181 | // uuid generator 182 | export function uuidv4() { 183 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 184 | const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); 185 | return v.toString(16); 186 | }); 187 | } -------------------------------------------------------------------------------- /frontend/src/plex/plexCommunity.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useUserSessionStore } from "../states/UserSession"; 3 | 4 | export namespace PlexCommunity { 5 | export interface ReviewsResponse { 6 | data: ReviewsData; 7 | } 8 | 9 | export interface ReviewsData { 10 | userReview: ActivityReview | null; 11 | friendReviews: ReviewsSection; 12 | hotReviews: ReviewsSection; 13 | otherReviews: ReviewsSection; 14 | recentReviews: ReviewsSection; 15 | topReviews: ReviewsSection; 16 | } 17 | 18 | export interface ReviewsSection { 19 | nodes: ActivityReview[]; 20 | pageInfo: PageInfo; 21 | title: string; 22 | } 23 | 24 | export interface PageInfo { 25 | hasNextPage: boolean; 26 | } 27 | 28 | export interface ActivityReview { 29 | __typename: "ActivityReview"; 30 | commentCount: number; 31 | date: string; 32 | id: string; 33 | isMuted: boolean; 34 | isPrimary: boolean | null; 35 | privacy: "ANYONE" | string; 36 | reaction: string | null; 37 | reactionsCount: string; 38 | reactionsTypes: string[]; 39 | metadataItem: MetadataItem; 40 | userV2: UserV2; 41 | reviewRating: number; 42 | hasSpoilers: boolean; 43 | message: string; 44 | updatedAt: string | null; 45 | status: "PUBLISHED" | string; 46 | } 47 | 48 | export interface MetadataImages { 49 | coverArt: string; 50 | coverPoster: string; 51 | thumbnail: string; 52 | art: string; 53 | } 54 | 55 | export interface UserState { 56 | viewCount: number; 57 | viewedLeafCount: number; 58 | watchlistedAt: string | null; 59 | } 60 | 61 | export interface MetadataItem { 62 | id: string; 63 | images: MetadataImages; 64 | userState: UserState; 65 | title: string; 66 | key: string; 67 | type: "SHOW" | "MOVIE" | string; 68 | index: number; 69 | publicPagesURL: string; 70 | parent: any | null; // TOTYPE 71 | grandparent: any | null; // TOTYPE 72 | publishedAt: string; 73 | leafCount: number; 74 | year: number; 75 | originallyAvailableAt: string; 76 | childCount: number; 77 | } 78 | 79 | export interface MutualFriends { 80 | count: number; 81 | friends: any[]; 82 | } 83 | 84 | export interface UserV2 { 85 | id: string; 86 | username: string; 87 | displayName: string; 88 | avatar: string; 89 | friendStatus: string | null; 90 | isMuted: boolean; 91 | isHidden: boolean; 92 | isBlocked: boolean; 93 | mutualFriends: MutualFriends; 94 | } 95 | 96 | export async function getUserReviews(metadataID: string): Promise { 97 | try { 98 | const res = await axios.post("https://community.plex.tv/api", { 99 | variables: { 100 | metadataID: metadataID 101 | }, 102 | operationName: "getRatingsAndReviewsPageData", 103 | query: "query getRatingsAndReviewsPageData($metadataID: ID!, $skipUserState: Boolean = false) { userReview: metadataReviewV2( metadata: {id: $metadataID} ignoreFutureMetadata: true ) { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } friendReviews: metadataReviewsV2( metadata: {id: $metadataID} type: FRIENDS first: 25 after: null last: null before: null ) { nodes { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } pageInfo { hasNextPage } title } hotReviews: metadataReviewsV2( metadata: {id: $metadataID} type: HOT first: 25 after: null last: null before: null ) { nodes { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } pageInfo { hasNextPage } title } otherReviews: metadataReviewsV2( metadata: {id: $metadataID} type: OTHER first: 25 after: null last: null before: null ) { nodes { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } pageInfo { hasNextPage } title } recentReviews: metadataReviewsV2( metadata: {id: $metadataID} type: RECENT first: 25 after: null last: null before: null ) { nodes { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } pageInfo { hasNextPage } title } topReviews: metadataReviewsV2( metadata: {id: $metadataID} type: TOP first: 25 after: null last: null before: null ) { nodes { ... on ActivityRating { ...ActivityRatingFragment } ... on ActivityReview { ...ActivityReviewFragment } ... on ActivityWatchRating { ...ActivityWatchRatingFragment } ... on ActivityWatchReview { ...ActivityWatchReviewFragment } } pageInfo { hasNextPage } title }} fragment ActivityRatingFragment on ActivityRating { ...activityFragment rating} fragment activityFragment on Activity { __typename commentCount date id isMuted isPrimary privacy reaction reactionsCount reactionsTypes metadataItem { ...itemFields } userV2 { id username displayName avatar friendStatus isMuted isHidden isBlocked mutualFriends { count friends { avatar displayName id username } } }} fragment itemFields on MetadataItem { id images { coverArt coverPoster thumbnail art } userState @skip(if: $skipUserState) { viewCount viewedLeafCount watchlistedAt } title key type index publicPagesURL parent { ...parentFields } grandparent { ...parentFields } publishedAt leafCount year originallyAvailableAt childCount} fragment parentFields on MetadataItem { index title publishedAt key type images { coverArt coverPoster thumbnail art } userState @skip(if: $skipUserState) { viewCount viewedLeafCount watchlistedAt }} fragment ActivityWatchRatingFragment on ActivityWatchRating { ...activityFragment rating} fragment ActivityReviewFragment on ActivityReview { ...activityFragment reviewRating: rating hasSpoilers message updatedAt status updatedAt} fragment ActivityWatchReviewFragment on ActivityWatchReview { ...activityFragment reviewRating: rating hasSpoilers message updatedAt status updatedAt}" // Fuck you Plex 104 | }, { 105 | headers: { 106 | "x-plex-token": useUserSessionStore.getState().user?.authToken 107 | } 108 | }) 109 | 110 | return res.data.data; 111 | } catch (error) { 112 | console.error("Error fetching user reviews", error); 113 | return null; 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /frontend/src/plex/plextv.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { queryBuilder } from "./QuickFunctions"; 3 | 4 | export namespace PlexTv { 5 | /** 6 | * Adds a given item to the watchlist. 7 | * 8 | * @param {string} ratingKey - The rating key from the guid. 9 | * @returns {Promise} - A promise that resolves when the item has been successfully added to the watchlist. 10 | * 11 | * @throws Will log an error message to the console if the request fails. 12 | */ 13 | export async function addToWatchlist(ratingKey: string): Promise { 14 | try { 15 | await axios.put(`https://discover.provider.plex.tv/actions/addToWatchlist?ratingKey=${ratingKey}`, {}, { 16 | headers: { 17 | "X-Plex-Token": localStorage.getItem("accAccessToken") as string 18 | } 19 | }) 20 | } catch (error) { 21 | console.error("Error adding to watchlist", error); 22 | } 23 | } 24 | 25 | export async function removeFromWatchlist(ratingKey: string): Promise { 26 | try { 27 | await axios.put(`https://discover.provider.plex.tv/actions/removeFromWatchlist?ratingKey=${ratingKey}`, {}, { 28 | headers: { 29 | "X-Plex-Token": localStorage.getItem("accAccessToken") as string 30 | } 31 | }) 32 | } catch (error) { 33 | console.error("Error removing from watchlist", error); 34 | } 35 | } 36 | 37 | export async function getWatchlist(): Promise { 38 | try { 39 | const res = await axios.get(`https://discover.provider.plex.tv/library/sections/watchlist/all?${queryBuilder({ 40 | "X-Plex-Token": localStorage.getItem("accAccessToken") as string, 41 | "includeAdvanced": 1, 42 | "includeMeta": 1, 43 | "X-Plex-Container-Start": 0, 44 | "X-Plex-Container-Size": 300, 45 | })}`) 46 | return res.data.MediaContainer.Metadata; 47 | } catch (error) { 48 | return []; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /frontend/src/states/PreviewPlayerState.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface PreviewPlayerState { 4 | MetaScreenPlayerMuted: boolean; 5 | setMetaScreenPlayerMuted: (value: boolean) => void; 6 | } 7 | 8 | export const usePreviewPlayer = create((set) => ({ 9 | MetaScreenPlayerMuted: true, 10 | setMetaScreenPlayerMuted: (value) => set({ MetaScreenPlayerMuted: value }), 11 | })); -------------------------------------------------------------------------------- /frontend/src/states/SessionState.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { authedGet, makeid } from "../plex/QuickFunctions"; 3 | 4 | type SessionState = { 5 | sessionID: string; 6 | XPlexSessionID: string; 7 | PlexServer: Plex.ServerPreferences | null; 8 | generateSessionID: () => void; 9 | fetchPlexServer: () => void; 10 | }; 11 | 12 | export const useSessionStore = create((set) => ({ 13 | sessionID: makeid(24), 14 | XPlexSessionID: makeid(24), 15 | PlexServer: null, 16 | generateSessionID: () => { 17 | set({ sessionID: makeid(24), XPlexSessionID: makeid(24) }); 18 | }, 19 | fetchPlexServer: async () => { 20 | try { 21 | const res = await authedGet("/"); 22 | if (!res) return; 23 | 24 | set({ PlexServer: res.MediaContainer ?? null }); 25 | } catch (err) { 26 | console.log(err); 27 | } 28 | }, 29 | })); -------------------------------------------------------------------------------- /frontend/src/states/SyncSessionState.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { EventEmitter } from "events"; 3 | import { io, Socket } from "socket.io-client"; 4 | import { getBackendURL, isDev } from "../backendURL"; 5 | import { NavigateFunction } from "react-router-dom"; 6 | import { useToast } from "../components/ToastManager"; 7 | 8 | export interface SyncSessionState { 9 | socket: Socket | null; 10 | isHost: boolean; 11 | room: string | null; 12 | 13 | connect: (room?: string, navigate?: NavigateFunction) => Promise; 14 | disconnect: () => void; 15 | } 16 | 17 | export const SessionStateEmitter = new EventEmitter(); 18 | 19 | export const useSyncSessionState = create((set, get) => ({ 20 | socket: null, 21 | isHost: false, 22 | room: null, 23 | 24 | connect: async (room, navigate) => { 25 | return new Promise((resolve) => { 26 | const socket = isDev ? io(getBackendURL(), { 27 | auth: { 28 | token: localStorage.getItem("accAccessToken") 29 | }, 30 | query: { 31 | room: room || "new" 32 | }, 33 | autoConnect: false 34 | }) : io({ 35 | auth: { 36 | token: localStorage.getItem("accAccessToken") 37 | }, 38 | query: { 39 | room: room || "new" 40 | }, 41 | autoConnect: false 42 | }); 43 | 44 | console.log("Connecting to server"); 45 | 46 | socket.on("connect", async () => { 47 | console.log("Connected to server"); 48 | }); 49 | 50 | (new Promise((resolve) => { 51 | let resolved = false; 52 | 53 | socket.once("ready", (data) => { 54 | console.log("Ready", data); 55 | set({ isHost: data.host, room: data.room }); 56 | resolved = true; 57 | resolve(true); 58 | }); 59 | 60 | socket.once("conn-error", (data) => { 61 | console.log("Error", data); 62 | resolved = true; 63 | resolve(data); 64 | }); 65 | 66 | setTimeout(() => { 67 | if (!resolved) resolve({ 68 | type: "timeout", 69 | message: "Connection timed out" 70 | }); 71 | }, 5000); 72 | })) 73 | .then((result) => { 74 | if (result !== true) { 75 | socket.disconnect(); 76 | return resolve(result); 77 | } else { 78 | set({ socket }); 79 | resolve(true); 80 | SocketManager(navigate); 81 | } 82 | 83 | }) 84 | 85 | socket.connect(); 86 | 87 | socket.on("disconnect", () => { 88 | console.log("Disconnected from server"); 89 | set({ socket: null, isHost: false, room: null }); 90 | SessionStateEmitter.emit("disconnect"); 91 | }) 92 | }); 93 | }, 94 | disconnect: () => { 95 | get().socket?.disconnect(); 96 | set({ socket: null, isHost: false, room: null }); 97 | } 98 | })); 99 | 100 | function SocketManager(navigate: NavigateFunction | undefined) { 101 | const { socket, isHost } = useSyncSessionState.getState(); 102 | if (socket === null) return; 103 | 104 | 105 | if (isHost) 106 | { 107 | socket.on("HOST_SYNC_GET_PLAYBACK", () => { 108 | const { playBackState } = useSessionPlayBackCache.getState(); 109 | console.log("Sending playback state", playBackState); 110 | socket.emit("RES_SYNC_GET_PLAYBACK", playBackState); 111 | }); 112 | } 113 | else 114 | { 115 | socket.on("RES_SYNC_SET_PLAYBACK", (user: PerPlexed.Sync.Member, data: PerPlexed.Sync.PlayBackState) => { 116 | console.log("Playback state received", data); 117 | navigate?.(`/watch/${data.key}?t=${data.time}`); 118 | useToast.getState().addToast(user, "PlaySet", "Started Playback", 5000); 119 | }); 120 | 121 | socket.on("RES_SYNC_RESYNC_PLAYBACK", (user: PerPlexed.Sync.Member, data: PerPlexed.Sync.PlayBackState) => { 122 | console.log("Playback resync received", data); 123 | SessionStateEmitter.emit("PLAYBACK_RESYNC", data); 124 | }) 125 | 126 | socket.on("RES_SYNC_PLAYBACK_END", (user: PerPlexed.Sync.Member) => { 127 | SessionStateEmitter.emit("PLAYBACK_END"); 128 | }) 129 | } 130 | 131 | socket.on("EVNT_SYNC_PAUSE", (user: PerPlexed.Sync.Member) => { 132 | useToast.getState().addToast(user, "Pause", "Paused Playback", 5000); 133 | SessionStateEmitter.emit("PLAYBACK_PAUSE"); 134 | }) 135 | 136 | socket.on("EVNT_SYNC_RESUME", (user: PerPlexed.Sync.Member) => { 137 | useToast.getState().addToast(user, "Play", "Resumed Playback", 5000); 138 | SessionStateEmitter.emit("PLAYBACK_RESUME"); 139 | }) 140 | 141 | socket.on("EVNT_SYNC_SEEK", (user: PerPlexed.Sync.Member, time: number) => { 142 | SessionStateEmitter.emit("PLAYBACK_SEEK", time); 143 | }) 144 | 145 | socket.on("EVNT_USER_JOIN", (user: PerPlexed.Sync.Member) => { 146 | useToast.getState().addToast(user, "UserAdd", "Joined the session", 5000); 147 | }); 148 | 149 | socket.on("EVNT_USER_LEAVE", (user: PerPlexed.Sync.Member) => { 150 | useToast.getState().addToast(user, "UserRemove", "Left the session", 5000); 151 | }); 152 | 153 | 154 | } 155 | 156 | export interface SessionPlayBackCache { 157 | playBackState: PerPlexed.Sync.PlayBackState | null; 158 | 159 | update: (data: PerPlexed.Sync.PlayBackState) => void; 160 | } 161 | 162 | export const useSessionPlayBackCache = create((set, get) => ({ 163 | playBackState: null, 164 | 165 | update: (data) => { 166 | set({ playBackState: data }); 167 | } 168 | })); -------------------------------------------------------------------------------- /frontend/src/states/UserSession.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { getLoggedInUser } from "../plex"; 3 | 4 | interface UserSessionState { 5 | user: Plex.UserData | null; 6 | loadUser: () => void; 7 | } 8 | 9 | export const useUserSessionStore = create(( 10 | set, 11 | get 12 | ) => ({ 13 | user: null, 14 | loadUser: async () => { 15 | const res = await getLoggedInUser(); 16 | console.log("User session store", res); 17 | if (!res) return set({ user: null }); 18 | set({ 19 | user: res 20 | }); 21 | } 22 | })); 23 | 24 | useUserSessionStore.getState().loadUser(); -------------------------------------------------------------------------------- /frontend/src/states/UserSettingsState.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | import { getBackendURL } from '../backendURL'; 4 | 5 | type UserSettingsOptions = 6 | "DISABLE_WATCHSCREEN_DARKENING" | 7 | "AUTO_MATCH_TRACKS" | 8 | "AUTO_NEXT_EP" | 9 | string; 10 | 11 | export interface UserSettingsState { 12 | loaded: boolean; 13 | settings: { 14 | [key in UserSettingsOptions]: string; 15 | } 16 | setSetting: (key: UserSettingsOptions, value: string) => void; 17 | fetchSettings: () => void; 18 | } 19 | 20 | export const useUserSettings = create((set) => ({ 21 | loaded: false, 22 | settings: { 23 | DISABLE_WATCHSCREEN_DARKENING: "false", 24 | AUTO_MATCH_TRACKS: "true", 25 | AUTO_NEXT_EP: "true", 26 | }, 27 | setSetting: async (key, value) => { 28 | await axios.post(`${getBackendURL()}/user/options`, { 29 | key, 30 | value, 31 | }, { 32 | headers: { 33 | 'X-Plex-Token': localStorage.getItem("accAccessToken"), 34 | } 35 | }).then((res) => { 36 | // Handle response if needed 37 | }).catch((error) => { 38 | console.error("Failed to update setting:", error); 39 | }); 40 | 41 | set((state) => ({ 42 | settings: { 43 | ...state.settings, 44 | [key]: value, 45 | } 46 | })); 47 | }, 48 | fetchSettings: async () => { 49 | const settings = await axios.get(`${getBackendURL()}/user/options`, { 50 | headers: { 51 | 'X-Plex-Token': localStorage.getItem("accAccessToken"), 52 | } 53 | }).then((res) => { 54 | return res.data; 55 | }).catch(() => { 56 | return null; 57 | }); 58 | 59 | if (settings) { 60 | set((state) => { 61 | const newSettings = { ...state.settings }; 62 | settings.forEach((setting: { key: UserSettingsOptions; value: string }) => { 63 | newSettings[setting.key] = setting.value; 64 | }); 65 | 66 | return { settings: newSettings }; 67 | }); 68 | 69 | console.log(settings); 70 | } 71 | 72 | set({ loaded: true }); 73 | }, 74 | })); -------------------------------------------------------------------------------- /frontend/src/states/WatchListCache.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { PlexTv } from "../plex/plextv"; 3 | import { EventEmitter } from "events"; 4 | 5 | export const WatchListCacheEmitter = new EventEmitter(); 6 | 7 | interface WatchListCacheState { 8 | watchListCache: Plex.Metadata[]; 9 | setWatchListCache: (watchListCache: Plex.Metadata[]) => void; 10 | addItem: (item: Plex.Metadata) => void; 11 | removeItem: (item: string) => void; 12 | loadWatchListCache: () => void; 13 | isOnWatchList: (item: string) => boolean; 14 | } 15 | 16 | export const useWatchListCache = create((set) => ({ 17 | watchListCache: [], 18 | setWatchListCache: (watchListCache) => set({ watchListCache }), 19 | addItem: async (item) => { 20 | if (useWatchListCache.getState().watchListCache.includes(item)) return; 21 | await PlexTv.addToWatchlist(item.guid.split("/")[3]); 22 | set((state) => ({ watchListCache: [item, ...state.watchListCache] })) 23 | WatchListCacheEmitter.emit("watchListUpdate", item); 24 | }, 25 | removeItem: async (item) => { 26 | if (!useWatchListCache.getState().isOnWatchList(item)) return; 27 | await PlexTv.removeFromWatchlist(item.split("/")[3]); 28 | set((state) => ({ watchListCache: state.watchListCache.filter((i) => i.guid !== item) })); 29 | WatchListCacheEmitter.emit("watchListUpdate", item); 30 | }, 31 | loadWatchListCache: async () => { 32 | const watchList = await PlexTv.getWatchlist(); 33 | if(!watchList) return; 34 | set({ watchListCache: watchList }); 35 | }, 36 | isOnWatchList: (item): boolean => useWatchListCache.getState().watchListCache.find((i) => i.guid === item) !== undefined, 37 | })); -------------------------------------------------------------------------------- /frontend/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace PerPlexed { 2 | interface RecommendationShelf { 3 | title: string; 4 | libraryID: string; 5 | dir: string; 6 | link: string; 7 | } 8 | 9 | interface Status { 10 | ready: boolean; 11 | error: boolean; 12 | message: string; 13 | } 14 | 15 | interface Config { 16 | PLEX_SERVER: string; 17 | DEPLOYMENTID: string; 18 | CONFIG: ConfigOptions 19 | } 20 | 21 | interface ConfigOptions { 22 | DISABLE_PROXY: boolean; // DEPRECATED 23 | DISABLE_NEVU_SYNC: boolean; 24 | } 25 | 26 | namespace Sync { 27 | interface SocketError { 28 | type: string; 29 | message: string; 30 | } 31 | 32 | interface Ready { 33 | room: string; 34 | host: boolean; 35 | } 36 | 37 | interface PlayBackState { 38 | key?: string; 39 | state: string; 40 | time?: number; 41 | } 42 | 43 | interface Member { 44 | uid: string; 45 | socket: string; 46 | name: string; 47 | avatar: string; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | /* Visit https://aka.ms/tsconfig to read more about this file */ 5 | 6 | /* Projects */ 7 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 8 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 9 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 10 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 11 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 12 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 13 | 14 | /* Language and Environment */ 15 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | 28 | /* Modules */ 29 | "module": "commonjs", /* Specify what module code is generated. */ 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 38 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 39 | // "resolveJsonModule": true, /* Enable importing .json files. */ 40 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 41 | 42 | /* JavaScript Support */ 43 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 44 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 45 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 46 | 47 | /* Emit */ 48 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 49 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 50 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 51 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 52 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 53 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 54 | // "removeComments": true, /* Disable emitting comments. */ 55 | // "noEmit": true, /* Disable emitting files from a compilation. */ 56 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 57 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 58 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 59 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 62 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 63 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 64 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 65 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 66 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 67 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 68 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 69 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 70 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 71 | 72 | /* Interop Constraints */ 73 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 74 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 75 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 76 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 77 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 78 | 79 | /* Type Checking */ 80 | "strict": true, /* Enable all strict type-checking options. */ 81 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 82 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 83 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 84 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 85 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 86 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 87 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 88 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 89 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 90 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 91 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 92 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 93 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 94 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 95 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 96 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 97 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 98 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 99 | 100 | /* Completeness */ 101 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 102 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 103 | } 104 | } 105 | --------------------------------------------------------------------------------